diff --git a/Bond.Build/Bond.Build.csproj b/Bond.Build/Bond.Build.csproj index 07b64e9..ec1f1d7 100644 --- a/Bond.Build/Bond.Build.csproj +++ b/Bond.Build/Bond.Build.csproj @@ -27,7 +27,6 @@ compile; build; native; contentfiles; analyzers; buildtransitive - diff --git a/Bond.Build/GenerateBond.cs b/Bond.Build/GenerateBond.cs index ab50bf0..399ca25 100644 --- a/Bond.Build/GenerateBond.cs +++ b/Bond.Build/GenerateBond.cs @@ -1,90 +1,101 @@ using System; -using System.Globalization; +using System.Collections.Generic; using System.IO; using System.Linq; +using System.Security.Cryptography; using System.Text; -using System.Threading; +using Bond.Parser.CodeGeneration; +using Bond.Parser.Parser; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; namespace BondTools.Build; -public sealed class GenerateBond : Task, ICancelableTask +/// +/// Generates C# for each Bond item. Generation is cheap, so it always runs; outputs are only rewritten when their +/// content changes, which keeps the C# compilation incremental. +/// +public sealed class GenerateBond : Microsoft.Build.Utilities.Task { + public ITaskItem[] Sources { get; set; } = []; + [Required] - public string ProjectFile { get; set; } = ""; + public string ProjectDirectory { get; set; } = ""; [Required] public string OutputDirectory { get; set; } = ""; - public ITaskItem[] Sources { get; set; } = []; - public ITaskItem[] ImportDirectories { get; set; } = []; - public ITaskItem[] Usings { get; set; } = []; - public ITaskItem[] NamespaceMappings { get; set; } = []; - public ITaskItem[] TypeMappings { get; set; } = []; [Output] public ITaskItem[] GeneratedFiles { get; private set; } = []; - [Output] - public ITaskItem[] WrittenFiles { get; private set; } = []; - - private readonly CancellationTokenSource _cancellation = new(); - public override bool Execute() { - try + Directory.CreateDirectory(OutputDirectory); + var outputs = new List(); + foreach (var source in Sources) { - var request = BuildRequest.Create(ProjectFile, OutputDirectory, Sources, ImportDirectories, Usings, NamespaceMappings, TypeMappings); - var result = GenerationEngine.RunAsync(request, _cancellation.Token).GetAwaiter().GetResult(); - - foreach (var error in result.Errors) + var path = source.GetMetadata("FullPath"); + var output = Path.Combine(OutputDirectory, OutputName(path)); + outputs.Add(output); + try { - Log.LogError(null, "BOND1001", null, error.FilePath ?? ProjectFile, error.Line, error.Column, 0, 0, "{0}", error.Message); + Generate(source, path, output); } - - if (result.Errors.Count != 0) + catch (Exception error) when (error is IOException or UnauthorizedAccessException) { - return false; + Log.LogError(null, "BOND1001", null, path, 0, 0, 0, 0, "{0}", error.Message); } - - GeneratedFiles = result.GeneratedFiles.Select(CreateOutputItem).ToArray(); - WrittenFiles = result.WrittenFiles.Select(CreateOutputItem).ToArray(); - Log.LogMessage(MessageImportance.High, "{0}", result.Status); - return true; } - catch (OperationCanceledException) when (_cancellation.IsCancellationRequested) - { - Log.LogMessage(MessageImportance.High, "Bond generation cancelled."); - return false; - } - catch (Exception error) when (error is IOException or InvalidDataException or UnauthorizedAccessException - or ArgumentException or NotSupportedException) + + foreach (var stale in Directory.EnumerateFiles(OutputDirectory, "*.g.cs").Except(outputs)) { - Log.LogError(null, "BOND1001", null, ProjectFile, 0, 0, 0, 0, "{0}", error.Message); - return false; + if (File.ReadLines(stale).FirstOrDefault() == CSharpGenerator.GeneratedHeader) + { + File.Delete(stale); + } } + + GeneratedFiles = outputs.Select(output => (ITaskItem)new TaskItem(Escape(output))).ToArray(); + return !Log.HasLoggedErrors; } - private static ITaskItem CreateOutputItem(string path) + private void Generate(ITaskItem source, string path, string output) { - // TaskItem expects an MSBuild-escaped include, not a literal filesystem path. - var escaped = new StringBuilder(path.Length); - foreach (var character in path) + var options = new CSharpGenerationOptions { - switch (character) - { - case '%' or '*' or '?' or '@' or '$' or '(' or ')' or ';' or '\'': - escaped.Append('%'); - escaped.Append(((int)character).ToString("X2", CultureInfo.InvariantCulture)); - break; - default: - escaped.Append(character); - break; - } + UsingNamespaces = List(source, "Usings"), + NamespaceMappings = List(source, "NamespaceMappings"), + TypeMappings = List(source, "TypeMappings"), + ModelFeatures = Feature(source, "Clone", CSharpModelFeatures.Cloning) + | Feature(source, "Equality", CSharpModelFeatures.Equality) + | Feature(source, "ToString", CSharpModelFeatures.StringRepresentation) + }; + var imports = List(source, "ImportDirectories").Select(directory => Path.GetFullPath(directory, ProjectDirectory)); + var parsed = ParserFacade.ParseFileAsync(path, DefaultImportResolver.Create(imports)).GetAwaiter().GetResult(); + var generated = parsed.Success ? CSharpGenerator.Generate(parsed.Ast!, path, options) : null; + foreach (var error in generated?.Errors ?? parsed.Errors) + { + Log.LogError(null, "BOND1001", null, error.FilePath ?? path, error.Line, error.Column, 0, 0, "{0}", error.Message); } - return new TaskItem(escaped.ToString()); + if (generated is { Success: true } && !(File.Exists(output) && File.ReadAllText(output) == generated.Code)) + { + File.WriteAllText(output, generated.Code); + } } - public void Cancel() => _cancellation.Cancel(); + // Unique per source path, so schemas with the same name in different directories do not collide. + private static string OutputName(string source) => + Path.GetFileNameWithoutExtension(source) + "." + + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(source)))[..8].ToLowerInvariant() + ".g.cs"; + + private static string[] List(ITaskItem item, string name) => + item.GetMetadata(name).Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + private static CSharpModelFeatures Feature(ITaskItem item, string name, CSharpModelFeatures feature) => + string.Equals(item.GetMetadata(name), "true", StringComparison.OrdinalIgnoreCase) ? feature : CSharpModelFeatures.None; + + // Task item specs are MSBuild-escaped; literal paths may contain characters such as ';' or '%'. + private static string Escape(string path) => + string.Concat(path.Select(character => "%*?@$();'".Contains(character) ? $"%{(int)character:X2}" : character.ToString())); } diff --git a/Bond.Build/GenerationEngine.cs b/Bond.Build/GenerationEngine.cs deleted file mode 100644 index 883cf56..0000000 --- a/Bond.Build/GenerationEngine.cs +++ /dev/null @@ -1,282 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Bond.Parser.CodeGeneration; -using Bond.Parser.Parser; - -namespace BondTools.Build; - -internal sealed record GenerationResult( - string[] GeneratedFiles, - string[] WrittenFiles, - IReadOnlyList Errors, - string Status); - -internal sealed class GenerationFailureException(string path, Exception cause) : Exception(cause.Message, cause) -{ - internal ParseError Error { get; } = new(cause.Message, path, 0, 0); - - internal static bool IsExpected(Exception error) => - error is IOException or InvalidDataException or UnauthorizedAccessException or ArgumentException - or NotSupportedException or JsonException; -} - -internal static class GenerationEngine -{ - internal static async Task RunAsync(BuildRequest request, CancellationToken cancellationToken) - { - try - { - var (plan, errors) = await PrepareAsync(request, cancellationToken); - if (plan == null) - { - return new GenerationResult([], [], errors, ""); - } - - return await plan.ApplyAsync(cancellationToken); - } - catch (GenerationFailureException error) - { - return new GenerationResult([], [], [error.Error], ""); - } - } - - private static async Task<(GenerationPlan? Plan, IReadOnlyList Errors)> PrepareAsync( - BuildRequest request, CancellationToken cancellationToken) - { - var (previous, identity) = await ReadPreviousGenerationAsync(request, cancellationToken); - var generatorUnchanged = previous?.GeneratorIdentity == identity; - var oldEntries = previous?.Entries.ToDictionary(entry => entry.Source, BuildFiles.PathComparer) - ?? new Dictionary(BuildFiles.PathComparer); - var outputs = new List(); - var errors = new List(); - - foreach (var input in request.Inputs) - { - cancellationToken.ThrowIfCancellationRequested(); - oldEntries.Remove(input.Source, out var oldEntry); - var (output, inputErrors) = await PrepareOutputAsync( - input, request.OutputDirectory, generatorUnchanged ? oldEntry : null, cancellationToken); - if (output != null) - { - outputs.Add(output); - } - else - { - errors.AddRange(inputErrors); - } - } - - // Stale-output ownership errors take precedence over collected schema diagnostics. - var removed = await FindRemovedOutputsAsync(request.OutputDirectory, oldEntries.Values, cancellationToken); - if (errors.Count != 0) - { - return (null, errors); - } - - return (new GenerationPlan(request, identity, outputs.ToArray(), removed), []); - } - - private static async Task<(BuildManifest? Manifest, string GeneratorIdentity)> ReadPreviousGenerationAsync( - BuildRequest request, CancellationToken cancellationToken) - { - var errorPath = request.OutputDirectory; - try - { - BuildFiles.EnsureSafePath(request.OutputDirectory, directory: true); - errorPath = Path.Combine(request.OutputDirectory, "manifest.json"); - var previous = await BuildManifest.ReadAsync(errorPath, request, cancellationToken); - var identity = await GeneratorIdentityAsync(cancellationToken); - return (previous, identity); - } - catch (Exception error) when (GenerationFailureException.IsExpected(error)) - { - throw new GenerationFailureException(errorPath, error); - } - } - - private static async Task<(PreparedOutput? Output, IReadOnlyList Errors)> PrepareOutputAsync( - BuildInput input, string outputDirectory, BuildManifestEntry? cachedEntry, CancellationToken cancellationToken) - { - var errorPath = outputDirectory; - try - { - var outputPath = BuildFiles.OutputPath(outputDirectory, input.Output); - errorPath = outputPath; - var existingOutput = await BuildFiles.ReadOwnedOutputAsync(outputPath, cancellationToken); - var optionsHash = BuildFiles.HashText(JsonSerializer.Serialize(input)); - - if (cachedEntry != null - && await CanReuseOutputAsync(cachedEntry, optionsHash, existingOutput, cancellationToken)) - { - return (new PreparedOutput(outputPath, cachedEntry, null, Reused: true), []); - } - - errorPath = input.Source; - var (generated, dependencies) = await GenerateInputAsync(input, cancellationToken); - if (!generated.Success) - { - return (null, generated.Errors); - } - - var content = Encoding.UTF8.GetBytes(generated.Code!); - var entry = new BuildManifestEntry - { - Source = input.Source, - Output = input.Output, - OptionsHash = optionsHash, - OutputHash = BuildFiles.Hash(content), - Dependencies = dependencies - }; - var contentChanged = existingOutput == null || !existingOutput.AsSpan().SequenceEqual(content); - - return (new PreparedOutput(outputPath, entry, contentChanged ? content : null, Reused: false), []); - } - catch (Exception error) when (GenerationFailureException.IsExpected(error)) - { - throw new GenerationFailureException(errorPath, error); - } - } - - private static async Task FindRemovedOutputsAsync( - string outputDirectory, IEnumerable oldEntries, CancellationToken cancellationToken) - { - var removed = new List(); - foreach (var entry in oldEntries) - { - var path = BuildFiles.OutputPath(outputDirectory, entry.Output); - try - { - if (await BuildFiles.ReadOwnedOutputAsync(path, cancellationToken) != null) - { - removed.Add(path); - } - } - catch (Exception error) when (GenerationFailureException.IsExpected(error)) - { - throw new GenerationFailureException(path, error); - } - } - - return removed.ToArray(); - } - - private static async Task<(CSharpGenerationResult Result, BuildDependency[] Dependencies)> GenerateInputAsync( - BuildInput input, CancellationToken cancellationToken) - { - var dependencies = new Dictionary(BuildFiles.PathComparer); - - async Task ReadSourceAsync(string path) - { - var bytes = await BuildFiles.ReadIfPresentAsync(path, cancellationToken); - - // Missing candidates also matter: a new higher-priority import must invalidate the cache. - dependencies[path] = bytes == null ? null : BuildFiles.Hash(bytes); - return bytes == null ? null : BuildFiles.Text(bytes); - } - - ImportResolver resolver = async (importingFile, import) => - { - var importPath = import.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar); - var directories = new[] { Path.GetDirectoryName(importingFile)! }.Concat(input.ImportDirectories); - - foreach (var directory in directories) - { - var candidate = BuildFiles.FullPath(importPath, directory); - var content = await ReadSourceAsync(candidate); - if (content != null) - { - return (candidate, content); - } - } - - throw new FileNotFoundException($"Imported file not found: {import}", import); - }; - - var source = await ReadSourceAsync(input.Source); - if (source == null) - { - return (new CSharpGenerationResult(null, - [new ParseError("Bond input file was not found.", input.Source, 0, 0)]), []); - } - - var parsed = await ParserFacade.ParseContentAsync(source, input.Source, resolver); - cancellationToken.ThrowIfCancellationRequested(); - if (!parsed.Success) - { - IReadOnlyList errors = parsed.Errors.Count != 0 - ? parsed.Errors - : [new ParseError("Parsing produced no schema.", input.Source, 0, 0)]; - return (new CSharpGenerationResult(null, errors), []); - } - - var generated = CSharpGenerator.Generate(parsed.Ast!, input.Source, input.Options); - if (!generated.Success && generated.Errors.Count == 0) - { - generated = new CSharpGenerationResult(null, - [new ParseError("C# generation produced no output.", input.Source, 0, 0)]); - } - - var dependencyHashes = dependencies - .OrderBy(pair => pair.Key, StringComparer.Ordinal) - .Select(pair => new BuildDependency(pair.Key, pair.Value)) - .ToArray(); - - return (generated, dependencyHashes); - } - - private static async Task CanReuseOutputAsync(BuildManifestEntry entry, string optionsHash, - byte[]? content, CancellationToken cancellationToken) - { - if (content == null || entry.OptionsHash != optionsHash) - { - return false; - } - - if (BuildFiles.Hash(content) != entry.OutputHash) - { - return false; - } - - return await DependenciesMatchAsync(entry.Dependencies, cancellationToken); - } - - private static async Task DependenciesMatchAsync(IEnumerable dependencies, CancellationToken cancellationToken) - { - foreach (var dependency in dependencies) - { - var bytes = await BuildFiles.ReadIfPresentAsync(dependency.Path, cancellationToken); - if ((bytes == null ? null : BuildFiles.Hash(bytes)) != dependency.Hash) - { - return false; - } - } - - return true; - } - - private static async Task GeneratorIdentityAsync(CancellationToken cancellationToken) - { - var assemblies = new[] - { - typeof(GenerationEngine).Assembly, - typeof(ParserFacade).Assembly, - typeof(Antlr4.Runtime.AntlrInputStream).Assembly - }; - var identities = new List { Environment.Version.ToString() }; - - foreach (var assembly in assemblies.OrderBy(assembly => assembly.GetName().Name, StringComparer.Ordinal)) - { - // A reused build node can still hold an older module after files are replaced. - var hash = BuildFiles.Hash(await File.ReadAllBytesAsync(assembly.Location, cancellationToken)); - identities.Add($"{assembly.GetName().Name}:{assembly.ManifestModule.ModuleVersionId}:{hash}"); - } - - return BuildFiles.HashText(string.Join("\n", identities)); - } -} diff --git a/Bond.Build/GenerationFiles.cs b/Bond.Build/GenerationFiles.cs deleted file mode 100644 index 7c7a89b..0000000 --- a/Bond.Build/GenerationFiles.cs +++ /dev/null @@ -1,174 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Bond.Parser.CodeGeneration; - -namespace BondTools.Build; - -internal static class BuildFiles -{ - internal static readonly StringComparer PathComparer = OperatingSystem.IsWindows() - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; - - internal static string FullPath(string path, string? baseDirectory = null) - { - if (string.IsNullOrWhiteSpace(path) || path.Any(char.IsControl)) - { - throw new InvalidDataException("Bond paths must be nonempty and cannot contain control characters."); - } - - return baseDirectory == null ? Path.GetFullPath(path) : Path.GetFullPath(path, baseDirectory); - } - - internal static bool IsRelativeChild(string path) => - !Path.IsPathRooted(path) && path != "." && path != ".." - && !path.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) - && !path.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal); - - internal static string OutputPath(string root, string relative) - { - if (!IsRelativeChild(relative)) - { - throw new InvalidDataException("A Bond output path escapes its intermediate directory."); - } - - var path = FullPath(relative, root); - if (!IsRelativeChild(Path.GetRelativePath(root, path))) - { - throw new InvalidDataException("A Bond output path escapes its intermediate directory."); - } - - return path; - } - - internal static string Hash(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)); - internal static string HashText(string text) => Hash(Encoding.UTF8.GetBytes(text)); - - internal static bool IsHash(string? value) => - value is { Length: 64 } && value.All(character => Uri.IsHexDigit(character)); - - internal static async Task ReadIfPresentAsync(string path, CancellationToken cancellationToken) - { - try - { - return await File.ReadAllBytesAsync(path, cancellationToken); - } - catch (FileNotFoundException) - { - return null; - } - catch (DirectoryNotFoundException) - { - return null; - } - } - - internal static string Text(byte[] bytes) - { - using var stream = new MemoryStream(bytes, writable: false); - using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); - return reader.ReadToEnd(); - } - - internal static void EnsureSafePath(string path, bool directory) - { - var current = path; - while (current != null) - { - FileAttributes? attributes = null; - try - { - attributes = File.GetAttributes(current); - } - catch (FileNotFoundException) - { - } - catch (DirectoryNotFoundException) - { - } - - if (attributes is { } existing) - { - if ((existing & FileAttributes.ReparsePoint) != 0) - { - throw new IOException($"Bond output paths cannot be symbolic links: {current}"); - } - - if (((existing & FileAttributes.Directory) != 0) != directory) - { - throw new IOException($"Bond output path has the wrong file/directory kind: {current}"); - } - } - - var parent = Path.GetDirectoryName(current); - if (parent != null && Directory.Exists(parent)) - { - var name = Path.GetFileName(current); - foreach (var entry in Directory.EnumerateFileSystemEntries(parent)) - { - if (string.Equals(Path.GetFileName(entry), name, StringComparison.OrdinalIgnoreCase) - && !string.Equals(Path.GetFileName(entry), name, StringComparison.Ordinal)) - { - throw new IOException($"Bond output path collides with an existing entry (case-insensitive): {current}"); - } - } - } - - current = parent; - directory = true; - } - } - - internal static async Task ReadOwnedOutputAsync(string path, CancellationToken cancellationToken) - { - EnsureSafePath(path, directory: false); - var bytes = await ReadIfPresentAsync(path, cancellationToken); - if (bytes == null) - { - return null; - } - - var text = Text(bytes); - if (text != CSharpGenerator.GeneratedHeader - && !text.StartsWith(CSharpGenerator.GeneratedHeader + "\n", StringComparison.Ordinal) - && !text.StartsWith(CSharpGenerator.GeneratedHeader + "\r\n", StringComparison.Ordinal)) - { - throw new IOException($"Refusing to overwrite or remove a non-generated file: {path}"); - } - - return bytes; - } - - internal static async Task WriteAtomicAsync(string path, byte[] bytes, CancellationToken cancellationToken) - { - EnsureSafePath(path, directory: false); - var existing = await ReadIfPresentAsync(path, cancellationToken); - if (existing != null && existing.AsSpan().SequenceEqual(bytes)) - { - return; - } - - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - var temporary = path + "." + Guid.NewGuid().ToString("N") + ".new"; - try - { - await using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 4096, useAsync: true)) - { - await stream.WriteAsync(bytes, cancellationToken); - await stream.FlushAsync(cancellationToken); - } - - EnsureSafePath(path, directory: false); - File.Move(temporary, path, overwrite: true); - } - finally - { - File.Delete(temporary); - } - } -} diff --git a/Bond.Build/GenerationManifest.cs b/Bond.Build/GenerationManifest.cs deleted file mode 100644 index a07e511..0000000 --- a/Bond.Build/GenerationManifest.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; - -namespace BondTools.Build; - -internal sealed record BuildDependency( - [property: JsonRequired] string Path, - [property: JsonRequired] string? Hash); - -internal sealed record BuildManifestEntry -{ - public required string Source { get; init; } - public required string Output { get; init; } - public required string OptionsHash { get; init; } - public required string OutputHash { get; init; } - public required BuildDependency[] Dependencies { get; init; } -} - -internal sealed record BuildManifest -{ - public required int Version { get; init; } - public required string ProjectFile { get; init; } - public required string OutputDirectory { get; init; } - public required string GeneratorIdentity { get; init; } - public required string RequestHash { get; init; } - public required BuildManifestEntry[] Entries { get; init; } - - internal static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow - }; - - internal static async Task ReadAsync(string path, BuildRequest request, CancellationToken cancellationToken) - { - BuildFiles.EnsureSafePath(path, directory: false); - var bytes = await BuildFiles.ReadIfPresentAsync(path, cancellationToken); - if (bytes == null) - { - return null; - } - - try - { - var manifest = ValidateIdentity(JsonSerializer.Deserialize(bytes, JsonOptions), request); - ValidateEntries(manifest.Entries, request); - return manifest; - } - catch (Exception error) when (error is JsonException or InvalidDataException or ArgumentException or NotSupportedException) - { - throw new InvalidDataException($"Bond manifest '{path}' is invalid. Run dotnet clean and rebuild, " + - $"or remove this manifest and rebuild. {error.Message}", error); - } - } - - private static BuildManifest ValidateIdentity(BuildManifest? manifest, BuildRequest request) - { - // JSON can contain explicit nulls even for required, non-nullable properties. - if (manifest == null || manifest.Version != 1 - || !BuildFiles.PathComparer.Equals(manifest.ProjectFile, request.ProjectFile) - || !BuildFiles.PathComparer.Equals(manifest.OutputDirectory, request.OutputDirectory) - || !BuildFiles.IsHash(manifest.GeneratorIdentity) || !BuildFiles.IsHash(manifest.RequestHash) - || manifest.Entries == null) - { - throw new InvalidDataException("Invalid manifest identity or version."); - } - - return manifest; - } - - private static void ValidateEntries(BuildManifestEntry[] entries, BuildRequest request) - { - var sources = new HashSet(BuildFiles.PathComparer); - var outputs = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var entry in entries) - { - if (entry == null || !CanonicalPath(entry.Source) || !sources.Add(entry.Source) - || !string.Equals(Path.GetExtension(entry.Source), ".bond", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidDataException("Invalid manifest output entry."); - } - - if (entry.Output != BuildRequest.OutputFor(request.ProjectFile, entry.Source) || !outputs.Add(entry.Output)) - { - throw new InvalidDataException("Invalid manifest output entry."); - } - - if (!BuildFiles.IsHash(entry.OptionsHash) || !BuildFiles.IsHash(entry.OutputHash) || entry.Dependencies == null) - { - throw new InvalidDataException("Invalid manifest output entry."); - } - - BuildFiles.OutputPath(request.OutputDirectory, entry.Output); - ValidateDependencies(entry); - } - } - - private static void ValidateDependencies(BuildManifestEntry entry) - { - var paths = new HashSet(BuildFiles.PathComparer); - foreach (var dependency in entry.Dependencies) - { - if (dependency == null || !CanonicalPath(dependency.Path) || !paths.Add(dependency.Path) - || (dependency.Hash != null && !BuildFiles.IsHash(dependency.Hash))) - { - throw new InvalidDataException("Invalid manifest dependency."); - } - } - - if (!entry.Dependencies.Any(dependency => - BuildFiles.PathComparer.Equals(dependency.Path, entry.Source) && dependency.Hash != null)) - { - throw new InvalidDataException("Manifest does not include the root input hash."); - } - } - - private static bool CanonicalPath(string? path) => - !string.IsNullOrWhiteSpace(path) && Path.IsPathFullyQualified(path) - && string.Equals(path, BuildFiles.FullPath(path), StringComparison.Ordinal); -} diff --git a/Bond.Build/GenerationPlan.cs b/Bond.Build/GenerationPlan.cs deleted file mode 100644 index 88ae296..0000000 --- a/Bond.Build/GenerationPlan.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -namespace BondTools.Build; - -internal sealed record PreparedOutput(string Path, BuildManifestEntry Entry, byte[]? NewContent, bool Reused); - -internal sealed record GenerationPlan( - BuildRequest Request, - string GeneratorIdentity, - PreparedOutput[] Outputs, - string[] RemovedOutputs) -{ - internal async Task ApplyAsync(CancellationToken cancellationToken) - { - foreach (var output in Outputs) - { - if (output.NewContent == null) - { - continue; - } - - try - { - // Recheck ownership in case a file changed after preparation. - await BuildFiles.ReadOwnedOutputAsync(output.Path, cancellationToken); - await BuildFiles.WriteAtomicAsync(output.Path, output.NewContent, cancellationToken); - } - catch (Exception error) when (GenerationFailureException.IsExpected(error)) - { - throw new GenerationFailureException(output.Path, error); - } - } - - foreach (var path in RemovedOutputs) - { - try - { - await BuildFiles.ReadOwnedOutputAsync(path, cancellationToken); - File.Delete(path); - } - catch (Exception error) when (GenerationFailureException.IsExpected(error)) - { - throw new GenerationFailureException(path, error); - } - } - - var manifestPath = Path.Combine(Request.OutputDirectory, "manifest.json"); - await SaveManifestAsync(manifestPath, cancellationToken); - - var skippedCount = Outputs.Count(output => output.Reused); - var generatedCount = Outputs.Length - skippedCount; - var status = generatedCount == 0 && RemovedOutputs.Length == 0 - ? $"Bond: up-to-date ({skippedCount} schemas)." - : $"Bond: generated {generatedCount}, skipped {skippedCount}, removed {RemovedOutputs.Length}."; - - // MSBuild needs every compile and clean item, including outputs reused without a write. - var generatedFiles = Outputs.Select(output => output.Path).ToArray(); - return new GenerationResult(generatedFiles, generatedFiles.Append(manifestPath).ToArray(), [], status); - } - - private async Task SaveManifestAsync(string path, CancellationToken cancellationToken) - { - try - { - var manifest = new BuildManifest - { - Version = 1, - ProjectFile = Request.ProjectFile, - OutputDirectory = Request.OutputDirectory, - GeneratorIdentity = GeneratorIdentity, - RequestHash = BuildFiles.HashText(JsonSerializer.Serialize(Request)), - Entries = Outputs.Select(output => output.Entry).ToArray() - }; - await BuildFiles.WriteAtomicAsync(path, - JsonSerializer.SerializeToUtf8Bytes(manifest, BuildManifest.JsonOptions), cancellationToken); - } - catch (Exception error) when (GenerationFailureException.IsExpected(error)) - { - throw new GenerationFailureException(path, error); - } - } -} diff --git a/Bond.Build/GenerationRequest.cs b/Bond.Build/GenerationRequest.cs deleted file mode 100644 index 6e16927..0000000 --- a/Bond.Build/GenerationRequest.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Bond.Parser.CodeGeneration; -using Microsoft.Build.Framework; - -namespace BondTools.Build; - -internal sealed record BuildInput( - string Source, - string Output, - string[] ImportDirectories, - CSharpGenerationOptions Options); - -internal sealed record BuildRequest(string ProjectFile, string OutputDirectory, BuildInput[] Inputs) -{ - internal static BuildRequest Create(string projectFile, string outputDirectory, ITaskItem[] sources, - ITaskItem[] importDirectories, ITaskItem[] usings, ITaskItem[] namespaceMappings, ITaskItem[] typeMappings) - { - var project = BuildFiles.FullPath(projectFile); - var projectDirectory = Path.GetDirectoryName(project)!; - outputDirectory = Path.TrimEndingDirectorySeparator(BuildFiles.FullPath(outputDirectory, projectDirectory)); - if (outputDirectory == Path.GetPathRoot(outputDirectory) || BuildFiles.PathComparer.Equals(outputDirectory, projectDirectory)) - { - throw new InvalidDataException("Bond outputs must use a dedicated intermediate directory."); - } - - var inputs = new List(); - var sourcePaths = new HashSet(BuildFiles.PathComparer); - var outputs = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var item in sources) - { - var source = BuildFiles.FullPath(item.ItemSpec, projectDirectory); - if (!string.Equals(Path.GetExtension(source), ".bond", StringComparison.OrdinalIgnoreCase) - || string.IsNullOrEmpty(Path.GetFileNameWithoutExtension(source))) - { - throw new InvalidDataException($"Input must be a named .bond file: {source}"); - } - - if (!sourcePaths.Add(source)) - { - throw new InvalidDataException($"Bond input is included more than once: {source}"); - } - - var output = OutputFor(project, source); - if (!outputs.Add(output)) - { - throw new InvalidDataException($"Bond output paths collide (case-insensitive): {output}"); - } - - var features = ReadFeatures(item, source); - var imports = CombinedMetadata(item, "ImportDirectories", importDirectories) - .Select(value => BuildFiles.FullPath(value, projectDirectory)) - .ToArray(); - var options = new CSharpGenerationOptions - { - UsingNamespaces = CombinedMetadata(item, "Usings", usings), - NamespaceMappings = CombinedMetadata(item, "NamespaceMappings", namespaceMappings), - TypeMappings = CombinedMetadata(item, "TypeMappings", typeMappings), - ModelFeatures = features - }; - - inputs.Add(new BuildInput(source, output, imports, options)); - } - - return new BuildRequest(project, outputDirectory, inputs.ToArray()); - } - - private static CSharpModelFeatures ReadFeatures(ITaskItem item, string source) - { - var features = CSharpModelFeatures.None; - var flags = new[] - { - ("Descriptors", CSharpModelFeatures.Descriptors), - ("Clone", CSharpModelFeatures.Cloning), - ("Equality", CSharpModelFeatures.Equality), - ("Debugger", CSharpModelFeatures.Debugger), - ("ToString", CSharpModelFeatures.StringRepresentation) - }; - - foreach (var (name, flag) in flags) - { - var value = item.GetMetadata(name); - if (!bool.TryParse(value, out var enabled)) - { - throw new InvalidDataException($"Bond {name} for '{source}' must be true or false, not '{value}'."); - } - - if (enabled) - { - features |= flag; - } - } - - return features; - } - - private static string[] CombinedMetadata(ITaskItem item, string name, ITaskItem[] shared) - { - var local = item.GetMetadata(name).Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - return shared.Select(option => option.ItemSpec.Trim()).Concat(local).ToArray(); - } - - internal static string OutputFor(string projectFile, string source) - { - var relative = Path.GetRelativePath(Path.GetDirectoryName(projectFile)!, source); - var directory = BuildFiles.IsRelativeChild(relative) - ? Path.Combine("sources", BuildFiles.HashText(relative.Replace(Path.DirectorySeparatorChar, '/'))) - : Path.Combine("external", BuildFiles.HashText(source)); - return Path.Combine(directory, Path.GetFileNameWithoutExtension(source) + ".g.cs"); - } -} diff --git a/Bond.Build/README.md b/Bond.Build/README.md index 1103bcd..c72933c 100644 --- a/Bond.Build/README.md +++ b/Bond.Build/README.md @@ -1,11 +1,23 @@ # BondTools.Build -In-process generation with .NET 8+ SDK MSBuild. +Generates C# for `Bond` items during `dotnet build` (.NET 8+ SDK). ```xml + + + true + true + true + Schemas/Shared + System.Collections.Generic + Contracts=MyApp.Contracts + Contracts.Timestamp=System.DateTime + ``` + +Lists are separated by `;`. Generated files live in the intermediate output directory. diff --git a/Bond.Build/build/BondTools.Build.props b/Bond.Build/build/BondTools.Build.props deleted file mode 100644 index 0c7d52a..0000000 --- a/Bond.Build/build/BondTools.Build.props +++ /dev/null @@ -1,9 +0,0 @@ - - - false - false - false - false - false - - diff --git a/Bond.Build/build/BondTools.Build.targets b/Bond.Build/build/BondTools.Build.targets index 326307d..727e51b 100644 --- a/Bond.Build/build/BondTools.Build.targets +++ b/Bond.Build/build/BondTools.Build.targets @@ -1,48 +1,36 @@ - - true - - - $(BondDescriptors) + $(BondImportDirectories) + $(BondUsings) + $(BondNamespaceMappings) + $(BondTypeMappings) $(BondClone) $(BondEquality) - $(BondDebugger) $(BondToString) - + + + + + + - <_BondOutputDirectory>$([System.IO.Path]::Combine('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)', 'bond')) - <_BondUnsupportedSdk Condition="'$(NETCoreSdkVersion)' != ''">$([MSBuild]::VersionLessThan('$(NETCoreSdkVersion)', '8.0')) + <_BondOutputDirectory>$([MSBuild]::NormalizeDirectory('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)', 'bond')) - + - - - + + + - - - - - diff --git a/Bond.Models/Bond.Models.csproj b/Bond.Models/Bond.Models.csproj deleted file mode 100644 index 4b58dbd..0000000 --- a/Bond.Models/Bond.Models.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - net8.0 - enable - true - true - BondTools.Models - Bond Generated Model Support - BondTools.Models - Mirco De Zorzi - Support for generated Bond C# descriptors, cloning, equality, and debugger views. This package does not provide serialization. - bond;codegen;models;metadata - README.md - LICENSE - - - - - - diff --git a/Bond.Models/ModelAdapterArguments.cs b/Bond.Models/ModelAdapterArguments.cs deleted file mode 100644 index 4e0a78e..0000000 --- a/Bond.Models/ModelAdapterArguments.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace BondTools.Models; - -/// Statically supplied operations for a closed generic model argument. -public sealed class ModelAdapterArgument -{ - private ModelAdapterArgument(Type type, object adapter) - { - Type = type; - Adapter = adapter; - } - - internal Type Type { get; } - internal object Adapter { get; } - - /// Supplies an argument's operations without inspecting its CLR members. - public static ModelAdapterArgument Create(IModelAdapter adapter) - { - ArgumentNullException.ThrowIfNull(adapter); - return new(typeof(T), adapter); - } -} - -internal sealed class ModelAdapterFrame(IReadOnlyList arguments, ModelAdapterFrame? parent) -{ - internal ModelBindingSemantics? Semantics { get; } = GetSemantics(arguments, parent); - - internal IModelAdapter? Find() - { - foreach (var argument in arguments) - { - if (argument.Type == typeof(T)) - { - return (IModelAdapter)argument.Adapter; - } - } - - return parent?.Find(); - } - - private static ModelBindingSemantics? GetSemantics( - IReadOnlyList arguments, ModelAdapterFrame? parent) - { - var bindings = parent?.Semantics is { } inherited - ? new Dictionary(inherited.Bindings) - : new Dictionary(); - var seen = new HashSet(); - foreach (var argument in arguments) - { - if (!seen.Add(argument.Type)) - { - continue; - } - - // Standard generated bindings do not change semantics; masks also remove outer overrides. - if (ModelAdapterSemantics.ForBinding(argument.Adapter) is { } semantics) - { - bindings[argument.Type] = semantics; - } - else - { - bindings.Remove(argument.Type); - } - } - - return bindings.Count == 0 ? null : new ModelBindingSemantics(bindings); - } -} - -internal sealed class ArgumentModelAdapter : IModelAdapter, IModelAdapterSemantics -{ - private readonly IModelAdapter _inner; - private readonly ModelAdapterArgument[] _arguments; - - public object? Semantics { get; } - - internal ArgumentModelAdapter(IModelAdapter inner, ModelAdapterArgument[] arguments) - { - _inner = inner; - - // Mask an outer registration for T before dispatching to the underlying adapter. - _arguments = new[] { ModelAdapterArgument.Create(inner) }.Concat(arguments).ToArray(); - Semantics = ModelAdapterSemantics.Compose(typeof(ArgumentModelAdapter), - ModelAdapterSemantics.For(inner), new ModelAdapterFrame(_arguments, null).Semantics); - } - - public T Clone(T value, CloneContext context) - { - using var scope = context.Traversal.PushBindings(_arguments); - return ModelAdapterSemantics.Clone(_inner, value, context); - } - - public bool Equals(T left, T right, EqualityContext context) - { - using var scope = context.Traversal.PushBindings(_arguments); - return ModelAdapterSemantics.Equals(_inner, left, right, context); - } - - public int GetHashCode(T value, HashContext context) - { - using var scope = context.Traversal.PushBindings(_arguments); - return ModelAdapterSemantics.Hash(_inner, value, context); - } -} - -internal sealed class CapturedModelAdapter(IModelAdapter inner, ModelAdapterFrame bindings) - : IModelAdapter, IModelAdapterSemantics -{ - public object? Semantics { get; } = - ModelAdapterSemantics.Compose(typeof(CapturedModelAdapter), ModelAdapterSemantics.For(inner), bindings.Semantics); - - public T Clone(T value, CloneContext context) - { - using var scope = context.Traversal.ReplaceBindings(bindings); - return ModelAdapterSemantics.Clone(inner, value, context); - } - - public bool Equals(T left, T right, EqualityContext context) - { - using var scope = context.Traversal.ReplaceBindings(bindings); - return ModelAdapterSemantics.Equals(inner, left, right, context); - } - - public int GetHashCode(T value, HashContext context) - { - using var scope = context.Traversal.ReplaceBindings(bindings); - return ModelAdapterSemantics.Hash(inner, value, context); - } -} diff --git a/Bond.Models/ModelAdapterSemantics.cs b/Bond.Models/ModelAdapterSemantics.cs deleted file mode 100644 index e3e8ef2..0000000 --- a/Bond.Models/ModelAdapterSemantics.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; - -namespace BondTools.Models; - -internal interface IModelAdapterSemantics -{ - object? Semantics { get; } -} - -internal static class ModelAdapterSemantics -{ - internal static object? For(object adapter) => - adapter is IModelAdapterSemantics known ? known.Semantics : new Identity(adapter); - - internal static object? ForBinding(object adapter) => NormalizeBinding(For(adapter)); - - // Standard generated paths share one graph scope; custom components form stable structural keys. - internal static object? Compose(Type kind, object? first, object? second = null) => - first is null && second is null ? null : new Composition(kind, first, second); - - internal static object Projection(Type kind, object? payload, bool defaultBinding) => - new Composition(kind, new ProjectionKind(kind, defaultBinding), payload); - - private static object? NormalizeBinding(object? semantics) => semantics switch - { - ProjectionKind { DefaultBinding: true } => null, - Composition composition => - Compose(composition.Kind, NormalizeBinding(composition.First), NormalizeBinding(composition.Second)), - _ => semantics - }; - - internal static T Clone(IModelAdapter adapter, T value, CloneContext context) - { - using var scope = context.Traversal.Enter(For(adapter)); - return adapter.Clone(value, context); - } - - internal static bool Equals(IModelAdapter adapter, T left, T right, EqualityContext context) - { - using var scope = context.Traversal.Enter(For(adapter)); - return adapter.Equals(left, right, context); - } - - internal static int Hash(IModelAdapter adapter, T value, HashContext context) - { - using var scope = context.Traversal.Enter(For(adapter)); - return adapter.GetHashCode(value, context); - } - - private readonly record struct Composition(Type Kind, object? First, object? Second); - private readonly record struct ProjectionKind(Type Kind, bool DefaultBinding); - - private readonly struct Identity(object value) : IEquatable - { - public bool Equals(Identity other) => ReferenceEquals(value, other.Value); - public override bool Equals(object? other) => other is Identity identity && Equals(identity); - public override int GetHashCode() => RuntimeHelpers.GetHashCode(value); - private object Value => value; - } -} - -internal sealed class ModelTraversal -{ - private object? _adapter; - - internal ModelAdapterFrame? Adapters { get; private set; } - internal ModelOperationSemantics Semantics => new(_adapter, Adapters?.Semantics); - - internal ModelTraversal Fork() => new() { _adapter = _adapter, Adapters = Adapters }; - - internal AdapterScope Enter(object? adapter) - { - var scope = new AdapterScope(this, _adapter); - _adapter = adapter; - return scope; - } - - internal BindingScope PushBindings(IReadOnlyList arguments) => - ReplaceBindings(new ModelAdapterFrame(arguments, Adapters)); - - internal BindingScope ReplaceBindings(ModelAdapterFrame bindings) - { - var scope = new BindingScope(this, Adapters); - Adapters = bindings; - return scope; - } - - internal readonly struct AdapterScope(ModelTraversal traversal, object? previous) : IDisposable - { - public void Dispose() => traversal._adapter = previous; - } - - internal readonly struct BindingScope(ModelTraversal traversal, ModelAdapterFrame? previous) : IDisposable - { - public void Dispose() => traversal.Adapters = previous; - } -} - -internal readonly record struct ModelOperationSemantics(object? Adapter, ModelBindingSemantics? Bindings); - -internal sealed class ModelBindingSemantics(Dictionary bindings) : IEquatable -{ - internal IReadOnlyDictionary Bindings => bindings; - - public bool Equals(ModelBindingSemantics? other) - { - if (other is null || bindings.Count != other.Bindings.Count) - { - return false; - } - - foreach (var binding in bindings) - { - if (!other.Bindings.TryGetValue(binding.Key, out var value) || !binding.Value.Equals(value)) - { - return false; - } - } - - return true; - } - - public override bool Equals(object? other) => other is ModelBindingSemantics semantics && Equals(semantics); - - public override int GetHashCode() - { - var hash = 0; - foreach (var binding in bindings) - { - hash = unchecked(hash + HashCode.Combine(binding.Key, binding.Value)); - } - - return hash; - } -} - -internal readonly struct ModelReferenceKey(object source, ModelOperationSemantics semantics) : IEquatable -{ - private object Source => source; - private ModelOperationSemantics Semantics => semantics; - - public bool Equals(ModelReferenceKey other) => - ReferenceEquals(source, other.Source) && semantics.Equals(other.Semantics); - - public override bool Equals(object? other) => other is ModelReferenceKey key && Equals(key); - public override int GetHashCode() => HashCode.Combine(RuntimeHelpers.GetHashCode(source), semantics); -} diff --git a/Bond.Models/ModelAdapters.cs b/Bond.Models/ModelAdapters.cs deleted file mode 100644 index 2a206a1..0000000 --- a/Bond.Models/ModelAdapters.cs +++ /dev/null @@ -1,545 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace BondTools.Models; - -/// Typed container combinators used by generated code; no runtime type discovery is performed. -public static class ModelAdapters -{ - /// Dispatches to generated operations, known immutable values, or an explicitly registered adapter. - public static IModelAdapter Value() => ValueAdapter.Instance; - - /// Uses .NET equality and identity cloning for a type explicitly known to be immutable. - public static IModelAdapter Immutable() => ImmutableAdapter.Instance; - - /// Supplies statically known generic argument operations for one model traversal. - public static IModelAdapter WithArguments(IModelAdapter value, params ModelAdapterArgument[] arguments) - { - ArgumentNullException.ThrowIfNull(value); - ArgumentNullException.ThrowIfNull(arguments); - return new ArgumentModelAdapter(value, arguments); - } - - /// Preserves nullable value semantics. - public static IModelAdapter Nullable(IModelAdapter element) where T : struct => - new NullableAdapter(element); - - /// Clones a vector and compares its elements in order. - public static IModelAdapter> List(IModelAdapter element) => - new SequenceAdapter, T>(element, value => new(value.Count), (list, item) => list.Add(item), unordered: false); - - /// Clones a linked list and compares its elements in order. - public static IModelAdapter> LinkedList(IModelAdapter element) => - new SequenceAdapter, T>(element, _ => new(), (list, item) => list.AddLast(item), unordered: false); - - /// Clones a set, preserving its comparer; structural equality and hashing ignore insertion order. - public static IModelAdapter> Set(IModelAdapter element) => - new SequenceAdapter, T>(element, value => new(value.Comparer), (set, item) => set.Add(item), unordered: true); - - /// Clones a map, preserving its comparer; structural equality and hashing ignore insertion order. - public static IModelAdapter> Map( - IModelAdapter key, IModelAdapter value) where TKey : notnull => - new MapAdapter(key, value); - - /// Copies owned backing storage, preserving shared buffers and segment offsets. - public static IModelAdapter> Blob { get; } = new BlobAdapter(); - - /// Copies byte-array storage and compares byte contents. - public static IModelAdapter ByteArray { get; } = new ByteArrayAdapter(); - - /// - /// Compares and clones actual lazy payload values. Reading a payload crosses into the original runtime, - /// which may use reflection or deserialize. Each wrapper is materialized at most once per operation. - /// Covariant views share cloned payloads but may receive separate typed wrappers. - /// - public static IModelAdapter Materialized( - Func read, Func wrap, IModelAdapter value) - where TWrapper : class => - new MaterializedAdapter(read, (payload, _) => wrap(payload), value, defaultBinding: false); - - /// - /// Clones lazy payloads and supplies their captured typed operations to the wrapper factory. - /// The supplied adapter retains argument bindings, not graph caches, and can be used with fresh contexts. - /// The factory must retain the supplied payload and its value semantics, as generated bonded wrappers do. - /// - public static IModelAdapter Materialized( - Func read, Func, TWrapper> wrap, IModelAdapter value) - where TWrapper : class => - new MaterializedAdapter(read, wrap, value, defaultBinding: true); - - internal static object? EffectiveSemantics(IModelAdapter adapter, ModelAdapterFrame? arguments) - { - if (adapter is ValueAdapter dispatcher) - { - var selected = dispatcher.ResolveOverride(arguments); - return selected is null ? null : ModelAdapterSemantics.For(selected); - } - - return ModelAdapterSemantics.For(adapter); - } - - private sealed class ValueAdapter : IModelAdapter, IModelAdapterSemantics - { - internal static readonly ValueAdapter Instance = new(); - public object? Semantics => null; - - internal IModelAdapter? ResolveOverride(ModelAdapterFrame? bindings) - { - if (ModelOperations.Registered() is { } registered) - { - return registered; - } - - var contextual = bindings?.Find(); - return ReferenceEquals(contextual, this) ? null : contextual; - } - - public T Clone(T value, CloneContext context) - { - using var scope = context.Traversal.Enter(null); - if (value is null) - { - return value; - } - - if (ResolveOverride(context.Adapters) is { } adapter) - { - return ModelAdapterSemantics.Clone(adapter, value, context); - } - - if (value is IGeneratedCloneable model) - { - return (T)model.Clone(context); - } - - if (value is byte[] bytes) - { - return (T)(object)ByteArray.Clone(bytes, context); - } - - if (value is ArraySegment blob) - { - return (T)(object)Blob.Clone(blob, context); - } - - if (ModelOperations.IsKnownImmutable(value)) - { - return value; - } - - throw ModelOperations.MissingAdapter(typeof(T)); - } - - public bool Equals(T left, T right, EqualityContext context) - { - using var scope = context.Traversal.Enter(null); - if (ReferenceEquals(left, right)) - { - return true; - } - - if (left is null || right is null) - { - return false; - } - - if (ResolveOverride(context.Adapters) is { } adapter) - { - return ModelAdapterSemantics.Equals(adapter, left, right, context); - } - - if (left is IGeneratedEquatable model) - { - return right is IGeneratedEquatable other && left.GetType() == right.GetType() && - context.CompareReferences(left, right, () => model.ValueEquals(other, context)); - } - - if (left is byte[] bytes) - { - return right is byte[] otherBytes && ByteArray.Equals(bytes, otherBytes, context); - } - - if (left is ArraySegment blob) - { - return right is ArraySegment otherBlob && Blob.Equals(blob, otherBlob, context); - } - - if (ModelOperations.IsKnownImmutable(left)) - { - return EqualityComparer.Default.Equals(left, right); - } - - throw ModelOperations.MissingAdapter(typeof(T)); - } - - public int GetHashCode(T value, HashContext context) - { - using var scope = context.Traversal.Enter(null); - if (value is null || context.RemainingDepth == 0) - { - return 0; - } - - if (ResolveOverride(context.Adapters) is { } adapter) - { - return ModelAdapterSemantics.Hash(adapter, value, context); - } - - if (value is IGeneratedEquatable model) - { - return context.HashReference(model, () => model.ValueHashCode(context)); - } - - if (value is byte[] bytes) - { - return ByteArray.GetHashCode(bytes, context); - } - - if (value is ArraySegment blob) - { - return Blob.GetHashCode(blob, context); - } - - if (ModelOperations.IsKnownImmutable(value)) - { - return EqualityComparer.Default.GetHashCode(value); - } - - throw ModelOperations.MissingAdapter(typeof(T)); - } - } - - private sealed class ImmutableAdapter : IModelAdapter, IModelAdapterSemantics - { - internal static readonly ImmutableAdapter Instance = new(); - public object Semantics => typeof(ImmutableAdapter); - - public T Clone(T value, CloneContext context) => value; - - public bool Equals(T left, T right, EqualityContext context) => EqualityComparer.Default.Equals(left, right); - - public int GetHashCode(T value, HashContext context) => - value is null || context.RemainingDepth == 0 ? 0 : EqualityComparer.Default.GetHashCode(value); - } - - private sealed class NullableAdapter(IModelAdapter element) : IModelAdapter, IModelAdapterSemantics - where T : struct - { - public object? Semantics { get; } = - ModelAdapterSemantics.Compose(typeof(NullableAdapter), ModelAdapterSemantics.For(element)); - - public T? Clone(T? value, CloneContext context) => - value.HasValue ? ModelAdapterSemantics.Clone(element, value.Value, context) : null; - - public bool Equals(T? left, T? right, EqualityContext context) => - left.HasValue == right.HasValue && - (!left.HasValue || ModelAdapterSemantics.Equals(element, left.Value, right!.Value, context)); - - public int GetHashCode(T? value, HashContext context) => - !value.HasValue || context.RemainingDepth == 0 ? 0 : ModelAdapterSemantics.Hash(element, value.Value, context); - } - - private sealed class SequenceAdapter( - IModelAdapter element, - Func create, - Action add, - bool unordered) : IModelAdapter, IModelAdapterSemantics where TCollection : class, ICollection - { - public object? Semantics { get; } = - ModelAdapterSemantics.Compose(typeof(SequenceAdapter), ModelAdapterSemantics.For(element)); - - public TCollection Clone(TCollection value, CloneContext context) - { - using var scope = context.Traversal.Enter(Semantics); - if (value is null) - { - return null!; - } - - if (context.TryGetClone(value, out var existing)) - { - return existing; - } - - var clone = create(value); - context.Register(value, clone); - foreach (var item in value) - { - add(clone, ModelAdapterSemantics.Clone(element, item, context)); - } - - return clone; - } - - public bool Equals(TCollection left, TCollection right, EqualityContext context) - { - using var scope = context.Traversal.Enter(Semantics); - return context.CompareReferences(left, right, () => - { - if (left.Count != right.Count) - { - return false; - } - - if (unordered) - { - return UnorderedEquals(left, right, context, - (a, b, branch) => ModelAdapterSemantics.Equals(element, a, b, branch)); - } - - using var leftItems = left.GetEnumerator(); - using var rightItems = right.GetEnumerator(); - while (leftItems.MoveNext()) - { - if (!rightItems.MoveNext() || - !ModelAdapterSemantics.Equals(element, leftItems.Current, rightItems.Current, context)) - { - return false; - } - } - - return !rightItems.MoveNext(); - }); - } - - public int GetHashCode(TCollection value, HashContext context) - { - using var scope = context.Traversal.Enter(Semantics); - if (value is null || context.RemainingDepth == 0) - { - return 0; - } - - return context.HashReference(value, () => - { - var hash = 17; - var child = context.Descend(); - foreach (var item in value) - { - var itemHash = ModelAdapterSemantics.Hash(element, item, child); - hash = unordered ? unchecked(hash + itemHash) : HashContext.Combine(hash, itemHash); - } - - return HashContext.Combine(hash, value.Count); - }); - } - } - - private sealed class MapAdapter(IModelAdapter key, IModelAdapter value) - : IModelAdapter>, IModelAdapterSemantics where TKey : notnull - { - public object? Semantics { get; } = ModelAdapterSemantics.Compose(typeof(MapAdapter), - ModelAdapterSemantics.For(key), ModelAdapterSemantics.For(value)); - - public Dictionary Clone(Dictionary source, CloneContext context) - { - using var scope = context.Traversal.Enter(Semantics); - if (source is null) - { - return null!; - } - - if (context.TryGetClone>(source, out var existing)) - { - return existing; - } - - var clone = new Dictionary(source.Count, source.Comparer); - context.Register(source, clone); - foreach (var item in source) - { - clone.Add(ModelAdapterSemantics.Clone(key, item.Key, context), - ModelAdapterSemantics.Clone(value, item.Value, context)); - } - - return clone; - } - - public bool Equals(Dictionary left, Dictionary right, EqualityContext context) - { - using var scope = context.Traversal.Enter(Semantics); - return context.CompareReferences(left, right, () => - { - if (left.Count != right.Count) - { - return false; - } - - return UnorderedEquals(left, right, context, (leftEntry, rightEntry, branch) => - ModelAdapterSemantics.Equals(key, leftEntry.Key, rightEntry.Key, branch) && - ModelAdapterSemantics.Equals(value, leftEntry.Value, rightEntry.Value, branch)); - }); - } - - public int GetHashCode(Dictionary source, HashContext context) - { - using var scope = context.Traversal.Enter(Semantics); - if (source is null || context.RemainingDepth == 0) - { - return 0; - } - - return context.HashReference(source, () => - { - var hash = 17; - var child = context.Descend(); - foreach (var item in source) - { - var entryHash = HashContext.Combine( - ModelAdapterSemantics.Hash(key, item.Key, child), ModelAdapterSemantics.Hash(value, item.Value, child)); - hash = unchecked(hash + entryHash); - } - - return HashContext.Combine(hash, source.Count); - }); - } - } - - private sealed class ByteArrayAdapter : IModelAdapter, IModelAdapterSemantics - { - public object? Semantics => null; - - public byte[] Clone(byte[] value, CloneContext context) - { - using var scope = context.Traversal.Enter(null); - if (value is null) - { - return null!; - } - - if (context.TryGetClone(value, out var clone)) - { - return clone; - } - - clone = (byte[])value.Clone(); - context.Register(value, clone); - return clone; - } - - public bool Equals(byte[] left, byte[] right, EqualityContext context) => - ReferenceEquals(left, right) || left is not null && right is not null && left.AsSpan().SequenceEqual(right); - - public int GetHashCode(byte[] value, HashContext context) => - value is null ? 0 : Blob.GetHashCode(new ArraySegment(value), context); - } - - private sealed class BlobAdapter : IModelAdapter>, IModelAdapterSemantics - { - public object? Semantics => null; - - public ArraySegment Clone(ArraySegment value, CloneContext context) - { - if (value.Array is null) - { - return default; - } - - var copy = ByteArray.Clone(value.Array, context); - return new(copy, value.Offset, value.Count); - } - - public bool Equals(ArraySegment left, ArraySegment right, EqualityContext context) => - (left.Array is null) == (right.Array is null) && left.AsSpan().SequenceEqual(right.AsSpan()); - - public int GetHashCode(ArraySegment value, HashContext context) - { - if (value.Array is null || context.RemainingDepth == 0) - { - return 0; - } - - var hash = 17; - foreach (var item in value.AsSpan()) - { - hash = HashContext.Combine(hash, item); - } - - return hash; - } - } - - private sealed class MaterializedAdapter( - Func read, Func, TWrapper> wrap, - IModelAdapter value, bool defaultBinding) - : IModelAdapter, IModelAdapterSemantics where TWrapper : class - { - public object Semantics { get; } = - ModelAdapterSemantics.Projection( - typeof(MaterializedAdapter), ModelAdapterSemantics.For(value), defaultBinding); - - public TWrapper Clone(TWrapper source, CloneContext context) - { - using var scope = context.Traversal.Enter(SemanticsFor(context.Adapters)); - return context.CloneMaterialized(source, read, wrap, value); - } - - public bool Equals(TWrapper left, TWrapper right, EqualityContext context) - { - using var scope = context.Traversal.Enter(SemanticsFor(context.Adapters)); - return context.CompareReferences(left, right, () => - { - var leftPayload = context.Materialize(left, () => read(left)); - var rightPayload = context.Materialize(right, () => read(right)); - return ModelAdapterSemantics.Equals(value, leftPayload, rightPayload, context); - }); - } - - public int GetHashCode(TWrapper source, HashContext context) - { - using var scope = context.Traversal.Enter(SemanticsFor(context.Adapters)); - if (source is null || context.RemainingDepth == 0) - { - return 0; - } - - return context.HashReference(source, () => - { - var payload = context.Materialize(source, () => read(source)); - return ModelAdapterSemantics.Hash(value, payload, context.Descend()); - }); - } - - private object SemanticsFor(ModelAdapterFrame? arguments) => - ModelAdapterSemantics.Projection( - typeof(MaterializedAdapter), EffectiveSemantics(value, arguments), defaultBinding); - } - - private static bool UnorderedEquals( - IEnumerable left, IEnumerable right, EqualityContext context, - Func equals) - { - var candidates = right.ToArray(); - var matched = new bool[candidates.Length]; - foreach (var item in left) - { - var found = false; - for (var i = 0; i < candidates.Length; i++) - { - if (matched[i]) - { - continue; - } - - var branch = context.Fork(); - if (!equals(item, candidates[i], branch)) - { - continue; - } - - matched[i] = true; - context.Accept(branch); - found = true; - break; - } - - if (!found) - { - return false; - } - } - - return true; - } -} diff --git a/Bond.Models/ModelContexts.cs b/Bond.Models/ModelContexts.cs deleted file mode 100644 index 0f5dbc6..0000000 --- a/Bond.Models/ModelContexts.cs +++ /dev/null @@ -1,284 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; - -namespace BondTools.Models; - -/// Graph state shared by one clone operation, scoped to compatible adapter semantics. -public sealed class CloneContext -{ - private readonly Dictionary> _clones = new(); - private readonly Dictionary> _reservations = new(ReferenceEqualityComparer.Instance); - private readonly HashSet<(ModelReferenceKey Key, Type View)> _materializing = new(); - private readonly MaterializationCache _payloads = new(); - - internal ModelTraversal Traversal { get; } = new(); - internal ModelAdapterFrame? Adapters => Traversal.Adapters; - - /// Looks up a compatible clone in the active adapter scope, preserving sharing and cycles. - public bool TryGetClone(object source, out T clone) => - TryGetClone(new ModelReferenceKey(source, Traversal.Semantics), out clone); - - private bool TryGetClone(ModelReferenceKey key, out T clone) - { - if (_clones.TryGetValue(key, out var values)) - { - foreach (var value in values) - { - if (value is T compatible) - { - clone = compatible; - return true; - } - } - } - - clone = default!; - return false; - } - - /// Registers a clone shell in the active adapter scope before cloning its mutable children. - public void Register(object source, object clone) - { - ArgumentNullException.ThrowIfNull(source); - ArgumentNullException.ThrowIfNull(clone); - - var key = new ModelReferenceKey(source, Traversal.Semantics); - _clones.Add(key, [clone]); - if (_reservations.Remove(source, out var reserve)) - { - reserve(clone); - } - } - - internal TWrapper CloneMaterialized( - TWrapper wrapper, Func read, - Func, TWrapper> wrap, IModelAdapter adapter) - where TWrapper : class - { - if (wrapper is null) - { - return null!; - } - - var wrapperKey = new ModelReferenceKey(wrapper, Traversal.Semantics); - if (TryGetClone(wrapperKey, out var existing)) - { - return existing; - } - - var materializing = (wrapperKey, typeof(TWrapper)); - if (!_materializing.Add(materializing)) - { - throw new NotSupportedException( - "A cyclic materialized value requires its typed adapter to call CloneContext.Register before cloning children."); - } - - object? source = null; - Action? reservation = null; - try - { - var value = _payloads.Get(wrapper, () => read(wrapper)); - var captured = Adapters is null ? adapter : new CapturedModelAdapter(adapter, Adapters); - source = value; - if (source is not null) - { - // Make the wrapper available as soon as its payload shell exists, before cloning cyclic children. - reservation = clone => - { - if (!TryGetClone(wrapperKey, out _)) - { - RegisterView(wrapperKey, wrapper, wrap((TValue)clone, captured)); - } - }; - - _reservations[source] = _reservations.GetValueOrDefault(source) + reservation; - } - - var copied = ModelAdapterSemantics.Clone(adapter, value, this); - if (TryGetClone(wrapperKey, out existing)) - { - return existing; - } - - var result = wrap(copied, captured); - RegisterView(wrapperKey, wrapper, result); - return result; - } - finally - { - _materializing.Remove(materializing); - if (source is not null && reservation is not null && _reservations.TryGetValue(source, out var callbacks)) - { - callbacks -= reservation; - if (callbacks is null) - { - _reservations.Remove(source); - } - else - { - _reservations[source] = callbacks; - } - } - } - } - - private void RegisterView(ModelReferenceKey key, object source, object clone) - { - ArgumentNullException.ThrowIfNull(clone); - if (_clones.TryGetValue(key, out var views)) - { - // A covariant base wrapper cannot always represent a more-derived view of the same payload. - views.Add(clone); - } - else - { - _clones.Add(key, [clone]); - } - - if (_reservations.Remove(source, out var reserve)) - { - reserve(clone); - } - } -} - -/// Coinductive equality state. Forks isolate unsuccessful unordered collection matches. -public sealed class EqualityContext -{ - private HashSet _pairs = new(ReferencePairComparer.Instance); - private readonly MaterializationCache _payloads; - - internal ModelTraversal Traversal { get; private init; } = new(); - internal ModelAdapterFrame? Adapters => Traversal.Adapters; - - /// Starts an independent equality operation. - public EqualityContext() : this(new MaterializationCache()) - { - } - - private EqualityContext(MaterializationCache payloads) => _payloads = payloads; - - /// Records a pair in the active adapter scope before recursively comparing children. - public bool CompareReferences(object? left, object? right, Func compare) - { - if (ReferenceEquals(left, right)) - { - return true; - } - - if (left is null || right is null) - { - return false; - } - - var pair = new ReferencePair(left, right, Traversal.Semantics); - if (!_pairs.Add(pair)) - { - return true; - } - - if (compare()) - { - return true; - } - - _pairs.Remove(pair); - return false; - } - - /// Creates an isolated branch for a tentative match. - public EqualityContext Fork() => new(_payloads) - { - _pairs = new(_pairs, ReferencePairComparer.Instance), - Traversal = Traversal.Fork() - }; - - /// Accepts a successful branch's comparisons. - public void Accept(EqualityContext branch) => _pairs.UnionWith(branch._pairs); - - internal T Materialize(object wrapper, Func read) => _payloads.Get(wrapper, read); - - private readonly record struct ReferencePair(object Left, object Right, ModelOperationSemantics Semantics); - - private sealed class ReferencePairComparer : IEqualityComparer - { - internal static readonly ReferencePairComparer Instance = new(); - - public bool Equals(ReferencePair x, ReferencePair y) => - ReferenceEquals(x.Left, y.Left) && ReferenceEquals(x.Right, y.Right) && x.Semantics.Equals(y.Semantics); - - public int GetHashCode(ReferencePair pair) => - HashCode.Combine(RuntimeHelpers.GetHashCode(pair.Left), RuntimeHelpers.GetHashCode(pair.Right), pair.Semantics); - } -} - -/// A bounded structural hash context. Identity is used only to cache materialization, never in the hash. -public sealed class HashContext -{ - private readonly MaterializationCache _payloads; - private readonly Dictionary<(ModelReferenceKey Reference, int Depth), int> _hashes; - - internal ModelTraversal Traversal { get; private init; } = new(); - internal ModelAdapterFrame? Adapters => Traversal.Adapters; - - /// The maximum number of traversed edges in a standard value hash. - public const int DefaultDepth = 16; - - /// The remaining structural depth. Return zero when this reaches zero. - public int RemainingDepth { get; } - - /// Starts an independent hash operation. - public HashContext() : this(DefaultDepth, new MaterializationCache(), new()) - { - } - - private HashContext(int depth, MaterializationCache payloads, - Dictionary<(ModelReferenceKey Reference, int Depth), int> hashes) - { - RemainingDepth = depth; - _payloads = payloads; - _hashes = hashes; - } - - /// Returns a context for a child edge while sharing materialization state. - public HashContext Descend() => new(Math.Max(0, RemainingDepth - 1), _payloads, _hashes) - { - Traversal = Traversal.Fork() - }; - - /// Combines ordered field or element hashes. - public static int Combine(int hash, int value) => unchecked(hash * 31 + value); - - internal T Materialize(object wrapper, Func read) => _payloads.Get(wrapper, read); - - internal int HashReference(object source, Func compute) - { - var key = (new ModelReferenceKey(source, Traversal.Semantics), RemainingDepth); - if (_hashes.TryGetValue(key, out var hash)) - { - return hash; - } - - hash = compute(); - _hashes.Add(key, hash); - return hash; - } -} - -internal sealed class MaterializationCache -{ - private readonly Dictionary _values = new(ReferenceEqualityComparer.Instance); - - internal T Get(object wrapper, Func read) - { - if (_values.TryGetValue(wrapper, out var value)) - { - return (T)value!; - } - - var result = read(); - _values.Add(wrapper, result); - return result; - } -} diff --git a/Bond.Models/ModelDebugView.cs b/Bond.Models/ModelDebugView.cs deleted file mode 100644 index 56e1b15..0000000 --- a/Bond.Models/ModelDebugView.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using System.Diagnostics; - -namespace BondTools.Models; - -/// An immediate field snapshot. Its debugger label never evaluates the value. -[DebuggerDisplay("{Name,nq} (id {Id})")] -public sealed class ModelDebugField(string declaringType, string name, ushort id, object? value) -{ - /// The declaring schema type, disambiguating inherited hidden fields. - public string DeclaringType { get; } = declaringType; - - /// The schema field name. - public string Name { get; } = name; - - /// The wire field identifier. - public ushort Id { get; } = id; - - /// The immediate value, without enumerating, formatting, cloning, or deserializing it. - [DebuggerBrowsable(DebuggerBrowsableState.Collapsed)] - public object? Value { get; } = value; -} - -/// A reflection-free debugger proxy that captures only immediate generated field getters. -public sealed class GeneratedModelDebugView -{ - /// Captures immediate fields; does not traverse collections or materialize bonded values. - public GeneratedModelDebugView(object model) - { - ArgumentNullException.ThrowIfNull(model); - Fields = ((IGeneratedDebugView)model).GetDebugFields(); - } - - /// The immediate field snapshots, including inherited fields. - [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] - public ModelDebugField[] Fields { get; } -} diff --git a/Bond.Models/ModelOperations.cs b/Bond.Models/ModelOperations.cs deleted file mode 100644 index fd3dd54..0000000 --- a/Bond.Models/ModelOperations.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Threading; - -namespace BondTools.Models; - -/// Static, generated operations. This contract is independent of the Bond serialization runtime. -public interface IGeneratedSchemaProvider -{ - /// The model's reflection-free schema. - SchemaDescriptor Descriptor { get; } -} - -/// A model with generated graph-cloning support. -public interface IGeneratedCloneable -{ - /// Clones the model, registering its new instance before cloning fields. - IGeneratedCloneable Clone(CloneContext context); -} - -/// A model with generated structural equality and hashing. -public interface IGeneratedEquatable -{ - /// Compares fields. Call through to handle cycles and runtime types. - bool ValueEquals(IGeneratedEquatable other, EqualityContext context); - - /// Hashes fields using the supplied depth budget. - int ValueHashCode(HashContext context); -} - -/// A model with generated immediate-field debugger support. -public interface IGeneratedDebugView -{ - /// Captures immediate fields without evaluating their contents. - ModelDebugField[] GetDebugFields(); -} - -/// A model with descriptors, cloning, equality, and debugger support. -public interface IGeneratedModel : IGeneratedSchemaProvider, IGeneratedCloneable, IGeneratedEquatable, IGeneratedDebugView -{ -} - -/// Typed operations for external CLR values. Mutable adapters must register clones before recursing. -public interface IModelAdapter -{ - /// Deep-clones a value using the shared graph context. - T Clone(T value, CloneContext context); - - /// Compares actual values using the shared graph context. - bool Equals(T left, T right, EqualityContext context); - - /// Hashes actual values, respecting the context's remaining depth. - int GetHashCode(T value, HashContext context); -} - -/// -/// A value already materialized by generated operations. Generated bonded adapters use this contract to -/// avoid restarting deserialization or graph cloning when revisiting a cloned cyclic payload. -/// -public interface IMaterializedModelValue -{ - /// The captured value. Reading it performs no serialization, cloning, or traversal. - T Value { get; } -} - -/// -/// A delegate-based adapter with its own graph-cache scope. Reuse an instance for recursive operations. -/// Equality must be an equivalence relation and agree with hashing. -/// -public sealed class ModelAdapter( - Func clone, - Func equals, - Func hash) : IModelAdapter -{ - /// - public T Clone(T value, CloneContext context) - { - using var scope = context.Traversal.Enter(ModelAdapterSemantics.For(this)); - return clone(value, context); - } - - /// - public bool Equals(T left, T right, EqualityContext context) - { - using var scope = context.Traversal.Enter(ModelAdapterSemantics.For(this)); - return equals(left, right, context); - } - - /// - public int GetHashCode(T value, HashContext context) - { - using var scope = context.Traversal.Enter(ModelAdapterSemantics.For(this)); - return hash(value, context); - } -} - -/// Reflection-free value operations for generated models and explicitly adapted CLR types. -public static class ModelOperations -{ - /// Deep-clones without invoking generated model constructors. Bonded fields may deserialize. - public static T Clone(T value) => ModelAdapters.Value().Clone(value, new CloneContext()); - - /// Structural equality; sharing is ignored and cycles are compared coinductively. - public static bool ValueEquals(T left, T right) => - ModelAdapters.Value().Equals(left, right, new EqualityContext()); - - /// - /// Structural hash truncated at 16 edges, independent of object identity and collection insertion order. - /// Equal cyclic graphs have equal hashes; deep unequal values may intentionally collide. - /// - public static int ValueHashCode(T value) => - ModelAdapters.Value().GetHashCode(value, new HashContext()); - - /// Registers operations for the exact declared CLR type T. Register before using models concurrently. - public static void RegisterAdapter(IModelAdapter adapter) - { - ArgumentNullException.ThrowIfNull(adapter); - Volatile.Write(ref AdapterRegistration.Adapter, adapter); - } - - /// Explicitly declares a CLR type immutable and uses its .NET equality and hash implementation. - public static void RegisterImmutable() => RegisterAdapter(ModelAdapters.Immutable()); - - /// Allocates a shallow shell for generated cloning, without invoking any constructor. - public static object ShallowClone(object value) => MemberwiseClone(value); - - /// Creates an actionable error for a type without generated or registered operations. - public static NotSupportedException MissingAdapter(Type type) => new( - $"No generated value operations or typed adapter exist for '{type}'. " + - "Register one with BondTools.Models.ModelOperations.RegisterAdapter(IModelAdapter), " + - "or RegisterImmutable() only if the type is immutable."); - - [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "MemberwiseClone")] - private static extern object MemberwiseClone(object value); - - internal static IModelAdapter? Registered() => Volatile.Read(ref AdapterRegistration.Adapter); - - private static class AdapterRegistration - { - internal static IModelAdapter? Adapter; - } - - internal static bool IsKnownImmutable(object value) => value is - string or bool or char or sbyte or byte or short or ushort or int or uint or long or ulong - or float or double or decimal or Enum or DateTime or DateTimeOffset or TimeSpan - or DateOnly or TimeOnly or Guid or Version or IntPtr or UIntPtr or System.Numerics.BigInteger - || value.GetType() == typeof(Uri); -} diff --git a/Bond.Models/ModelSummary.cs b/Bond.Models/ModelSummary.cs deleted file mode 100644 index a208bca..0000000 --- a/Bond.Models/ModelSummary.cs +++ /dev/null @@ -1,392 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Globalization; -using System.Text; - -namespace BondTools.Models; - -/// A model with generated compact-summary support. -public interface IGeneratedSummary -{ - /// The schema type's display name. - string SummaryName { get; } - - /// Captures immediate fields, including inherited fields, without materializing bonded values. - ModelDebugField[] GetSummaryFields(); -} - -/// Bounded, cycle-safe summaries without serialization or member discovery. -public static class ModelSummary -{ - /// Formats at most four levels, 16 items per container, 160 characters per string, and 2048 characters total. - public static string Format(object? value) => new Writer().Render(value); - - /// Marks a value as opaque; only its CLR type name is captured. - public static object? Opaque(object? value) => value is null ? null : new OpaqueValue(value.GetType().Name); - - private sealed class OpaqueValue(string name) - { - internal string Name { get; } = name; - } - - private sealed class Writer - { - private const int MaxDepth = 4; - private const int MaxItems = 16; - private const int MaxStringLength = 160; - private const int MaxLength = 2048; - - private readonly StringBuilder _text = new(); - private readonly HashSet _path = new(ReferenceEqualityComparer.Instance); - - private bool Full => _text.Length >= MaxLength; - - internal string Render(object? value) - { - Write(value, 0); - if (Full) - { - _text.Length = MaxLength - 3; - _text.Append("..."); - } - - return _text.ToString(); - } - - private void Append(string value) - { - var count = Math.Min(value.Length, MaxLength - _text.Length); - _text.Append(value, 0, count); - } - - private void Append(char value) - { - if (!Full) - { - _text.Append(value); - } - } - - private void Write(object? value, int depth) - { - if (Full) - { - return; - } - - switch (value) - { - case null: - Append("null"); - return; - case string item: - Quoted(item, '"'); - return; - case char item: - Append('\''); - Escaped(item, '\''); - Append('\''); - return; - case bool item: - Append(item ? "true" : "false"); - return; - case sbyte or byte or short or ushort or int or uint or long or ulong - or Int128 or UInt128 or IntPtr or UIntPtr or decimal: - Append(((IFormattable)value).ToString(null, CultureInfo.InvariantCulture)); - return; - case float item: - Append(item.ToString("R", CultureInfo.InvariantCulture)); - return; - case double item: - Append(item.ToString("R", CultureInfo.InvariantCulture)); - return; - case Half item: - Append(item.ToString("R", CultureInfo.InvariantCulture)); - return; - case Enum item: - Append(item.ToString("G")); - return; - case Guid item: - Append(item.ToString("D")); - return; - case DateTime item: - Append(item.ToString("O", CultureInfo.InvariantCulture)); - return; - case DateTimeOffset item: - Append(item.ToString("O", CultureInfo.InvariantCulture)); - return; - case TimeSpan item: - Append(item.ToString("c", CultureInfo.InvariantCulture)); - return; - case DateOnly item: - Append(item.ToString("O", CultureInfo.InvariantCulture)); - return; - case TimeOnly item: - Append(item.ToString("O", CultureInfo.InvariantCulture)); - return; - case Uri item: - Quoted(item.OriginalString, '"'); - return; - case Version item: - Append(item.ToString()); - return; - case byte[] item: - Blob(item); - return; - case ArraySegment item: - Blob(item.AsSpan()); - return; - case Memory item: - Blob(item.Span); - return; - case ReadOnlyMemory item: - Blob(item.Span); - return; - case OpaqueValue item: - TypeName(item.Name); - return; - } - - if (value is not IGeneratedSummary && value is not IEnumerable) - { - TypeName(value.GetType().Name); - return; - } - - if (!_path.Add(value)) - { - Append(""); - return; - } - - try - { - if (depth >= MaxDepth) - { - Append("..."); - return; - } - - switch (value) - { - case IGeneratedSummary model: - Model(model, depth); - break; - case IDictionary dictionary: - Sequence(dictionary, depth, map: true); - break; - case IEnumerable sequence: - Sequence(sequence, depth, map: false); - break; - } - } - finally - { - // Shared values may appear again outside the current traversal path. - _path.Remove(value); - } - } - - private void TypeName(string name) - { - Append('<'); - var arity = name.IndexOf('`'); - Append(arity < 0 ? name : name[..arity]); - Append('>'); - } - - private void Model(IGeneratedSummary model, int depth) - { - Append(model.SummaryName); - Append(" {"); - if (Full) - { - return; - } - - var fields = model.GetSummaryFields(); - var count = Math.Min(fields.Length, MaxItems); - var seenNames = new HashSet(StringComparer.Ordinal); - var duplicateNames = new HashSet(StringComparer.Ordinal); - for (var i = 0; i < count; i++) - { - if (!seenNames.Add(fields[i].Name)) - { - duplicateNames.Add(fields[i].Name); - } - } - - for (var i = 0; i < count && !Full; i++) - { - var field = fields[i]; - Append(i == 0 ? " " : ", "); - if (duplicateNames.Contains(field.Name)) - { - Append(field.DeclaringType); - Append('.'); - } - - Append(field.Name); - Append(" = "); - Write(field.Value, depth + 1); - } - - if (fields.Length > count) - { - Append(", ..."); - } - - Append(" }"); - } - - private void Sequence(IEnumerable sequence, int depth, bool map) - { - Append(map ? "{" : "["); - if (Full) - { - return; - } - - var iterator = map ? ((IDictionary)sequence).GetEnumerator() : sequence.GetEnumerator(); - var count = 0; - try - { - while (count < MaxItems && !Full && iterator.MoveNext()) - { - if (count > 0) - { - Append(", "); - } - else if (map) - { - Append(' '); - } - - if (Full) - { - break; - } - - if (map) - { - var entry = ((IDictionaryEnumerator)iterator).Entry; - Write(entry.Key, depth + 1); - Append(" = "); - Write(entry.Value, depth + 1); - } - else - { - Write(iterator.Current, depth + 1); - } - - count++; - } - - // Do not probe another item: even an infinite sequence must stop at MaxItems. - if (!Full && count == MaxItems && (sequence is not ICollection collection || collection.Count > count)) - { - Append(", ..."); - } - } - finally - { - (iterator as IDisposable)?.Dispose(); - } - - Append(map ? " }" : "]"); - } - - private void Blob(ReadOnlySpan bytes) - { - Append("blob ["); - var count = Math.Min(bytes.Length, MaxItems); - for (var i = 0; i < count && !Full; i++) - { - if (i != 0) - { - Append(", "); - } - - Append(bytes[i].ToString("X2", CultureInfo.InvariantCulture)); - } - - if (bytes.Length > count) - { - Append(", ..."); - } - - Append(']'); - } - - private void Quoted(string value, char quote) - { - Append(quote); - var count = Math.Min(value.Length, MaxStringLength); - for (var i = 0; i < count && !Full; i++) - { - Escaped(value[i], quote); - } - - if (value.Length > count) - { - Append("..."); - } - - Append(quote); - } - - private void Escaped(char value, char quote) - { - if (value == quote) - { - Append('\\'); - Append(value); - return; - } - - switch (value) - { - case '\\': - Append(@"\\"); - break; - case '\0': - Append(@"\0"); - break; - case '\a': - Append(@"\a"); - break; - case '\b': - Append(@"\b"); - break; - case '\f': - Append(@"\f"); - break; - case '\n': - Append(@"\n"); - break; - case '\r': - Append(@"\r"); - break; - case '\t': - Append(@"\t"); - break; - case '\v': - Append(@"\v"); - break; - default: - if (char.IsControl(value) || char.IsSurrogate(value) || value is '\u2028' or '\u2029') - { - Append(@"\u"); - Append(((ushort)value).ToString("x4", CultureInfo.InvariantCulture)); - } - else - { - Append(value); - } - - break; - } - } - } -} diff --git a/Bond.Models/README.md b/Bond.Models/README.md deleted file mode 100644 index 68cabb6..0000000 --- a/Bond.Models/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# BondTools.Models - -Optional model support for .NET 8+. Match the generator version: - -```bash -dotnet add MyApp.csproj package BondTools.Models --version "$(bond --version)" -bond generate csharp order.bond -o Generated \ - --descriptors --clone --equality --debugger --to-string -``` - -```csharp -var schema = OrderSchema.Descriptor; -var copy = order.Clone(); -bool same = order.Equals(copy); -Console.WriteLine(order); // Order { id = 42, ... } -``` diff --git a/Bond.Models/SchemaDescriptor.cs b/Bond.Models/SchemaDescriptor.cs deleted file mode 100644 index f8bfb67..0000000 --- a/Bond.Models/SchemaDescriptor.cs +++ /dev/null @@ -1,303 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Numerics; - -namespace BondTools.Models; - -/// The kind of named IDL declaration. -public enum SchemaKind -{ - /// A struct, including a view. - Struct, - /// An enum. - Enum, - /// A file-local type alias. - Alias -} - -/// The declared field presence modifier. -public enum SchemaFieldModifier -{ - /// An optional field. - Optional, - /// A required field. - Required, - /// A required_optional field. - RequiredOptional -} - -/// The declared constraint on a generic schema parameter. -public enum SchemaTypeConstraint -{ - /// No value constraint. - None, - /// The IDL value constraint. - Value -} - -/// An uninterpreted source attribute. Duplicate names and declaration order are preserved. -public sealed record SchemaAttribute(string Name, string Value); - -/// A generic template parameter and its source constraint. -public sealed record TypeParameterDescriptor(string Name, SchemaTypeConstraint Constraint = SchemaTypeConstraint.None); - -/// An enum member, including its effective signed 32-bit value and optional source initializer. -public sealed record EnumValueDescriptor(string Name, int Value, long? DeclaredValue = null); - -/// A declared default, never a constructed model, container, or inferred CLR default. -public abstract record SchemaDefault -{ - private SchemaDefault() - { - } - - /// A Boolean source literal. - public sealed record Boolean(bool Value) : SchemaDefault; - - /// An integer source literal, without narrowing unsigned 64-bit values. - public sealed record Integer(BigInteger Value) : SchemaDefault; - - /// A floating-point source literal, retaining double precision and the sign of zero. - public sealed record FloatingPoint(double Value) : SchemaDefault; - - /// A decoded string source literal. - public sealed record String(string Value) : SchemaDefault; - - /// An enum member name as written in the source default. - public sealed record Enum(string Name) : SchemaDefault; - - /// The explicit nothing default, distinct from an omitted default. - public sealed record Nothing : SchemaDefault - { - /// The immutable nothing value. - public static Nothing Instance { get; } = new(); - - private Nothing() - { - } - } -} - -/// An immutable declared field. Inherited fields belong to the explicit base descriptor. -public sealed class FieldDescriptor -{ - /// Creates a field from source metadata, defensively copying attributes. - public FieldDescriptor(ushort id, string name, SchemaType type, - SchemaFieldModifier modifier = SchemaFieldModifier.Optional, SchemaDefault? defaultValue = null, - IEnumerable? attributes = null) - { - Id = id; - Name = name ?? throw new ArgumentNullException(nameof(name)); - Type = type ?? throw new ArgumentNullException(nameof(type)); - Modifier = modifier; - DefaultValue = defaultValue; - Attributes = SchemaCollections.Freeze(attributes); - } - - /// The IDL field ordinal. - public ushort Id { get; } - - /// The declared IDL field name. - public string Name { get; } - - /// The symbolic schema type, retaining aliases and nothing wrappers. - public SchemaType Type { get; } - - /// The source presence modifier. - public SchemaFieldModifier Modifier { get; } - - /// The wire modifier, including Bond's required_optional rule for metadata-name fields. - public SchemaFieldModifier EffectiveModifier => Type.Kind is SchemaTypeKind.MetaName or SchemaTypeKind.MetaFullName - ? SchemaFieldModifier.RequiredOptional - : Modifier; - - /// The declared default; null means no default was written. - public SchemaDefault? DefaultValue { get; } - - /// The source attributes, in declaration order. - public IReadOnlyList Attributes { get; } - - internal FieldDescriptor Substitute(IReadOnlyList arguments) => - new(Id, Name, Type.Substitute(arguments), Modifier, DefaultValue, Attributes); -} - -/// -/// Immutable generated metadata for an IDL declaration. Reading it does not instantiate a model, -/// inspect CLR types, or depend on a serialization runtime. -/// -public sealed class SchemaDescriptor -{ - /// Creates a declaration template, copying all supplied collections. - public SchemaDescriptor( - string name, - string @namespace, - string? clrName, - SchemaKind kind, - IEnumerable? fields = null, - SchemaType? baseType = null, - IEnumerable? typeParameters = null, - IEnumerable? enumValues = null, - IEnumerable? attributes = null, - SchemaType? aliasedType = null, - bool isView = false, - IEnumerable? viewTarget = null, - IEnumerable? viewFields = null, - bool isForward = false) - { - Name = name ?? throw new ArgumentNullException(nameof(name)); - Namespace = @namespace ?? throw new ArgumentNullException(nameof(@namespace)); - ClrName = clrName; - Kind = kind; - IsForward = isForward; - - var declaredFields = SchemaCollections.Freeze(fields); - Fields = SchemaCollections.Freeze(declaredFields.OrderBy(field => field.Id)); - if (Fields.Select(field => field.Id).Distinct().Count() != Fields.Count) - { - throw new ArgumentException("A schema cannot declare duplicate field ordinals.", nameof(fields)); - } - - BaseType = baseType; - TypeParameters = SchemaCollections.Freeze(typeParameters); - EnumValues = SchemaCollections.Freeze(enumValues); - Attributes = SchemaCollections.Freeze(attributes); - AliasedType = aliasedType; - IsView = isView; - ViewTarget = SchemaCollections.Freeze(viewTarget); - ViewFields = SchemaCollections.Freeze(viewFields); - - TypeArguments = SchemaCollections.Freeze(null); - Definition = this; - } - - private SchemaDescriptor(SchemaDescriptor definition, IReadOnlyList arguments) - : this(definition.Name, definition.Namespace, definition.ClrName, definition.Kind, - fields: definition.Fields.Select(field => field.Substitute(arguments)), - baseType: definition.BaseType?.Substitute(arguments), - typeParameters: definition.TypeParameters, - enumValues: definition.EnumValues, - attributes: definition.Attributes, - aliasedType: definition.AliasedType?.Substitute(arguments), - isView: definition.IsView, - viewTarget: definition.ViewTarget, - viewFields: definition.ViewFields, - isForward: definition.IsForward) - { - Definition = definition; - TypeArguments = arguments; - } - - /// The original IDL declaration name. - public string Name { get; } - - /// The IDL namespace, unaffected by C# namespace and type mappings. - public string Namespace { get; } - - /// The qualified IDL name. Alias identity is the descriptor, not this potentially shadowed name. - public string FullName => Namespace.Length == 0 ? Name : Namespace + "." + Name; - - /// - /// The mapped C# representation of the declaration template, including generic parameter names. - /// Binding preserves this template name; actual schema arguments are available in TypeArguments. - /// Null means the symbolic template has no representable CLR type, for example an erased integer argument alias. - /// - public string? ClrName { get; } - - /// The named declaration category. - public SchemaKind Kind { get; } - - /// - /// True when only a forward declaration is available. Its fields and base layout are unknown, - /// not an empty struct definition, and are never discovered by inspecting the CLR type. - /// - public bool IsForward { get; } - - /// Own declared fields sorted by ordinal, excluding inherited fields; unavailable on forward declarations. - public IReadOnlyList Fields { get; } - - /// The explicit symbolic base type, if any. - public SchemaType? BaseType { get; } - - /// The declaration's generic parameters in source order. - public IReadOnlyList TypeParameters { get; } - - /// Enum members in declaration order, including repeated numeric values. - public IReadOnlyList EnumValues { get; } - - /// Declaration attributes in source order. - public IReadOnlyList Attributes { get; } - - /// The alias's symbolic target without erasing nested aliases. - public SchemaType? AliasedType { get; } - - /// Whether this struct was declared as a view. - public bool IsView { get; } - - /// The view target's qualified-name segments as written in the IDL. - public IReadOnlyList ViewTarget { get; } - - /// The view's field selection as written, including repeated or unmatched selectors. - public IReadOnlyList ViewFields { get; } - - /// The unbound declaration template; identical to this descriptor when unbound. - public SchemaDescriptor Definition { get; } - - /// Explicitly supplied binding arguments; empty on a declaration template. - public IReadOnlyList TypeArguments { get; } - - /// - /// Binds a template using explicit symbolic arguments. Substitution is simultaneous, preserves - /// defaults and declaration identity, and does not eagerly resolve recursive references. - /// The argument count is checked; source constraints remain available in TypeParameters. - /// - public SchemaDescriptor Bind(params SchemaType[] arguments) - { - ArgumentNullException.ThrowIfNull(arguments); - if (arguments.Length != TypeParameters.Count) - { - throw new ArgumentException("The number of schema arguments must match the declaration's parameters.", nameof(arguments)); - } - - return arguments.Length == 0 ? Definition : new SchemaDescriptor(Definition, SchemaCollections.Freeze(arguments)); - } - - /// - /// Creates a symbolic reference to this declaration for use as an explicit generic argument. - /// On a bound descriptor, omitting arguments preserves its existing binding. - /// - public NamedSchemaType AsType(params SchemaType[] arguments) - { - ArgumentNullException.ThrowIfNull(arguments); - var suppliedArguments = arguments.Length == 0 && TypeArguments.Count != 0 - ? TypeArguments - : SchemaCollections.Freeze(arguments); - if (suppliedArguments.Count != TypeParameters.Count) - { - throw new ArgumentException("The number of schema arguments must match the declaration's parameters.", nameof(arguments)); - } - - var kind = Kind switch - { - SchemaKind.Struct => SchemaTypeKind.Struct, - SchemaKind.Enum => SchemaTypeKind.Enum, - SchemaKind.Alias => SchemaTypeKind.Alias, - _ => throw new InvalidOperationException("Unknown declaration kind.") - }; - return new NamedSchemaType(kind, Name, Namespace, () => Definition, suppliedArguments); - } -} - -internal static class SchemaCollections -{ - internal static IReadOnlyList Freeze(IEnumerable? values) - { - var copy = values?.ToList() ?? new List(); - if (copy.Any(value => value is null)) - { - throw new ArgumentException("Schema collections cannot contain null values.", nameof(values)); - } - - return copy.AsReadOnly(); - } -} diff --git a/Bond.Models/SchemaType.cs b/Bond.Models/SchemaType.cs deleted file mode 100644 index 155627a..0000000 --- a/Bond.Models/SchemaType.cs +++ /dev/null @@ -1,251 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Threading; - -namespace BondTools.Models; - -/// The IDL type, independent of its CLR representation. -public enum SchemaTypeKind -{ - /// A signed 8-bit integer. - Int8, - /// A signed 16-bit integer. - Int16, - /// A signed 32-bit integer. - Int32, - /// A signed 64-bit integer. - Int64, - /// An unsigned 8-bit integer. - UInt8, - /// An unsigned 16-bit integer. - UInt16, - /// An unsigned 32-bit integer. - UInt32, - /// An unsigned 64-bit integer. - UInt64, - /// A 32-bit floating-point number. - Float, - /// A 64-bit floating-point number. - Double, - /// A Boolean. - Bool, - /// A UTF-8 string. - String, - /// A UTF-16 string, distinct from string even when both map to System.String. - WString, - /// A binary blob. - Blob, - /// The containing model's IDL name. - MetaName, - /// The containing model's qualified IDL name. - MetaFullName, - /// An IDL list. - List, - /// An IDL vector. - Vector, - /// An IDL set. - Set, - /// An IDL map. - Map, - /// An explicitly nullable value. - Nullable, - /// A value with the declared default nothing; not the nullable IDL type. - Maybe, - /// A deferred bonded value. - Bonded, - /// A named struct. - Struct, - /// A named enum. - Enum, - /// A named alias, without erasing its declaration. - Alias, - /// A generic parameter in the enclosing declaration. - TypeParameter, - /// A signed integer generic argument, not a CLR type. - IntegerArgument -} - -/// An immutable, symbolic IDL type expression. No CLR type discovery is performed. -public abstract class SchemaType -{ - private protected SchemaType(SchemaTypeKind kind) => Kind = kind; - - /// The exact IDL type category. - public SchemaTypeKind Kind { get; } - - /// Formats the symbolic IDL type without resolving declaration factories. - public override string ToString() => Kind switch - { - SchemaTypeKind.MetaName => "bond_meta::name", - SchemaTypeKind.MetaFullName => "bond_meta::full_name", - _ => Kind.ToString().ToLowerInvariant() - }; - - internal abstract SchemaType Substitute(IReadOnlyList arguments); -} - -/// A primitive, blob, or metadata-string type. -public sealed class PrimitiveSchemaType : SchemaType -{ - /// Creates a leaf type; container and declaration kinds are rejected. - public PrimitiveSchemaType(SchemaTypeKind kind) : base(kind) - { - if (kind is < SchemaTypeKind.Int8 or > SchemaTypeKind.MetaFullName) - { - throw new ArgumentOutOfRangeException(nameof(kind)); - } - } - - internal override SchemaType Substitute(IReadOnlyList arguments) => this; -} - -/// A list, vector, set, nullable, nothing-default, or bonded type. -public sealed class UnarySchemaType : SchemaType -{ - /// Creates a symbolic single-argument type without evaluating its argument. - public UnarySchemaType(SchemaTypeKind kind, SchemaType elementType) : base(kind) - { - if (kind is not (SchemaTypeKind.List or SchemaTypeKind.Vector or SchemaTypeKind.Set - or SchemaTypeKind.Nullable or SchemaTypeKind.Maybe or SchemaTypeKind.Bonded)) - { - throw new ArgumentOutOfRangeException(nameof(kind)); - } - - ElementType = elementType ?? throw new ArgumentNullException(nameof(elementType)); - } - - /// The element, set key, or wrapped type. - public SchemaType ElementType { get; } - - /// - public override string ToString() => Kind == SchemaTypeKind.Maybe - ? ElementType + " = nothing" - : base.ToString() + "<" + ElementType + ">"; - - internal override SchemaType Substitute(IReadOnlyList arguments) => - new UnarySchemaType(Kind, ElementType.Substitute(arguments)); -} - -/// A map retaining separate symbolic key and value types. -public sealed class MapSchemaType : SchemaType -{ - /// Creates a map type. - public MapSchemaType(SchemaType keyType, SchemaType valueType) : base(SchemaTypeKind.Map) - { - KeyType = keyType ?? throw new ArgumentNullException(nameof(keyType)); - ValueType = valueType ?? throw new ArgumentNullException(nameof(valueType)); - } - - /// The map key type. - public SchemaType KeyType { get; } - - /// The map value type. - public SchemaType ValueType { get; } - - /// - public override string ToString() => $"map<{KeyType}, {ValueType}>"; - - internal override SchemaType Substitute(IReadOnlyList arguments) => - new MapSchemaType(KeyType.Substitute(arguments), ValueType.Substitute(arguments)); -} - -/// A lazy declaration reference with explicitly supplied symbolic generic arguments. -public sealed class NamedSchemaType : SchemaType -{ - private readonly Lazy _declaration; - private readonly Lazy _resolved; - - /// - /// Creates a reference. The generated delegate is not called until Declaration or Resolve is used, - /// allowing descriptors for recursive and mutually recursive models to initialize safely. - /// - public NamedSchemaType(SchemaTypeKind kind, string name, string @namespace, - Func declaration, IEnumerable? typeArguments = null) : base(kind) - { - if (kind is not (SchemaTypeKind.Struct or SchemaTypeKind.Enum or SchemaTypeKind.Alias)) - { - throw new ArgumentOutOfRangeException(nameof(kind)); - } - - Name = name ?? throw new ArgumentNullException(nameof(name)); - Namespace = @namespace ?? throw new ArgumentNullException(nameof(@namespace)); - ArgumentNullException.ThrowIfNull(declaration); - TypeArguments = SchemaCollections.Freeze(typeArguments); - - _declaration = new Lazy( - () => declaration() ?? throw new InvalidOperationException("A declaration factory returned null."), - LazyThreadSafetyMode.ExecutionAndPublication); - _resolved = new Lazy( - () => TypeArguments.Count == 0 ? Declaration : Declaration.Bind(TypeArguments.ToArray()), - LazyThreadSafetyMode.ExecutionAndPublication); - } - - /// The referenced declaration's IDL name. - public string Name { get; } - - /// The referenced declaration's IDL namespace, unaffected by C# mappings. - public string Namespace { get; } - - /// The qualified IDL name. File-local aliases need not have globally unique names. - public string FullName => Namespace.Length == 0 ? Name : Namespace + "." + Name; - - /// The generic arguments, defensively copied and read-only. - public IReadOnlyList TypeArguments { get; } - - /// The original declaration template, obtained through a compiled delegate. - public SchemaDescriptor Declaration => _declaration.Value; - - /// Resolves this reference and binds its explicit arguments, without traversing nested references. - public SchemaDescriptor Resolve() => _resolved.Value; - - /// - public override string ToString() => TypeArguments.Count == 0 - ? FullName - : FullName + "<" + string.Join(", ", TypeArguments) + ">"; - - internal override SchemaType Substitute(IReadOnlyList arguments) => - new NamedSchemaType(Kind, Name, Namespace, () => Declaration, - TypeArguments.Select(argument => argument.Substitute(arguments))); -} - -/// A positional parameter of the enclosing schema declaration, not a System.Type. -public sealed class TypeParameterSchemaType : SchemaType -{ - /// Creates a parameter reference using its zero-based declaration position. - public TypeParameterSchemaType(int position, string name) : base(SchemaTypeKind.TypeParameter) - { - ArgumentOutOfRangeException.ThrowIfNegative(position); - Position = position; - Name = name ?? throw new ArgumentNullException(nameof(name)); - } - - /// The zero-based position in the declaration's TypeParameters. - public int Position { get; } - - /// The declared parameter name. - public string Name { get; } - - /// - public override string ToString() => Name; - - internal override SchemaType Substitute(IReadOnlyList arguments) => - Position < arguments.Count ? arguments[Position] - : throw new ArgumentException("No argument was supplied for schema parameter " + Name + ".", nameof(arguments)); -} - -/// A signed literal generic argument, including arguments erased by an alias's CLR mapping. -public sealed class IntegerArgumentSchemaType : SchemaType -{ - /// Creates an integer type argument. - public IntegerArgumentSchemaType(long value) : base(SchemaTypeKind.IntegerArgument) => Value = value; - - /// The declared signed integer value. - public long Value { get; } - - /// - public override string ToString() => Value.ToString(CultureInfo.InvariantCulture); - - internal override SchemaType Substitute(IReadOnlyList arguments) => this; -} diff --git a/Bond.Parser.CLI/Arguments.cs b/Bond.Parser.CLI/Arguments.cs new file mode 100644 index 0000000..df69c6b --- /dev/null +++ b/Bond.Parser.CLI/Arguments.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Bond.Parser.CLI; + +internal sealed class UsageException(string message) : Exception(message); + +/// Positional arguments, flags, and options with values (--name value or --name=value). +internal sealed class Arguments +{ + private static readonly Dictionary Aliases = new(StringComparer.Ordinal) + { + ["-h"] = "--help", + ["-v"] = "--verbose", + ["-I"] = "--import-dir", + ["-o"] = "--output-dir", + ["-n"] = "--namespace", + ["-u"] = "--using" + }; + + private readonly Dictionary> _options = new(StringComparer.Ordinal); + + public List Positionals { get; } = []; + + public static Arguments Parse(IReadOnlyList args, string[] flags, string[] options) + { + var result = new Arguments(); + for (var i = 0; i < args.Count; i++) + { + var argument = args[i]; + if (argument == "--") + { + result.Positionals.AddRange(args.Skip(i + 1)); + break; + } + + if (argument.Length < 2 || argument[0] != '-') + { + result.Positionals.Add(argument); + continue; + } + + var separator = argument.IndexOf('='); + var name = separator < 0 ? argument : argument[..separator]; + name = Aliases.GetValueOrDefault(name, name); + var value = separator < 0 ? null : argument[(separator + 1)..]; + if (name == "--help" || flags.Contains(name)) + { + if (value != null) + { + throw new UsageException($"Option '{name}' does not take a value."); + } + } + else if (options.Contains(name)) + { + if (value == null && i + 1 < args.Count && !args[i + 1].StartsWith('-')) + { + value = args[++i]; + } + + if (string.IsNullOrWhiteSpace(value)) + { + throw new UsageException($"Option '{name}' requires a value."); + } + } + else + { + throw new UsageException($"Unknown option '{name}'."); + } + + if (!result._options.TryGetValue(name, out var values)) + { + result._options[name] = values = []; + } + + values.Add(value ?? ""); + } + + return result; + } + + public bool Has(string name) => _options.ContainsKey(name); + + public IReadOnlyList All(string name) => _options.GetValueOrDefault(name) ?? []; + + public string? Value(string name) => All(name) switch + { + [] => null, + [var value] => value, + _ => throw new UsageException($"Option '{name}' may only be specified once.") + }; + + public string Required(string name) => Value(name) ?? throw new UsageException($"Option '{name}' is required."); + + public string Positional(string description) => Positionals switch + { + [var value] => value, + [] => throw new UsageException($"A {description} is required."), + _ => throw new UsageException($"Only one {description} may be specified.") + }; + + public string ErrorFormat() => Value("--error-format") switch + { + null or "text" => "text", + "json" => "json", + var format => throw new UsageException($"Unsupported error format '{format}'; expected text or json.") + }; +} diff --git a/Bond.Parser.CLI/BreakingCommand.cs b/Bond.Parser.CLI/BreakingCommand.cs index 1e09808..b6c9019 100644 --- a/Bond.Parser.CLI/BreakingCommand.cs +++ b/Bond.Parser.CLI/BreakingCommand.cs @@ -2,137 +2,113 @@ using System.IO; using System.Linq; using System.Text.Json; -using System.Threading; using System.Threading.Tasks; using Bond.Parser.Compatibility; using Bond.Parser.Parser; namespace Bond.Parser.CLI; -public static class BreakingCommand +internal static class BreakingCommand { - public static async Task RunAsync(string[] args, TextWriter standardOutput, TextWriter standardError, - CancellationToken cancellationToken = default) + public static async Task Run(string[] rest) { - var options = SchemaCommandOptions.Parse(args, comparison: true); - if (options.Errors.Count != 0) + var args = Arguments.Parse(rest, ["--ignore-imports", "--list-rules", "--verbose"], + ["--against", "--suppress", "--import-dir", "--error-format"]); + if (args.Has("--help")) { - return await SchemaDiagnostics.WriteAsync(standardError, options.ErrorFormat, - options.Errors.Select(error => new ParseError(error, null, 0, 0))); + return Program.ShowHelp(); } - if (options.Help) + if (args.Has("--list-rules")) { - await standardOutput.WriteLineAsync(""" - Usage: bond breaking --against - - Checks Compact/Fast Binary and Simple JSON compatibility. - - --suppress Acknowledge diagnostic IDs (repeatable or comma-separated) - --list-rules List diagnostic IDs - --ignore-imports Compare root declarations without resolving imports - -I, --import-dir Import search directory (repeatable) - --error-format text|json - -v, --verbose Include compatible and suppressed changes + foreach (var rule in DiagnosticIds.Rules.OrderBy(rule => rule.Key, StringComparer.Ordinal)) + { + Console.WriteLine($"{rule.Key} {rule.Value}"); + } - Exit codes: 0 no unsuppressed errors, 1 incompatible, 2 invalid input or usage. - """); return 0; } - if (options.ListRules) + var format = args.ErrorFormat(); + var input = Path.GetFullPath(args.Positional("schema file")); + var against = args.Required("--against"); + var suppressions = args.All("--suppress") + .SelectMany(value => value.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .ToHashSet(StringComparer.Ordinal); + if (suppressions.FirstOrDefault(id => !DiagnosticIds.All.Contains(id)) is { } unknown) { - foreach (var rule in DiagnosticIds.Rules.OrderBy(rule => rule.Key, StringComparer.Ordinal)) - { - await standardOutput.WriteLineAsync($"{rule.Key} {rule.Value}"); - } + throw new UsageException($"Unknown diagnostic ID '{unknown}'."); + } - return 0; + var ignoreImports = args.Has("--ignore-imports"); + var parseOptions = new ParseOptions(IgnoreImports: ignoreImports); + var importDirectories = args.All("--import-dir"); + var current = await ParserFacade.ParseFileAsync(input, DefaultImportResolver.Create(importDirectories), options: parseOptions); + if (!current.Success) + { + return Diagnostics.Report(current.Errors, format, exitCode: 2); } - var currentPath = options.Input!; + ParseResult previous; try { - var compatibility = options.CreateCompatibilityOptions(); - var input = Path.GetFullPath(options.Input!); - var parseOptions = new ParseOptions(IgnoreImports: options.IgnoreImports); - var resolver = SchemaFiles.ImportResolver(options.ImportDirectories, cancellationToken); - var current = await ParserFacade.ParseFileAsync(input, resolver, cancellationToken, parseOptions); - if (!current.Success) - { - return await SchemaDiagnostics.WriteAsync(standardError, options.ErrorFormat, current.Errors); - } + previous = against.StartsWith(".git#", StringComparison.Ordinal) + ? await GitReference.ParseAsync(against, input, importDirectories, parseOptions) + : await ParserFacade.ParseFileAsync(Path.GetFullPath(against), DefaultImportResolver.Create(importDirectories), + options: parseOptions); + } + catch (IOException error) + { + previous = new ParseResult(null, [new ParseError(error.Message, against, 0, 0)]); + } - currentPath = options.Against!; - ParseResult previous; - if (options.Against!.StartsWith(".git#", StringComparison.Ordinal)) - { - var reference = await SchemaFiles.ReadGitReference(options.Against, input, - options.ImportDirectories, cancellationToken); - previous = await ParserFacade.ParseContentAsync(reference.Content, input, reference.Resolver, parseOptions); - } - else - { - previous = await ParserFacade.ParseFileAsync(options.Against, resolver, cancellationToken, parseOptions); - } + if (!previous.Success) + { + return Diagnostics.Report(previous.Errors, format, exitCode: 2); + } - if (!previous.Success) - { - return await SchemaDiagnostics.WriteAsync(standardError, options.ErrorFormat, previous.Errors); - } + var result = new CompatibilityChecker().Compare(previous.Ast!, current.Ast!, new CompatibilityOptions + { + IncludeImports = !ignoreImports, + AllowUnresolvedTypes = ignoreImports, + SuppressedDiagnosticIds = suppressions + }); - var checker = new CompatibilityChecker(); - var result = checker.Compare(previous.Ast!, current.Ast!, compatibility); - if (options.ErrorFormat == "json") - { - await standardOutput.WriteLineAsync(JsonSerializer.Serialize(new - { - compatible = !result.HasBreakingChanges, - severity = result.MaxSeverity.ToString().ToLowerInvariant(), - exit_code = result.ExitCode, - changes = result.Changes.Select(change => new - { - id = change.Id, - type = Category(change.Category), - severity = change.Severity.ToString().ToLowerInvariant(), - suppressed = change.IsSuppressed, - location = change.Location, - description = change.Description, - recommendation = change.Recommendation - }) - })); - } - else + if (format == "json") + { + Console.WriteLine(JsonSerializer.Serialize(new { - foreach (var change in result.Changes) + compatible = !result.HasBreakingChanges, + severity = result.MaxSeverity.ToString().ToLowerInvariant(), + exit_code = result.ExitCode, + changes = result.Changes.Select(change => new { - if (change.IsSuppressed && !options.Verbose) - { - continue; - } - - if (!options.Verbose && change.Severity == ChangeSeverity.Info) - { - continue; - } - - var writer = change.Severity == ChangeSeverity.Info || change.IsSuppressed ? standardOutput : standardError; - await writer.WriteLineAsync($"{change.Location}: {change.Severity.ToString().ToLowerInvariant()} {change.Id}: {change.Description}" + - (change.IsSuppressed ? " (suppressed)" : "")); - if (change.Recommendation != null) - { - await writer.WriteLineAsync(" " + change.Recommendation); - } - } - } - + id = change.Id, + type = Category(change.Category), + severity = change.Severity.ToString().ToLowerInvariant(), + suppressed = change.IsSuppressed, + location = change.Location, + description = change.Description, + recommendation = change.Recommendation + }) + })); return result.ExitCode; } - catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + + var verbose = args.Has("--verbose"); + foreach (var change in result.Changes.Where(change => + verbose || (!change.IsSuppressed && change.Severity != ChangeSeverity.Info))) { - return await SchemaDiagnostics.WriteAsync(standardError, options.ErrorFormat, - [new ParseError(error.Message, currentPath, 0, 0)]); + var writer = change.Severity == ChangeSeverity.Info || change.IsSuppressed ? Console.Out : Console.Error; + writer.WriteLine($"{change.Location}: {change.Severity.ToString().ToLowerInvariant()} {change.Id}: {change.Description}" + + (change.IsSuppressed ? " (suppressed)" : "")); + if (change.Recommendation != null) + { + writer.WriteLine(" " + change.Recommendation); + } } + + return result.ExitCode; } private static string Category(ChangeCategory category) => category switch diff --git a/Bond.Parser.CLI/CSharpGeneration.cs b/Bond.Parser.CLI/CSharpGeneration.cs deleted file mode 100644 index 9ae5863..0000000 --- a/Bond.Parser.CLI/CSharpGeneration.cs +++ /dev/null @@ -1,194 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Bond.Parser.CodeGeneration; -using Bond.Parser.Parser; - -namespace Bond.Parser.CLI; - -internal sealed record GeneratedFile(string Path, string Code); - -internal sealed record GenerationPlan( - string OutputDirectory, - IReadOnlyList Outputs, - IReadOnlyList PendingWrites); - -internal sealed class CSharpGeneration(GenerateOptions options, CancellationToken cancellationToken) -{ - private readonly List _errors = []; - - internal IReadOnlyList Errors => _errors; - internal string CurrentPath { get; private set; } = options.OutputDirectory!; - - internal async Task PrepareAsync() - { - var outputDirectory = Path.GetFullPath(options.OutputDirectory!); - var importDirectories = options.ImportDirectories.Select(Path.GetFullPath).ToArray(); - var inputs = ResolveInputs(outputDirectory); - if (_errors.Count != 0) - { - return null; - } - - var resolver = SchemaFiles.ImportResolver(importDirectories, cancellationToken); - var outputs = await GenerateSourcesAsync(inputs, resolver); - if (_errors.Count != 0) - { - return null; - } - - var pendingWrites = await PrepareWritesAsync(outputDirectory, outputs); - return _errors.Count == 0 - ? new GenerationPlan(outputDirectory, outputs, pendingWrites) - : null; - } - - internal async Task ApplyAsync(GenerationPlan plan) - { - // Preparation validates every input and destination before any file or directory is created. - CurrentPath = plan.OutputDirectory; - Directory.CreateDirectory(plan.OutputDirectory); - foreach (var output in plan.PendingWrites) - { - CurrentPath = output.Path; - await File.WriteAllTextAsync(output.Path, output.Code, cancellationToken); - } - } - - private List<(string Input, string Output)> ResolveInputs(string outputDirectory) - { - var inputs = new List<(string Input, string Output)>(); - var outputNames = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var input in options.Inputs) - { - CurrentPath = input; - var fullPath = Path.GetFullPath(input); - var name = Path.GetFileNameWithoutExtension(fullPath); - if (!string.Equals(Path.GetExtension(fullPath), ".bond", StringComparison.OrdinalIgnoreCase) - || name.Length == 0) - { - _errors.Add(new ParseError("Input must be a named .bond file.", input, 0, 0)); - continue; - } - - var outputName = name + ".g.cs"; - if (!outputNames.Add(outputName)) - { - _errors.Add(new ParseError( - $"Output-name collision: '{outputName}'. Input basenames must be unique (case-insensitive).", - input, 0, 0)); - } - - inputs.Add((fullPath, Path.Combine(outputDirectory, outputName))); - } - - return inputs; - } - - private async Task> GenerateSourcesAsync( - IEnumerable<(string Input, string Output)> inputs, ImportResolver resolver) - { - var generationOptions = options.CreateGenerationOptions(); - var outputs = new List(); - foreach (var (input, output) in inputs) - { - CurrentPath = input; - var content = await File.ReadAllTextAsync(input, cancellationToken); - var parsed = await ParserFacade.ParseContentAsync(content, input, resolver); - if (!parsed.Success) - { - AddErrors(parsed.Errors, "Parsing produced no schema."); - continue; - } - - var generated = CSharpGenerator.Generate(parsed.Ast!, input, generationOptions); - if (!generated.Success) - { - AddErrors(generated.Errors, "C# generation produced no output."); - continue; - } - - outputs.Add(new GeneratedFile(output, generated.Code!)); - } - - return outputs; - } - - private async Task> PrepareWritesAsync( - string outputDirectory, IReadOnlyList outputs) - { - CurrentPath = outputDirectory; - if (File.Exists(outputDirectory)) - { - _errors.Add(new ParseError("Output directory is an existing file.", outputDirectory, 0, 0)); - } - - IEnumerable entries = Directory.Exists(outputDirectory) - ? Directory.EnumerateFileSystemEntries(outputDirectory) - : []; - var existingEntries = entries.ToLookup(Path.GetFileName, StringComparer.OrdinalIgnoreCase); - var pending = new List(); - - foreach (var output in outputs) - { - CurrentPath = output.Path; - var outputName = Path.GetFileName(output.Path); - var matches = existingEntries[outputName].ToArray(); - var differentCasing = matches.Length == 1 - && !string.Equals(Path.GetFileName(matches[0]), outputName, StringComparison.Ordinal); - if (matches.Length > 1 || differentCasing) - { - _errors.Add(new ParseError( - "Output-name collision with an existing directory entry (case-insensitive).", output.Path, 0, 0)); - continue; - } - - if (matches.Length == 1) - { - var attributes = File.GetAttributes(matches[0]); - if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) - { - _errors.Add(new ParseError( - "Output path is a directory or symbolic link; refusing to overwrite it.", output.Path, 0, 0)); - continue; - } - - var existing = await File.ReadAllTextAsync(output.Path, cancellationToken); - if (!HasGeneratedHeader(existing)) - { - _errors.Add(new ParseError("Refusing to overwrite a non-generated file.", output.Path, 0, 0)); - continue; - } - - if (string.Equals(existing, output.Code, StringComparison.Ordinal)) - { - continue; - } - } - - pending.Add(output); - } - - return pending; - } - - private void AddErrors(IReadOnlyList errors, string missingResult) - { - if (errors.Count == 0) - { - _errors.Add(new ParseError(missingResult, CurrentPath, 0, 0)); - } - else - { - _errors.AddRange(errors); - } - } - - private static bool HasGeneratedHeader(string content) => - content == CSharpGenerator.GeneratedHeader - || content.StartsWith(CSharpGenerator.GeneratedHeader + "\n", StringComparison.Ordinal) - || content.StartsWith(CSharpGenerator.GeneratedHeader + "\r\n", StringComparison.Ordinal); -} diff --git a/Bond.Parser.CLI/CheckCommand.cs b/Bond.Parser.CLI/CheckCommand.cs deleted file mode 100644 index 90c94a2..0000000 --- a/Bond.Parser.CLI/CheckCommand.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Bond.Parser.Parser; - -namespace Bond.Parser.CLI; - -public static class CheckCommand -{ - public static async Task RunAsync(string[] args, TextWriter standardOutput, TextWriter standardError, - CancellationToken cancellationToken = default) - { - var options = SchemaCommandOptions.Parse(args, comparison: false); - if (options.Errors.Count != 0) - { - return await SchemaDiagnostics.WriteAsync(standardError, options.ErrorFormat, - options.Errors.Select(error => new ParseError(error, null, 0, 0))); - } - - if (options.Help) - { - await standardOutput.WriteLineAsync(""" - Usage: bond check [options] - - Validate syntax, types, and imports without generating files. - - -I, --import-dir Import search directory (repeatable) - --error-format text|json Diagnostics on stderr (default: text) - -h, --help Show this help - - Exit codes: 0 valid, 1 invalid schema, 2 invalid usage or I/O failure. - """); - return 0; - } - - var file = options.Input!; - try - { - file = Path.GetFullPath(file); - var result = await ParserFacade.ParseFileAsync(file, - SchemaFiles.ImportResolver(options.ImportDirectories, cancellationToken), cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - if (result.Success) - { - return 0; - } - - return await SchemaDiagnostics.WriteAsync(standardError, options.ErrorFormat, result.Errors, exitCode: 1); - } - catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) - { - return await SchemaDiagnostics.WriteAsync(standardError, options.ErrorFormat, - [new ParseError(error.Message, file, 0, 0)]); - } - } -} diff --git a/Bond.Parser.CLI/CommandLineOption.cs b/Bond.Parser.CLI/CommandLineOption.cs deleted file mode 100644 index 730caf4..0000000 --- a/Bond.Parser.CLI/CommandLineOption.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Bond.Parser.CLI; - -internal readonly record struct CommandLineOption(string Name, string? InlineValue) -{ - internal static CommandLineOption Parse(string argument) - { - var separator = argument.IndexOf('='); - return separator < 0 - ? new CommandLineOption(argument, null) - : new CommandLineOption(argument[..separator], argument[(separator + 1)..]); - } - - internal bool TryReadValue(string[] arguments, ref int index, out string value) - { - if (InlineValue is not null) - { - value = InlineValue; - return true; - } - - var next = index + 1; - if (next < arguments.Length && !arguments[next].StartsWith('-')) - { - index = next; - value = arguments[next]; - return true; - } - - value = ""; - return false; - } -} diff --git a/Bond.Parser.CLI/SchemaDiagnostics.cs b/Bond.Parser.CLI/Diagnostics.cs similarity index 58% rename from Bond.Parser.CLI/SchemaDiagnostics.cs rename to Bond.Parser.CLI/Diagnostics.cs index 01545ca..b83ce8c 100644 --- a/Bond.Parser.CLI/SchemaDiagnostics.cs +++ b/Bond.Parser.CLI/Diagnostics.cs @@ -1,23 +1,20 @@ +using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Text.Json; -using System.Threading.Tasks; using Bond.Parser.Parser; namespace Bond.Parser.CLI; -internal static class SchemaDiagnostics +internal static class Diagnostics { - internal static async Task WriteAsync(TextWriter writer, string format, IEnumerable errors, - int exitCode = 2) + public static int Report(IEnumerable errors, string format, int exitCode) { var values = errors.ToArray(); if (format == "json") { - await writer.WriteLineAsync(JsonSerializer.Serialize(new + Console.Error.WriteLine(JsonSerializer.Serialize(new { - error = "schema_error", errors = values.Select(error => new { file = error.FilePath, @@ -31,7 +28,7 @@ await writer.WriteLineAsync(JsonSerializer.Serialize(new { foreach (var error in values) { - await writer.WriteLineAsync($"{error.FilePath ?? "bond"}({error.Line},{error.Column}): error: {error.Message}"); + Console.Error.WriteLine($"{error.FilePath ?? "bond"}({error.Line},{error.Column}): error: {error.Message}"); } } diff --git a/Bond.Parser.CLI/GenerateCommand.cs b/Bond.Parser.CLI/GenerateCommand.cs deleted file mode 100644 index c0147f7..0000000 --- a/Bond.Parser.CLI/GenerateCommand.cs +++ /dev/null @@ -1,138 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Bond.Parser.Parser; - -namespace Bond.Parser.CLI; - -public static class GenerateCommand -{ - public static async Task RunAsync( - string[] args, - TextWriter standardOutput, - TextWriter standardError, - CancellationToken cancellationToken = default) - { - var options = GenerateOptions.Parse(args); - if (options.Errors.Count > 0) - { - return await WriteErrorsAsync(standardError, options.ErrorFormat, options.Errors); - } - - if (options.Help) - { - await standardOutput.WriteLineAsync(options.Language is null ? GenerateHelp : CSharpHelp); - return 0; - } - - var generation = new CSharpGeneration(options, cancellationToken); - try - { - var plan = await generation.PrepareAsync(); - if (plan is null) - { - return await WriteErrorsAsync(standardError, options.ErrorFormat, generation.Errors); - } - - await generation.ApplyAsync(plan); - foreach (var output in plan.Outputs) - { - await standardOutput.WriteLineAsync(output.Path); - } - - return 0; - } - catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) - { - var errors = generation.Errors.Append(new ParseError(error.Message, generation.CurrentPath, 0, 0)).ToArray(); - return await WriteErrorsAsync(standardError, options.ErrorFormat, errors); - } - } - - private static async Task WriteErrorsAsync(TextWriter writer, string format, IReadOnlyList errors) - { - if (format == "json") - { - await writer.WriteLineAsync(JsonSerializer.Serialize(new - { - error = "generation_error", - message = "C# generation failed.", - errors = errors.Select(error => new - { - line = error.Line, - column = error.Column, - message = error.Message, - file = error.FilePath ?? "bond" - }) - })); - } - else - { - foreach (var error in errors) - { - await writer.WriteLineAsync($"{error.FilePath ?? "bond"}({error.Line},{error.Column}): error BOND1001: {error.Message}"); - } - } - - return 1; - } - - private const string GenerateHelp = """ - C# model generation. - - Usage: bond generate csharp ... -o [options] - - Generate model-only C# code requiring Bond.Runtime.CSharp. - Only csharp is supported. - Run bond generate csharp --help for options and limitations. - """; - - private const string CSharpHelp = """ - C# model generation. - - Usage: bond generate csharp ... -o [options] - - Generate model-only C# code requiring Bond.Runtime.CSharp. - One .g.cs is generated per explicit input; imports are resolved - but are not generated unless explicitly selected. - - Options: - -o, --output-dir Required output directory - -I, --import-dir Import search directory (repeatable, searched in order) - -n, --namespace Map an exact C# namespace (repeatable) - -u, --using Add a C# using directive (repeatable) - --type-map Map an IDL alias to a CLR type (repeatable) - --descriptors Emit reflection-free schema descriptors - --clone, --clonable Emit deep cloning methods - --equality, --default-equals Emit structural equality and hashing - --debugger Emit safe debugger displays and field views - --to-string Emit bounded compact ToString() summaries - --error-format Diagnostics on stderr (default: text) - -h, --help Show this help - -- Treat remaining arguments as positional inputs - - Imports are searched relative to the importing file before import directories. - Options accept both --option value and --option=value. - Generates structs and enums, including generics, inheritance, views, aliases, - and bond_meta fields. Services produce no C# RPC types, matching gbc. - No protocol selection or serializer code is generated. - Plain models are the default. Enabling any model feature requires BondTools.Models. - Use the BondTools.Models version shown in the generated header; bond --version - reports the tool version. Model support is a separate package, not a serializer. - Individual feature flags are additive. Cloning and equality may materialize bonded - payloads; debugger inspection never does. External CLR types may require typed adapters. - Type mappings use qualified IDL alias names (or a local alias name) and qualified CLR - types. Generic templates use {0}, {1}, etc. Supply BondTypeAliasConverter.Convert - overloads in each consuming model namespace for conversions in both directions. - Custom mapped defaults are converted explicitly; mappings do not change wire types. - The original Bond runtime supports only zero/empty/nothing defaults for custom CLR - aliases; other mapped scalar defaults are rejected to avoid changing schema semantics. - - Example: - bond generate csharp schemas/order.bond -o out/generated - """; -} diff --git a/Bond.Parser.CLI/GenerateOptions.cs b/Bond.Parser.CLI/GenerateOptions.cs deleted file mode 100644 index 6a55b8e..0000000 --- a/Bond.Parser.CLI/GenerateOptions.cs +++ /dev/null @@ -1,179 +0,0 @@ -using System; -using System.Collections.Generic; -using Bond.Parser.CodeGeneration; -using Bond.Parser.Parser; - -namespace Bond.Parser.CLI; - -internal sealed class GenerateOptions -{ - internal string? Language { get; private set; } - internal List Inputs { get; } = []; - internal string? OutputDirectory { get; private set; } - internal List ImportDirectories { get; } = []; - internal List UsingNamespaces { get; } = []; - internal List NamespaceMappings { get; } = []; - internal List TypeMappings { get; } = []; - internal CSharpModelFeatures ModelFeatures { get; private set; } - internal string ErrorFormat { get; private set; } = "text"; - internal bool Help { get; private set; } - internal List Errors { get; } = []; - - internal CSharpGenerationOptions CreateGenerationOptions() => new() - { - UsingNamespaces = UsingNamespaces, - NamespaceMappings = NamespaceMappings, - TypeMappings = TypeMappings, - ModelFeatures = ModelFeatures - }; - - internal static GenerateOptions Parse(string[] args) - { - var options = new GenerateOptions(); - var positionalOnly = false; - var seenOutput = false; - var seenFormat = false; - - for (var i = 0; i < args.Length; i++) - { - var argument = args[i]; - if (!positionalOnly && argument == "--") - { - positionalOnly = true; - continue; - } - - if (!positionalOnly && argument is "-h" or "--help") - { - options.Help = true; - continue; - } - - if (positionalOnly || !argument.StartsWith('-')) - { - if (options.Language is null) - { - options.Language = argument; - } - else - { - options.Inputs.Add(argument); - } - - continue; - } - - var option = CommandLineOption.Parse(argument); - var feature = option.Name switch - { - "--descriptors" => CSharpModelFeatures.Descriptors, - "--clone" or "--clonable" => CSharpModelFeatures.Cloning, - "--equality" or "--default-equals" => CSharpModelFeatures.Equality, - "--debugger" => CSharpModelFeatures.Debugger, - "--to-string" => CSharpModelFeatures.StringRepresentation, - _ => CSharpModelFeatures.None - }; - if (feature != CSharpModelFeatures.None) - { - if (option.InlineValue is not null) - { - options.Error($"Flag '{option.Name}' does not take a value."); - } - else - { - options.ModelFeatures |= feature; - } - - continue; - } - - if (option.Name is not ("-o" or "--output-dir" or "-I" or "--import-dir" or "--error-format" - or "-n" or "--namespace" or "-u" or "--using" or "--type-map")) - { - options.Error($"Unknown option '{option.Name}'."); - continue; - } - - if (!option.TryReadValue(args, ref i, out var value)) - { - options.Error($"Option '{option.Name}' requires a value."); - continue; - } - - if (value.Length == 0) - { - options.Error($"Option '{option.Name}' requires a non-empty value."); - continue; - } - - switch (option.Name) - { - case "-o" or "--output-dir": - if (seenOutput) - { - options.Error("Option '--output-dir' may only be specified once."); - } - - seenOutput = true; - options.OutputDirectory = value; - break; - case "-I" or "--import-dir": - options.ImportDirectories.Add(value); - break; - case "-n" or "--namespace": - options.NamespaceMappings.Add(value); - break; - case "-u" or "--using": - options.UsingNamespaces.Add(value); - break; - case "--type-map": - options.TypeMappings.Add(value); - break; - case "--error-format": - if (seenFormat) - { - options.Error("Option '--error-format' may only be specified once."); - } - - seenFormat = true; - if (value is "text" or "json") - { - options.ErrorFormat = value; - } - else - { - options.Error($"Unsupported error format '{value}'; expected 'text' or 'json'."); - } - - break; - } - } - - if (options.Language is not null && !options.Language.Equals("csharp", StringComparison.OrdinalIgnoreCase)) - { - options.Error($"Unsupported language '{options.Language}'; only 'csharp' is supported."); - } - - if (!options.Help) - { - if (options.Language is null) - { - options.Error("A language is required: bond generate csharp."); - } - - if (options.Inputs.Count == 0) - { - options.Error("At least one explicit .bond input file is required."); - } - - if (options.OutputDirectory is null) - { - options.Error("Option '-o'/'--output-dir' is required."); - } - } - - return options; - } - - private void Error(string message) => Errors.Add(new ParseError(message, "bond", 0, 0)); -} diff --git a/Bond.Parser.CLI/GitReference.cs b/Bond.Parser.CLI/GitReference.cs new file mode 100644 index 0000000..ef4751b --- /dev/null +++ b/Bond.Parser.CLI/GitReference.cs @@ -0,0 +1,100 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Bond.Parser.Parser; + +namespace Bond.Parser.CLI; + +/// Parses a schema as of a Git revision, reading its imports from the same revision. +internal static class GitReference +{ + public static async Task ParseAsync(string reference, string input, IReadOnlyList importDirectories, + ParseOptions options) + { + var separator = reference.IndexOf('='); + if (separator < 0 || reference[..separator] is not (".git#branch" or ".git#tag" or ".git#commit")) + { + throw new UsageException("Git references use .git#branch=, .git#tag=, or .git#commit=."); + } + + var directory = Path.GetDirectoryName(input)!; + + // Relative to the input's directory rather than --show-toplevel (a physical path), so the root stays in the + // same path space as the input when it is reached through a symbolic link. + var root = Path.GetFullPath(Path.Combine(directory, (await Git(directory, "rev-parse", "--show-cdup")).Trim())); + var revision = (await Git(root, "rev-parse", "--verify", "--end-of-options", + reference[(separator + 1)..] + "^{commit}")).Trim(); + + string? Relative(string path) + { + var relative = Path.GetRelativePath(root, path); + return Path.IsPathRooted(relative) || relative == ".." || relative.StartsWith(".." + Path.DirectorySeparatorChar) + ? null + : relative.Replace('\\', '/'); + } + + async Task Show(string relative) + { + var (exitCode, output, _) = await Run(root, "show", $"{revision}:{relative}"); + return exitCode == 0 ? output : null; + } + + var inputPath = Relative(input) ?? throw new IOException("The input schema must be inside the selected Git repository."); + var content = await Show(inputPath) ?? throw new IOException($"'{input}' does not exist at {revision}."); + var directories = importDirectories.Select(Path.GetFullPath).ToArray(); + ImportResolver resolver = async (currentFile, importPath) => + { + foreach (var candidate in DefaultImportResolver.Candidates(currentFile, importPath, directories)) + { + // Imports outside the repository, such as shared import directories, come from the working tree. + var imported = Relative(candidate) is { } relative + ? await Show(relative) + : File.Exists(candidate) ? await File.ReadAllTextAsync(candidate) : null; + if (imported != null) + { + return (candidate, imported); + } + } + + throw new FileNotFoundException($"Imported file not found at {revision}: {importPath}", importPath); + }; + + return await ParserFacade.ParseContentAsync(content, input, resolver, options); + } + + private static async Task Git(string directory, params string[] arguments) + { + var (exitCode, output, error) = await Run(directory, arguments); + return exitCode == 0 ? output : throw new IOException(error.Trim()); + } + + private static async Task<(int ExitCode, string Output, string Error)> Run(string directory, params string[] arguments) + { + var start = new ProcessStartInfo("git") + { + WorkingDirectory = directory, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + foreach (var argument in arguments) + { + start.ArgumentList.Add(argument); + } + + try + { + using var process = Process.Start(start)!; + var output = process.StandardOutput.ReadToEndAsync(); + var error = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + return (process.ExitCode, await output, await error); + } + catch (Win32Exception error) + { + throw new IOException($"Could not start git: {error.Message}", error); + } + } +} diff --git a/Bond.Parser.CLI/Program.cs b/Bond.Parser.CLI/Program.cs index f1929df..aadf5f5 100644 --- a/Bond.Parser.CLI/Program.cs +++ b/Bond.Parser.CLI/Program.cs @@ -1,222 +1,212 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading.Tasks; using Bond.Parser.CodeGeneration; using Bond.Parser.Formatting; +using Bond.Parser.Parser; namespace Bond.Parser.CLI; public static class Program { - static async Task Main(string[] args) + private const string Help = """ + Bond schema tools + + Usage: + bond check [options] + bond breaking --against [options] + bond format [--check] (alias: fmt) + bond generate csharp ... -o [options] + + Common options: + -I, --import-dir Import search directory, searched after the importing file's directory (repeatable) + --error-format text|json Diagnostic format (default: text) + -h, --help Show this help + --version Show the version + + breaking: + --against A schema file, or .git#branch=, .git#tag=, .git#commit= + --ignore-imports Compare root declarations without resolving imports + --suppress Acknowledge diagnostic IDs (comma-separated, repeatable) + --list-rules List diagnostic IDs + -v, --verbose Include compatible and suppressed changes + + format: + --check Report files that need formatting instead of rewriting them + + generate csharp (writes .g.cs per input; requires Bond.Runtime.CSharp): + -o, --output-dir Output directory + -n, --namespace Map a C# namespace (repeatable) + -u, --using Add a using directive (repeatable) + --type-map Map a Bond alias to a CLR type; converters are user-supplied (repeatable) + --clone Generate deep Clone() + --equality Generate structural Equals() and GetHashCode() + --to-string Generate ToString() + + Exit codes: + 0 success + 1 invalid schema, breaking change, unformatted file, or failed generation + 2 usage error, or an invalid schema passed to breaking + """; + + public static async Task Main(string[] args) { - if (args.Length == 1 && args[0] == "--version") + try { - Console.WriteLine(CSharpGenerator.Version); - return 0; - } - - if (args.Length > 0 && args[0].Equals("generate", StringComparison.OrdinalIgnoreCase)) - { - return await GenerateCommand.RunAsync(args[1..], Console.Out, Console.Error); - } - - if (args.Length > 0 && args[0].Equals("breaking", StringComparison.OrdinalIgnoreCase)) - { - return await BreakingCommand.RunAsync(args[1..], Console.Out, Console.Error); - } + switch (args) + { + case []: + Console.Error.WriteLine(Help); + return 2; + case ["-h" or "--help"]: + Console.WriteLine(Help); + return 0; + case ["--version"]: + Console.WriteLine(CSharpGenerator.Version); + return 0; + } - if (args.Length > 0 && args[0].Equals("check", StringComparison.OrdinalIgnoreCase)) - { - return await CheckCommand.RunAsync(args[1..], Console.Out, Console.Error); + var rest = args[1..]; + return args[0].ToLowerInvariant() switch + { + "check" => await Check(rest), + "breaking" => await BreakingCommand.Run(rest), + "format" or "fmt" => await Format(rest), + "generate" => await Generate(rest), + var command => throw new UsageException($"Unknown command '{command}'.") + }; } - - if (args.Length == 0 || args[0] is "--help" or "-h") + catch (UsageException error) { - ShowHelp(); - return args.Length == 0 ? 1 : 0; + Console.Error.WriteLine($"error: {error.Message} Run 'bond --help' for usage."); + return 2; } - - var command = args[0].ToLowerInvariant(); - var rest = args[1..]; - - return command switch - { - "fmt" or "format" => await RunFormatCommand(rest), - _ => UnknownCommand(command) - }; } - static int UnknownCommand(string command) + internal static int ShowHelp() { - WriteError($"Error: unknown command '{command}'. Run with --help to see available commands."); - return 1; + Console.WriteLine(Help); + return 0; } - static async Task RunFormatCommand(string[] args) + private static async Task Check(string[] rest) { - var parsed = new Args(args); - if (parsed.HasFlag("-h", "--help")) + var args = Arguments.Parse(rest, [], ["--import-dir", "--error-format"]); + if (args.Has("--help")) { - ShowHelp(); - return 0; + return ShowHelp(); } - var filePath = parsed.PositionalOrNull; - if (filePath is null) - { - WriteError("Error: No file specified"); - ShowHelp(); - return 1; - } - - var check = parsed.HasFlag("--check"); + var format = args.ErrorFormat(); + var input = Path.GetFullPath(args.Positional("schema file")); + var result = await ParserFacade.ParseFileAsync(input, DefaultImportResolver.Create(args.All("--import-dir"))); + return result.Success ? 0 : Diagnostics.Report(result.Errors, format, exitCode: 1); + } - if (!File.Exists(filePath)) + private static async Task Format(string[] rest) + { + var args = Arguments.Parse(rest, ["--check"], ["--error-format"]); + if (args.Has("--help")) { - WriteError($"Error: File not found: {filePath}"); - return 1; + return ShowHelp(); } - var content = await File.ReadAllTextAsync(filePath); - var result = BondFormatter.Format(content, Path.GetFullPath(filePath)); - - if (!result.Success) + var format = args.ErrorFormat(); + var input = args.Positional("schema file"); + var path = Path.GetFullPath(input); + if (!File.Exists(path)) { - Console.Error.WriteLine($"format failed: {filePath}"); - foreach (var error in result.Errors) - { - Console.Error.WriteLine($"{error.Line}:{error.Column}: {error.Message}"); - if (error.FilePath is not null) - { - Console.Error.WriteLine($" in {error.FilePath}"); - } - } - - return 1; + return Diagnostics.Report([new ParseError($"File not found: {path}", path, 0, 0)], format, exitCode: 1); } - if (result.FormattedText is null) + var content = await File.ReadAllTextAsync(path); + var result = BondFormatter.Format(content, path); + if (!result.Success) { - WriteError("Error: Format produced no output"); - return 1; + return Diagnostics.Report(result.Errors, format, exitCode: 1); } - if (check) + if (result.FormattedText == content) { - if (!string.Equals(content, result.FormattedText, StringComparison.Ordinal)) - { - Console.Error.WriteLine($"{filePath} would be reformatted"); - return 1; - } - return 0; } - if (!string.Equals(content, result.FormattedText, StringComparison.Ordinal)) + if (args.Has("--check")) { - await File.WriteAllTextAsync(filePath, result.FormattedText); + Console.Error.WriteLine($"{input} would be reformatted"); + return 1; } + await File.WriteAllTextAsync(path, result.FormattedText); return 0; } - // Suppress ANSI color sequences when stderr is redirected so they don't leak into pipes / log files. - static void WriteError(string message) + private static async Task Generate(string[] rest) { - if (!Console.IsErrorRedirected) + var args = Arguments.Parse(rest, ["--clone", "--equality", "--to-string"], + ["--output-dir", "--import-dir", "--namespace", "--using", "--type-map", "--error-format"]); + if (args.Has("--help")) { - Console.ForegroundColor = ConsoleColor.Red; + return ShowHelp(); } - Console.Error.WriteLine(message); - - if (!Console.IsErrorRedirected) + var format = args.ErrorFormat(); + if (args.Positionals is not [var language, _, ..]) { - Console.ResetColor(); + throw new UsageException("Expected: bond generate csharp ... -o ."); } - } - static void ShowHelp() - { - Console.WriteLine("Bond Schema Tools"); - Console.WriteLine(); - Console.WriteLine("Usage:"); - Console.WriteLine(" bond check [options]"); - Console.WriteLine(" bond breaking --against [options]"); - Console.WriteLine(" bond format [options] (alias: bond fmt)"); - Console.WriteLine(" bond generate csharp ... -o [options]"); - Console.WriteLine(); - Console.WriteLine("Commands:"); - Console.WriteLine(" check Validate a Bond schema and its imports"); - Console.WriteLine(" breaking Check for breaking changes against a reference schema"); - Console.WriteLine(" format Format a Bond schema file (alias: fmt)"); - Console.WriteLine(" generate Generate C# models (run bond generate csharp --help)"); - Console.WriteLine(); - Console.WriteLine("Check Options:"); - Console.WriteLine(" -I, --import-dir Import search directory (repeatable)"); - Console.WriteLine(" --error-format Diagnostics: text, json (default: text)"); - Console.WriteLine(); - Console.WriteLine("Breaking Options:"); - Console.WriteLine(" --against Reference schema to compare against (file path or .git#branch=name)"); - Console.WriteLine(" --error-format Output format: text, json (default: text)"); - Console.WriteLine(" --ignore-imports Compare without resolving imports or types"); - Console.WriteLine(" --suppress Suppress selected diagnostic IDs"); - Console.WriteLine(); - Console.WriteLine("Format Options:"); - Console.WriteLine(" --check Exit non-zero if formatting is needed"); - Console.WriteLine(); - Console.WriteLine("Examples:"); - Console.WriteLine(" bond check schema.bond"); - Console.WriteLine(" bond breaking schema.bond --against schema_v1.bond"); - Console.WriteLine(" bond breaking schema.bond --against .git#branch=main --error-format=json"); - Console.WriteLine(" bond format schema.bond"); - Console.WriteLine(" bond generate csharp schemas/order.bond -o out/generated"); - Console.WriteLine(); - Console.WriteLine("Global Options:"); - Console.WriteLine(" -h, --help Show this help message"); - Console.WriteLine(" --version Show the package version"); - } - - private sealed class Args - { - private readonly string[] _args; - - public Args(string[] args) + if (!language.Equals("csharp", StringComparison.OrdinalIgnoreCase)) { - _args = args; + throw new UsageException($"Unsupported language '{language}'; only csharp is supported."); } - public string? PositionalOrNull + var output = Path.GetFullPath(args.Required("--output-dir")); + var options = new CSharpGenerationOptions + { + UsingNamespaces = args.All("--using"), + NamespaceMappings = args.All("--namespace"), + TypeMappings = args.All("--type-map"), + ModelFeatures = (args.Has("--clone") ? CSharpModelFeatures.Cloning : 0) + | (args.Has("--equality") ? CSharpModelFeatures.Equality : 0) + | (args.Has("--to-string") ? CSharpModelFeatures.StringRepresentation : 0) + }; + + var resolver = DefaultImportResolver.Create(args.All("--import-dir")); + var errors = new List(); + var files = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var input in args.Positionals.Skip(1).Select(Path.GetFullPath)) { - get + var parsed = await ParserFacade.ParseFileAsync(input, resolver); + var generated = parsed.Success ? CSharpGenerator.Generate(parsed.Ast!, input, options) : null; + errors.AddRange(generated?.Errors ?? parsed.Errors); + + var path = Path.Combine(output, Path.GetFileNameWithoutExtension(input) + ".g.cs"); + if (!files.TryAdd(path, generated?.Code ?? "")) { - for (var i = 0; i < _args.Length; i++) - { - if (!_args[i].StartsWith('-')) - { - return _args[i]; - } - } - - return null; + errors.Add(new ParseError($"Another input also generates '{path}'.", input, 0, 0)); } } - public bool HasFlag(params string[] names) + if (errors.Count != 0) + { + return Diagnostics.Report(errors, format, exitCode: 1); + } + + Directory.CreateDirectory(output); + foreach (var (path, code) in files) { - foreach (var arg in _args) + // Unchanged files keep their timestamps, so incremental builds are not invalidated. + if (!File.Exists(path) || await File.ReadAllTextAsync(path) != code) { - foreach (var name in names) - { - if (arg == name) - { - return true; - } - } + await File.WriteAllTextAsync(path, code); } - return false; + Console.WriteLine(path); } + + return 0; } } diff --git a/Bond.Parser.CLI/SchemaCommandOptions.cs b/Bond.Parser.CLI/SchemaCommandOptions.cs deleted file mode 100644 index 7602745..0000000 --- a/Bond.Parser.CLI/SchemaCommandOptions.cs +++ /dev/null @@ -1,166 +0,0 @@ -using System; -using System.Collections.Generic; -using Bond.Parser.Compatibility; - -namespace Bond.Parser.CLI; - -internal sealed class SchemaCommandOptions -{ - internal string? Input { get; private set; } - internal string? Against { get; private set; } - internal List ImportDirectories { get; } = []; - internal HashSet Suppressions { get; } = new(StringComparer.Ordinal); - internal bool IgnoreImports { get; private set; } - internal bool Verbose { get; private set; } - internal bool Help { get; private set; } - internal bool ListRules { get; private set; } - internal string ErrorFormat { get; private set; } = "text"; - internal List Errors { get; } = []; - - internal CompatibilityOptions CreateCompatibilityOptions() => new() - { - IncludeImports = !IgnoreImports, - AllowUnresolvedTypes = IgnoreImports, - SuppressedDiagnosticIds = Suppressions - }; - - internal static SchemaCommandOptions Parse(string[] args, bool comparison) - { - var options = new SchemaCommandOptions(); - var positionalOnly = false; - var seen = new HashSet(StringComparer.Ordinal); - - for (var i = 0; i < args.Length; i++) - { - var argument = args[i]; - if (!positionalOnly && argument == "--") - { - positionalOnly = true; - continue; - } - - if (positionalOnly || !argument.StartsWith('-')) - { - if (options.Input != null) - { - options.Errors.Add("Only one root schema may be selected."); - } - else - { - options.Input = argument; - } - - continue; - } - - var option = CommandLineOption.Parse(argument); - var name = option.Name; - var isFlag = name is "-h" or "--help" - || (comparison && name is "-v" or "--verbose" or "--ignore-imports" or "--list-rules"); - - if (isFlag) - { - if (option.InlineValue is not null) - { - options.Errors.Add($"Flag '{name}' does not accept a value."); - } - else - { - switch (name) - { - case "-h" or "--help": - options.Help = true; - break; - case "-v" or "--verbose": - options.Verbose = true; - break; - case "--ignore-imports": - options.IgnoreImports = true; - break; - case "--list-rules": - options.ListRules = true; - break; - } - } - - continue; - } - - var allowed = name is "-I" or "--import-dir" or "--error-format" - || (comparison && name is "--against" or "--suppress"); - if (!allowed) - { - options.Errors.Add($"Unknown option '{name}'."); - continue; - } - - if (!option.TryReadValue(args, ref i, out var value)) - { - options.Errors.Add($"Option '{name}' requires a value."); - continue; - } - - if (string.IsNullOrWhiteSpace(value)) - { - options.Errors.Add($"Option '{name}' requires a non-empty value."); - continue; - } - - if (name is "--against" or "--error-format" && !seen.Add(name)) - { - options.Errors.Add($"Option '{name}' may only be specified once."); - } - - switch (name) - { - case "-I" or "--import-dir": - options.ImportDirectories.Add(value); - break; - case "--against": - options.Against = value; - break; - case "--suppress": - var suppressions = Split(value); - if (suppressions.Length == 0) - { - options.Errors.Add("--suppress requires at least one diagnostic ID."); - } - else - { - options.Suppressions.UnionWith(suppressions); - } - - break; - case "--error-format": - if (value is "text" or "json") - { - options.ErrorFormat = value; - } - else - { - options.Errors.Add("Error format must be text or json."); - } - - break; - } - } - - if (!options.Help && !options.ListRules) - { - if (options.Input == null) - { - options.Errors.Add("A .bond schema file is required."); - } - - if (comparison && options.Against == null) - { - options.Errors.Add("--against is required."); - } - } - - return options; - } - - internal static string[] Split(string value) => - value.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); -} diff --git a/Bond.Parser.CLI/SchemaFiles.cs b/Bond.Parser.CLI/SchemaFiles.cs deleted file mode 100644 index 2bbd146..0000000 --- a/Bond.Parser.CLI/SchemaFiles.cs +++ /dev/null @@ -1,172 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Bond.Parser.Parser; - -namespace Bond.Parser.CLI; - -internal static class SchemaFiles -{ - internal static ImportResolver ImportResolver(IEnumerable directories, CancellationToken cancellationToken) - { - var imports = directories.Select(Path.GetFullPath).ToArray(); - return async (currentFile, importPath) => - { - foreach (var candidate in ImportCandidates(currentFile, importPath, imports)) - { - var content = await ReadIfPresent(candidate, cancellationToken); - if (content != null) - { - return (candidate, content); - } - } - - throw new FileNotFoundException($"Imported file not found: {importPath}", importPath); - }; - } - - internal static IEnumerable ImportCandidates(string currentFile, string importPath, IEnumerable directories) - { - var name = importPath.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar); - return new[] { Path.GetDirectoryName(currentFile)! }.Concat(directories) - .Select(directory => Path.GetFullPath(Path.Combine(directory, name))); - } - - internal static async Task ReadIfPresent(string path, CancellationToken cancellationToken) - { - try - { - return await File.ReadAllTextAsync(path, cancellationToken); - } - catch (FileNotFoundException) - { - return null; - } - catch (DirectoryNotFoundException) - { - return null; - } - } - - internal static async Task<(string Content, ImportResolver Resolver)> ReadGitReference( - string reference, string input, IEnumerable directories, CancellationToken cancellationToken) - { - var separator = reference.IndexOf('='); - if (separator < 0 || reference[..separator] is not (".git#branch" or ".git#tag" or ".git#commit")) - { - throw new ArgumentException("Git references use .git#branch=name, .git#tag=name, or .git#commit=hash."); - } - - var root = (await Git(Path.GetDirectoryName(input)!, cancellationToken, "rev-parse", "--show-toplevel")).Trim(); - var revision = (await Git(root, cancellationToken, "rev-parse", "--verify", "--end-of-options", - reference[(separator + 1)..] + "^{commit}")).Trim(); - var relative = Path.GetRelativePath(root, input); - if (!WithinRepository(relative)) - { - throw new ArgumentException("The input schema must be inside the selected Git repository."); - } - - var content = await Git(root, cancellationToken, "show", revision + ":" + relative.Replace('\\', '/')); - var imports = directories.Select(Path.GetFullPath).ToArray(); - var cache = new Dictionary(StringComparer.Ordinal); - - ImportResolver resolver = async (currentFile, importPath) => - { - foreach (var candidate in ImportCandidates(currentFile, importPath, imports)) - { - var path = Path.GetRelativePath(root, candidate); - string? imported; - if (WithinRepository(path)) - { - if (!cache.TryGetValue(path, out imported)) - { - var result = await RunGit(root, cancellationToken, "show", revision + ":" + path.Replace('\\', '/')); - imported = result.ExitCode == 0 ? result.Output : null; - cache[path] = imported; - } - } - else - { - imported = await ReadIfPresent(candidate, cancellationToken); - } - - if (imported != null) - { - return (candidate, imported); - } - } - - throw new FileNotFoundException($"Imported file not found at {revision}: {importPath}", importPath); - }; - - return (content, resolver); - } - - private static bool WithinRepository(string relative) => - !Path.IsPathRooted(relative) && relative != ".." - && !relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal); - - private static async Task Git(string directory, CancellationToken cancellationToken, params string[] arguments) - { - var result = await RunGit(directory, cancellationToken, arguments); - if (result.ExitCode != 0) - { - throw new IOException(result.Error.Trim()); - } - - return result.Output; - } - - private static async Task<(int ExitCode, string Output, string Error)> RunGit( - string directory, CancellationToken cancellationToken, params string[] arguments) - { - var start = new ProcessStartInfo("git") - { - WorkingDirectory = directory, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - foreach (var argument in arguments) - { - start.ArgumentList.Add(argument); - } - - using var process = new Process { StartInfo = start }; - try - { - if (!process.Start()) - { - throw new IOException("Could not start git."); - } - } - catch (Win32Exception startError) - { - throw new IOException($"Could not start git: {startError.Message}", startError); - } - - var output = process.StandardOutput.ReadToEndAsync(cancellationToken); - var error = process.StandardError.ReadToEndAsync(cancellationToken); - try - { - await process.WaitForExitAsync(cancellationToken); - } - catch (OperationCanceledException) - { - if (!process.HasExited) - { - process.Kill(entireProcessTree: true); - } - - throw; - } - - return (process.ExitCode, await output, await error); - } -} diff --git a/Bond.Parser/CodeGeneration/CSharpGenerationOptions.cs b/Bond.Parser/CodeGeneration/CSharpGenerationOptions.cs index 9847428..7cdf182 100644 --- a/Bond.Parser/CodeGeneration/CSharpGenerationOptions.cs +++ b/Bond.Parser/CodeGeneration/CSharpGenerationOptions.cs @@ -7,12 +7,10 @@ namespace Bond.Parser.CodeGeneration; public enum CSharpModelFeatures { None = 0, - Descriptors = 1, - Cloning = 2, - Equality = 4, - Debugger = 8, - StringRepresentation = 16, - All = Descriptors | Cloning | Equality | Debugger | StringRepresentation + Cloning = 1, + Equality = 2, + StringRepresentation = 4, + All = Cloning | Equality | StringRepresentation } public sealed record CSharpGenerationOptions @@ -23,9 +21,7 @@ public sealed record CSharpGenerationOptions public CSharpModelFeatures ModelFeatures { get; init; } public bool GenerateModelFeatures => ModelFeatures != CSharpModelFeatures.None; - public bool GenerateDescriptors => ModelFeatures.HasFlag(CSharpModelFeatures.Descriptors); public bool GenerateCloning => ModelFeatures.HasFlag(CSharpModelFeatures.Cloning); public bool GenerateEquality => ModelFeatures.HasFlag(CSharpModelFeatures.Equality); - public bool GenerateDebuggerSupport => ModelFeatures.HasFlag(CSharpModelFeatures.Debugger); public bool GenerateToString => ModelFeatures.HasFlag(CSharpModelFeatures.StringRepresentation); } diff --git a/Bond.Parser/CodeGeneration/CSharpGenerator.cs b/Bond.Parser/CodeGeneration/CSharpGenerator.cs index 57e07f8..af1bc3c 100644 --- a/Bond.Parser/CodeGeneration/CSharpGenerator.cs +++ b/Bond.Parser/CodeGeneration/CSharpGenerator.cs @@ -82,11 +82,6 @@ public CSharpGenerationResult Generate() Line(0, GeneratedHeader); Line(0, $"// bond-tools {Version}"); - if (options.GenerateModelFeatures) - { - Line(0, $"// BondTools.Models {Version}"); - } - Line(0, "#nullable disable"); Line(0); foreach (var name in _usingNamespaces) @@ -107,11 +102,6 @@ public CSharpGenerationResult Generate() { case StructDeclaration structure: EmitStruct((StructDeclaration)Canonical(structure)); - if (options.GenerateCloning || options.GenerateEquality || options.GenerateToString) - { - EmitModelCompanions((StructDeclaration)Canonical(structure)); - } - break; case EnumDeclaration enumeration: EmitEnum((EnumDeclaration)Canonical(enumeration)); @@ -131,18 +121,6 @@ public CSharpGenerationResult Generate() } } - if (options.GenerateDescriptors && _errors.Count == 0) - { - try - { - EmitMetadata(); - } - catch (GenerationException error) - { - _errors.Add(new ParseError(error.Message, filePath, error.Location.Line, error.Location.Column)); - } - } - return new CSharpGenerationResult(_errors.Count == 0 ? _code.ToString() : null, _errors); } @@ -162,7 +140,9 @@ private void EmitStruct(StructDeclaration structure) if (options.GenerateModelFeatures) { - EmitModelMembers(structure); + var self = QualifiedName(structure) + TypeArguments(structure.TypeParameters.Select(parameter => + Identifier(parameter.Name, structure.Location, typeName: true))); + EmitModelMembers(structure, self); } Line(1, "}"); @@ -176,20 +156,15 @@ private void EmitStructDeclaration(StructDeclaration structure, string name) Identifier(parameter.Name, structure.Location, typeName: true))); var baseType = structure.BaseType == null ? "" : " : " + MapType(structure.BaseType, structure.Location).Name; - if (options.GenerateModelFeatures) + if (options.GenerateCloning) { - baseType += (baseType.Length == 0 ? " : " : ", ") + ModelInterfaces(structure); + baseType += (baseType.Length == 0 ? " : " : ", ") + "global::System.ICloneable"; } BeginNamespace(structure); EmitDocumentation(structure.LeadingTrivia, structure.TrailingTrivia, 1); EmitAttributes(structure.Attributes, 1); EmitNamespaceAttribute(structure); - if (options.GenerateDebuggerSupport) - { - EmitModelAttributes(structure); - } - Line(1, "[global::Bond.Schema]"); Line(1, $"public partial class {name}{parameters}{baseType}"); foreach (var parameter in structure.TypeParameters) @@ -341,7 +316,7 @@ private void EmitEnum(EnumDeclaration enumeration) Line(0); } - private MappedType MapType(BondType type, SourceLocation location) + private MappedType MapType(BondType type, SourceLocation location, TypeMode mode = TypeMode.Clr) { switch (type) { @@ -377,26 +352,26 @@ private MappedType MapType(BondType type, SourceLocation location) return new("string", "string", "fullName"); case BondType.Blob: return new("global::System.ArraySegment", - _useBlobSchemaTags ? "global::Bond.Tag.blob" : "global::System.ArraySegment", - "new global::System.ArraySegment()", IsValueType: true, IsSchemaValueType: !_useBlobSchemaTags); + mode == TypeMode.Schema ? "global::Bond.Tag.blob" : "global::System.ArraySegment", + "new global::System.ArraySegment()", IsValueType: true, IsSchemaValueType: mode != TypeMode.Schema); case BondType.Vector vector: - return Collection("List", location, vector.ElementType); + return Collection("List", location, mode, vector.ElementType); case BondType.List list: - return Collection("LinkedList", location, list.ElementType); + return Collection("LinkedList", location, mode, list.ElementType); case BondType.Set set: - return Collection("HashSet", location, set.KeyType); + return Collection("HashSet", location, mode, set.KeyType); case BondType.Map map: - return Collection("Dictionary", location, map.KeyType, map.ValueType); + return Collection("Dictionary", location, mode, map.KeyType, map.ValueType); case BondType.Nullable nullable: { - var element = MapType(nullable.ElementType, location); + var element = MapType(nullable.ElementType, location, mode); return new(element.Name + (element.IsScalar ? "?" : ""), $"global::Bond.Tag.nullable<{element.SchemaType}>", IsValueType: !element.IsScalar && element.IsValueType, IsCustom: element.IsCustom); } case BondType.Maybe maybe: { - var element = MapType(maybe.ElementType, location); + var element = MapType(maybe.ElementType, location, mode); var suffix = element.IsScalar ? "?" : ""; // gbc retains an alias's annotation, without adding the "nothing" wrapper. @@ -416,12 +391,12 @@ private MappedType MapType(BondType type, SourceLocation location) Fail("bonded requires a struct or type parameter.", location); } - var element = MapType(bonded.StructType, location); + var element = MapType(bonded.StructType, location, mode); return new($"global::Bond.IBonded<{element.Name}>", $"global::Bond.IBonded<{element.SchemaType}>", $"global::Bond.Bonded<{element.Name}>.Empty"); } case BondType.TypeReference reference: - return MapReferencedType(reference, location); + return MapReferencedType(reference, location, mode); case BondType.TypeParameter parameter: { var name = Identifier(parameter.Param.Name, location, typeName: true); @@ -437,20 +412,20 @@ private MappedType MapType(BondType type, SourceLocation location) } } - private MappedType MapReferencedType(BondType.TypeReference reference, SourceLocation location) + private MappedType MapReferencedType(BondType.TypeReference reference, SourceLocation location, TypeMode mode) { var declaration = Canonical(reference.Declaration); - ValidateTypeArguments(declaration, reference.TypeArguments, location); + ValidateTypeArguments(declaration, reference.TypeArguments, location, mode: mode); if (declaration is AliasDeclaration alias) { ValidateAlias(alias); - if (TryMapAlias(alias, reference.TypeArguments, location, out var custom)) + if (mode == TypeMode.Clr && TryMapAlias(alias, reference.TypeArguments, location, out var custom)) { return custom; } var underlying = Substitute(alias.AliasedType, alias, reference.TypeArguments); - var mapped = MapType(underlying, location); + var mapped = MapType(underlying, location, mode); if (alias.AliasedType is BondType.Blob) { return mapped with @@ -463,7 +438,7 @@ private MappedType MapReferencedType(BondType.TypeReference reference, SourceLoc return mapped; } - var arguments = reference.TypeArguments.Select(argument => MapType(argument, location)).ToArray(); + var arguments = reference.TypeArguments.Select(argument => MapType(argument, location, mode)).ToArray(); var name = QualifiedName(declaration) + TypeArguments(arguments.Select(argument => argument.Name)); var schema = QualifiedName(declaration) + TypeArguments(arguments.Select(argument => argument.SchemaType)); return declaration switch @@ -474,9 +449,9 @@ private MappedType MapReferencedType(BondType.TypeReference reference, SourceLoc }; } - private MappedType Collection(string name, SourceLocation location, params BondType[] arguments) + private MappedType Collection(string name, SourceLocation location, TypeMode mode, params BondType[] arguments) { - var types = arguments.Select(argument => MapType(argument, location)).ToArray(); + var types = arguments.Select(argument => MapType(argument, location, mode)).ToArray(); var clr = $"global::System.Collections.Generic.{name}<{string.Join(", ", types.Select(type => type.Name))}>"; var schema = $"global::System.Collections.Generic.{name}<{string.Join(", ", types.Select(type => type.SchemaType))}>"; return new(clr, schema, $"new {clr}()"); @@ -647,8 +622,8 @@ private static void ValidateTypeParameters(Declaration declaration) } } - private void ValidateTypeArguments( - Declaration declaration, BondType[] arguments, SourceLocation location, bool annotated = false) + private void ValidateTypeArguments(Declaration declaration, BondType[] arguments, SourceLocation location, + bool annotated = false, TypeMode mode = TypeMode.Clr) { if (declaration.TypeParameters.Length != arguments.Length) { @@ -667,7 +642,7 @@ private void ValidateTypeArguments( continue; } - var argument = MapType(arguments[index], location); + var argument = MapType(arguments[index], location, mode); // The consuming compiler resolves constraints on externally supplied CLR types. if (!annotated && argument.IsCustom) @@ -843,24 +818,6 @@ private string QualifiedName(Declaration declaration) => .Select(part => Identifier(part, declaration.Location)) .Append(Identifier(declaration.Name, declaration.Location, typeName: true))); - private string CompanionName(Declaration declaration, string suffix) - { - var name = declaration.Name + suffix; - var namespaceParts = CSharpNamespace(declaration); - while (_declarations.Values.Any(candidate => candidate is StructDeclaration or EnumDeclaration - && candidate.Name == name && CSharpNamespace(candidate).SequenceEqual(namespaceParts))) - { - name += "_"; - } - - return name; - } - - private string QualifiedCompanionName(Declaration declaration, string suffix) => - "global::" + string.Join(".", CSharpNamespace(declaration) - .Select(part => Identifier(part, declaration.Location)) - .Append(Identifier(CompanionName(declaration, suffix), declaration.Location, typeName: true))); - private void EmitAttributes(Syntax.Attribute[] attributes, int indent) { foreach (var attribute in attributes) diff --git a/Bond.Parser/CodeGeneration/CSharpMappings.cs b/Bond.Parser/CodeGeneration/CSharpMappings.cs index f037cce..eb264de 100644 --- a/Bond.Parser/CodeGeneration/CSharpMappings.cs +++ b/Bond.Parser/CodeGeneration/CSharpMappings.cs @@ -15,16 +15,22 @@ private sealed partial class Emitter private readonly List _usingNamespaces = []; private readonly Dictionary _namespaceMappings = new(StringComparer.Ordinal); private readonly Dictionary _typeMappings = new(StringComparer.Ordinal); - private int _suppressTypeMappings; - private bool _useBlobSchemaTags; - private void InitializeMappings() + /// How treats custom type mappings. + private enum TypeMode { - if ((options.ModelFeatures & ~CSharpModelFeatures.All) != 0) - { - Fail("Unknown C# model feature selection.", default); - } + /// Custom mappings apply. + Clr, + + /// The Bond type itself, which custom-mapped values are converted from. + Wire, + /// The wire type as a schema annotation, keeping blob tags nested in converted values. + Schema + } + + private void InitializeMappings() + { var usings = new HashSet(StringComparer.Ordinal); foreach (var name in options.UsingNamespaces) { @@ -121,62 +127,42 @@ private bool TryMapAlias(AliasDeclaration alias, BondType[] arguments, SourceLoc [NotNullWhen(true)] out MappedType? mapped) { mapped = null; - if (_suppressTypeMappings != 0 || !TryGetAliasTemplate(alias, out var template)) + if (!TryGetAliasTemplate(alias, out var template)) { return false; } - var identity = IdlFullName(alias); - var typeParameters = new HashSet(StringComparer.Ordinal); - var expanded = ExpandTemplate(template, index => + var name = ExpandTemplate(template, index => { if (index >= arguments.Length) { - throw new GenerationException($"Type mapping for '{identity}' references missing argument {{{index}}}.", location); + throw new GenerationException( + $"Type mapping for '{IdlFullName(alias)}' references missing argument {{{index}}}.", location); } - var argument = arguments[index]; - CollectParameters(argument, typeParameters); - return argument is BondType.IntTypeArg number + return arguments[index] is BondType.IntTypeArg number ? number.Value.ToString(CultureInfo.InvariantCulture) - : MapType(argument, location).Name; + : MapType(arguments[index], location).Name; }); - var name = new ClrTypeParser(expanded, typeParameters, location).Parse(); var substituted = Substitute(alias.AliasedType, alias, arguments); - var underlying = MapWireType(substituted, location); - if (name == underlying.Name) + var wire = MapType(substituted, location, TypeMode.Wire); + + // A mapping to the type's own CLR representation needs no converter. + if (name.Replace("global::", "", StringComparison.Ordinal) == wire.Name.Replace("global::", "", StringComparison.Ordinal)) { - mapped = underlying; + mapped = wire; return true; } - // Converters use the wire CLR type; annotations must preserve nested blob tags. - var schema = MapWireType(substituted, location, useBlobSchemaTags: true); - mapped = new MappedType(name, schema.SchemaType, IsScalar: underlying.IsScalar, - IsValueType: KnownValueTypes.Contains(name), IsSchemaValueType: schema.IsSchemaValueType, - IsCustom: true); + var schema = MapType(substituted, location, TypeMode.Schema); + mapped = new MappedType(name, schema.SchemaType, IsScalar: wire.IsScalar, + IsSchemaValueType: schema.IsSchemaValueType, IsCustom: true); return true; } - private MappedType MapWireType(BondType type, SourceLocation location, bool useBlobSchemaTags = false) - { - _suppressTypeMappings++; - var previousBlobSchemaTags = _useBlobSchemaTags; - _useBlobSchemaTags |= useBlobSchemaTags; - try - { - return MapType(type, location); - } - finally - { - _useBlobSchemaTags = previousBlobSchemaTags; - _suppressTypeMappings--; - } - } - private string CustomDefaultValue(Field field, MappedType mapped, StructDeclaration owner) { - var wireType = MapWireType(field.Type, field.Location); + var wireType = MapType(field.Type, field.Location, TypeMode.Wire); var value = WireDefaultValue(field, wireType) ?? $"default({wireType.Name})"; if (!HasRuntimeCompatibleMappedDefault(field)) { @@ -226,31 +212,30 @@ private bool HasRuntimeCompatibleMappedDefault(Field field) } } - private static void CollectParameters(BondType type, HashSet names) + // The C# compiler validates the type itself; this only rejects text that is not a type name. + private static void ValidateTypeTemplate(string template) { - if (type is BondType.TypeParameter parameter) + var depth = 0; + foreach (var character in ExpandTemplate(template, _ => "T")) { - names.Add(parameter.Param.Name); + depth += character switch + { + '<' or '[' => 1, + '>' or ']' => -1, + _ => 0 + }; + if (depth < 0 || !(IsIdentifierPart(character) || character is '.' or ':' or '<' or '>' or ',' or '[' or ']' or '?' or '@' or ' ')) + { + throw new GenerationException($"Invalid CLR type in type mapping '{template}'.", default); + } } - foreach (var child in Children(type)) + if (depth != 0) { - CollectParameters(child, names); + throw new GenerationException($"Unbalanced brackets in type mapping '{template}'.", default); } } - private static void ValidateTypeTemplate(string template) - { - var parameters = new HashSet(StringComparer.Ordinal); - var expanded = ExpandTemplate(template, index => - { - var name = "__BondTypeParameter" + index.ToString(CultureInfo.InvariantCulture); - parameters.Add(name); - return name; - }); - new ClrTypeParser(expanded, parameters, default).Parse(); - } - private static string ExpandTemplate(string template, Func argument) { var result = new StringBuilder(); @@ -280,165 +265,5 @@ private static string ExpandTemplate(string template, Func argument return result.ToString(); } - - private static readonly Dictionary PrimitiveNames = new(StringComparer.Ordinal) - { - ["System.Boolean"] = "bool", - ["System.Byte"] = "byte", - ["System.SByte"] = "sbyte", - ["System.Int16"] = "short", - ["System.UInt16"] = "ushort", - ["System.Int32"] = "int", - ["System.UInt32"] = "uint", - ["System.Int64"] = "long", - ["System.UInt64"] = "ulong", - ["System.Single"] = "float", - ["System.Double"] = "double", - ["System.Decimal"] = "decimal", - ["System.Char"] = "char", - ["System.String"] = "string", - ["System.Object"] = "object" - }; - - private static readonly HashSet KnownValueTypes = new(StringComparer.Ordinal) - { - "bool", "byte", "sbyte", "short", "ushort", "int", "uint", "long", "ulong", - "float", "double", "decimal", "char", "nint", "nuint", - "global::System.DateTime", "global::System.DateTimeOffset", "global::System.DateOnly", - "global::System.TimeOnly", "global::System.TimeSpan", "global::System.Guid", - "global::System.IntPtr", "global::System.UIntPtr", "global::System.Numerics.BigInteger" - }; - - private static bool IsPrimitiveName(string name) => - PrimitiveNames.Values.Contains(name) || name is "nint" or "nuint"; - - private sealed class ClrTypeParser(string text, HashSet parameters, SourceLocation location) - { - private int _position; - - public string Parse() - { - var result = ReadType(); - Space(); - if (_position != text.Length) - { - Invalid(); - } - - return result; - } - - private string ReadType() - { - Space(); - var isGlobal = text.AsSpan(_position).StartsWith("global::", StringComparison.Ordinal); - if (isGlobal) - { - _position += "global::".Length; - } - - var segments = new List { Segment() }; - while (Take('.')) - { - segments.Add(Segment()); - } - - var name = string.Join(".", segments); - if (isGlobal && segments.Count == 1 && IsPrimitiveName(name)) - { - Invalid(); - } - - if (PrimitiveNames.TryGetValue(name, out var primitive)) - { - name = primitive; - } - else if (segments.Count > 1 || isGlobal) - { - name = "global::" + name; - } - else if (!IsPrimitiveName(name) && !parameters.Contains(name.TrimStart('@')) - && !name.StartsWith("__BondTypeParameter", StringComparison.Ordinal)) - { - throw new GenerationException($"Type mapping '{text}' must use fully qualified CLR type names.", location); - } - - while (Take('[')) - { - if (!Take(']')) - { - throw new GenerationException("Only single-dimensional arrays are supported in type mappings.", location); - } - - name += "[]"; - } - - return name; - } - - private string Segment() - { - Space(); - var escaped = Take('@'); - var start = _position; - while (_position < text.Length && IsIdentifierPart(text[_position])) - { - _position++; - } - - if (_position == start) - { - Invalid(); - } - - var name = text[start.._position]; - var identifier = Identifier(name, location, typeName: true); - if (!escaped && IsPrimitiveName(name)) - { - identifier = name; - } - - if (Take('<')) - { - var arguments = new List { ReadType() }; - while (Take(',')) - { - arguments.Add(ReadType()); - } - - if (!Take('>')) - { - Invalid(); - } - - identifier += "<" + string.Join(", ", arguments) + ">"; - } - - return identifier; - } - - private bool Take(char character) - { - Space(); - if (_position >= text.Length || text[_position] != character) - { - return false; - } - - _position++; - return true; - } - - private void Space() - { - while (_position < text.Length && char.IsWhiteSpace(text[_position])) - { - _position++; - } - } - - private void Invalid() => - throw new GenerationException($"Invalid CLR type syntax in mapping '{text}'.", location); - } } } diff --git a/Bond.Parser/CodeGeneration/CSharpMetadataEmitter.cs b/Bond.Parser/CodeGeneration/CSharpMetadataEmitter.cs deleted file mode 100644 index 7e20f63..0000000 --- a/Bond.Parser/CodeGeneration/CSharpMetadataEmitter.cs +++ /dev/null @@ -1,449 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using Bond.Parser.Syntax; - -namespace Bond.Parser.CodeGeneration; - -public static partial class CSharpGenerator -{ - private sealed partial class Emitter - { - private const string MetadataNamespace = "global::BondTools.Models."; - - // Alias identity is file-local, even when qualified names and target types match. - private readonly Dictionary _metadataIndices = new(ReferenceEqualityComparer.Instance); - private readonly List _metadataDeclarations = []; - - private void EmitMetadata() - { - var roots = new List(); - foreach (var root in ast.Declarations) - { - if (root is not (StructDeclaration or EnumDeclaration or AliasDeclaration)) - { - continue; - } - - var declaration = Canonical(root); - if (!_metadataIndices.ContainsKey(declaration)) - { - roots.Add(declaration); - } - - RegisterMetadata(declaration); - } - - for (var index = 0; index < _metadataDeclarations.Count; index++) - { - switch (_metadataDeclarations[index]) - { - case StructDeclaration structure: - if (structure.BaseType != null) - { - RegisterMetadataType(structure.BaseType); - } - - foreach (var field in structure.Fields.OrderBy(field => field.Ordinal)) - { - RegisterMetadataType(field.Type); - } - - break; - case AliasDeclaration alias: - RegisterMetadataType(alias.AliasedType); - break; - } - } - - if (_metadataDeclarations.Count == 0) - { - return; - } - - var catalogName = MetadataCatalogName(); - Line(0, $"file static class {catalogName}"); - Line(0, "{"); - Line(1, $"private static readonly global::System.Lazy<{MetadataNamespace}SchemaDescriptor>[] Descriptors ="); - Line(2, $"new global::System.Lazy<{MetadataNamespace}SchemaDescriptor>[]"); - Line(2, "{"); - for (var index = 0; index < _metadataDeclarations.Count; index++) - { - Line(3, $"new global::System.Lazy<{MetadataNamespace}SchemaDescriptor>(Create{index.ToString(CultureInfo.InvariantCulture)}),"); - } - - Line(2, "};"); - Line(0); - Line(1, $"internal static {MetadataNamespace}SchemaDescriptor Get(int index) => Descriptors[index].Value;"); - Line(0); - foreach (var declaration in _metadataDeclarations) - { - EmitMetadataDefinition(declaration); - } - - Line(0, "}"); - Line(0); - - foreach (var declaration in roots.Where(declaration => declaration is StructDeclaration or EnumDeclaration)) - { - var name = Identifier(CompanionName(declaration, "Schema"), declaration.Location, typeName: true); - Line(0, $"namespace {string.Join(".", CSharpNamespace(declaration).Select(part => Identifier(part, declaration.Location)))}"); - Line(0, "{"); - Line(1, $"public static class {name}"); - Line(1, "{"); - Line(2, $"public static {MetadataNamespace}SchemaDescriptor Descriptor => global::{catalogName}.Get({MetadataIndex(declaration)});"); - Line(1, "}"); - Line(0, "}"); - Line(0); - } - } - - private void RegisterMetadata(Declaration declaration) - { - declaration = Canonical(declaration); - if (_metadataIndices.TryAdd(declaration, _metadataDeclarations.Count)) - { - _metadataDeclarations.Add(declaration); - } - } - - private void RegisterMetadataType(BondType type) - { - if (type is BondType.TypeReference reference) - { - RegisterMetadata(reference.Declaration); - } - - foreach (var child in Children(type)) - { - RegisterMetadataType(child); - } - } - - private string MetadataIndex(Declaration declaration) => - _metadataIndices[Canonical(declaration)].ToString(CultureInfo.InvariantCulture); - - private string MetadataCatalogName() - { - var reserved = new HashSet(StringComparer.Ordinal); - foreach (var schemaNamespace in ast.Namespaces) - { - foreach (var part in schemaNamespace.Name.Concat(MapNamespace(schemaNamespace.Name))) - { - reserved.Add(part); - } - } - - foreach (var declaration in _metadataDeclarations) - { - reserved.Add(declaration.Name); - if (declaration is not AliasDeclaration) - { - foreach (var part in CSharpNamespace(declaration)) - { - reserved.Add(part); - } - } - } - - var name = "__BondSchemaCatalog"; - while (reserved.Contains(name)) - { - name += "_"; - } - - return name; - } - - private void EmitMetadataDefinition(Declaration declaration) - { - Line(1, $"private static {MetadataNamespace}SchemaDescriptor Create{MetadataIndex(declaration)}() =>"); - Line(2, $"new {MetadataNamespace}SchemaDescriptor("); - Line(3, $"{Literal(declaration.Name)}, {Literal(string.Join(".", IdlNamespace(declaration)))},"); - - var arguments = declaration.TypeParameters.Select(parameter => (BondType)new BondType.TypeParameter(parameter)).ToArray(); - var clrName = MetadataClrName(new BondType.TypeReference(declaration, arguments), declaration.Location); - Line(3, $"clrName: {(clrName == null ? "null" : Literal(clrName))},"); - Line(3, $"kind: {MetadataNamespace}SchemaKind.{MetadataKind(declaration)},"); - - var typeParameters = declaration.TypeParameters.Select(parameter => - $"new {MetadataNamespace}TypeParameterDescriptor({Literal(parameter.Name)}, {MetadataNamespace}SchemaTypeConstraint.{parameter.Constraint})"); - Line(3, $"typeParameters: {MetadataArray("TypeParameterDescriptor", typeParameters)},"); - - switch (declaration) - { - case StructDeclaration structure: - EmitStructMetadata(structure, 3); - break; - case EnumDeclaration enumeration: - EmitEnumMetadata(enumeration, 3); - break; - case AliasDeclaration alias: - Line(3, $"aliasedType: {MetadataType(alias.AliasedType, alias)});"); - break; - case ForwardDeclaration: - Line(3, "isForward: true);"); - break; - } - - Line(0); - } - - private string? MetadataClrName(BondType type, SourceLocation location) - { - string? Container(string name, params BondType[] arguments) - { - var names = arguments.Select(argument => MetadataClrName(argument, location)).ToArray(); - if (names.Any(name => name == null)) - { - return null; - } - - return name + "<" + string.Join(", ", names) + ">"; - } - - string? Optional(BondType element) - { - var name = MetadataClrName(element, location); - if (name == null) - { - return null; - } - - var suffix = UnwrapAlias(element, location).IsScalar() ? "?" : ""; - return name + suffix; - } - - switch (type) - { - case BondType.IntTypeArg: - return null; - case BondType.TypeParameter parameter: - return Identifier(parameter.Param.Name, location, typeName: true); - case BondType.TypeReference reference: - { - var declaration = Canonical(reference.Declaration); - if (declaration is not AliasDeclaration alias) - { - return reference.TypeArguments.Length == 0 - ? QualifiedName(declaration) - : Container(QualifiedName(declaration), reference.TypeArguments); - } - - ValidateAlias(alias); - if (TryGetAliasTemplate(alias, out var template)) - { - return MetadataMappedAliasName(alias, reference.TypeArguments, template, location); - } - - return MetadataClrName(Substitute(alias.AliasedType, alias, reference.TypeArguments), location); - } - case BondType.List list: - return Container("global::System.Collections.Generic.LinkedList", list.ElementType); - case BondType.Vector vector: - return Container("global::System.Collections.Generic.List", vector.ElementType); - case BondType.Set set: - return Container("global::System.Collections.Generic.HashSet", set.KeyType); - case BondType.Map map: - return Container("global::System.Collections.Generic.Dictionary", map.KeyType, map.ValueType); - case BondType.Nullable nullable: - return Optional(nullable.ElementType); - case BondType.Maybe maybe: - return Optional(maybe.ElementType); - case BondType.Bonded bonded: - return Container("global::Bond.IBonded", bonded.StructType); - default: - return MapType(type, location).Name; - } - } - - private string? MetadataMappedAliasName(AliasDeclaration alias, BondType[] arguments, string template, - SourceLocation location) - { - // Metadata needs only a name: neither erased arguments nor the alias's wire representation need CLR types. - var parameters = new HashSet(StringComparer.Ordinal); - var representable = true; - var hasIntegerArgument = false; - var expanded = ExpandTemplate(template, index => - { - if (index >= arguments.Length) - { - throw new GenerationException($"Type mapping for '{IdlFullName(alias)}' references missing argument {{{index}}}.", location); - } - - var argument = arguments[index]; - CollectParameters(argument, parameters); - if (argument is BondType.IntTypeArg integer) - { - hasIntegerArgument = true; - return integer.Value.ToString(CultureInfo.InvariantCulture); - } - - var name = MetadataClrName(argument, location); - representable &= name != null; - return name ?? ""; - }); - - if (!representable) - { - return null; - } - - try - { - return new ClrTypeParser(expanded, parameters, location).Parse(); - } - catch (GenerationException) when (hasIntegerArgument) - { - return null; - } - } - - private void EmitStructMetadata(StructDeclaration structure, int indent) - { - Line(indent, $"attributes: {MetadataAttributes(structure.Attributes)},"); - if (structure.BaseType != null) - { - Line(indent, $"baseType: {MetadataType(structure.BaseType, structure)},"); - } - - if (structure.IsView) - { - Line(indent, "isView: true,"); - Line(indent, $"viewTarget: new string[] {{ {string.Join(", ", (structure.ViewTarget ?? []).Select(Literal))} }},"); - Line(indent, $"viewFields: new string[] {{ {string.Join(", ", structure.ViewFields.Select(Literal))} }},"); - } - - Line(indent, $"fields: new {MetadataNamespace}FieldDescriptor[]"); - Line(indent, "{"); - foreach (var field in structure.Fields.OrderBy(field => field.Ordinal)) - { - Line(indent + 1, $"new {MetadataNamespace}FieldDescriptor("); - Line(indent + 2, $"{field.Ordinal.ToString(CultureInfo.InvariantCulture)}, {Literal(field.Name)},"); - Line(indent + 2, $"{MetadataType(field.Type, structure)},"); - Line(indent + 2, $"{MetadataNamespace}SchemaFieldModifier.{field.Modifier}, {MetadataDefault(field.DefaultValue)},"); - Line(indent + 2, $"{MetadataAttributes(field.Attributes)}),"); - } - - Line(indent, "});"); - } - - private void EmitEnumMetadata(EnumDeclaration enumeration, int indent) - { - Line(indent, $"attributes: {MetadataAttributes(enumeration.Attributes)},"); - Line(indent, $"enumValues: new {MetadataNamespace}EnumValueDescriptor[]"); - Line(indent, "{"); - - long nextValue = 0; - foreach (var constant in enumeration.Constants) - { - var value = constant.Value is long declared ? unchecked((int)declared) : (int)nextValue; - var source = constant.Value is long literal ? MetadataInteger(literal) : "null"; - Line(indent + 1, $"new {MetadataNamespace}EnumValueDescriptor({Literal(constant.Name)}, {value.ToString(CultureInfo.InvariantCulture)}, {source}),"); - nextValue = (long)value + 1; - } - - Line(indent, "});"); - } - - private string MetadataType(BondType type, Declaration owner) - { - string Unary(string kind, BondType element) => - $"new {MetadataNamespace}UnarySchemaType({MetadataNamespace}SchemaTypeKind.{kind}, {MetadataType(element, owner)})"; - - switch (type) - { - case BondType.List list: - return Unary("List", list.ElementType); - case BondType.Vector vector: - return Unary("Vector", vector.ElementType); - case BondType.Set set: - return Unary("Set", set.KeyType); - case BondType.Nullable nullable: - return Unary("Nullable", nullable.ElementType); - case BondType.Maybe maybe: - return Unary("Maybe", maybe.ElementType); - case BondType.Bonded bonded: - return Unary("Bonded", bonded.StructType); - case BondType.Map map: - return $"new {MetadataNamespace}MapSchemaType({MetadataType(map.KeyType, owner)}, {MetadataType(map.ValueType, owner)})"; - case BondType.IntTypeArg integer: - return $"new {MetadataNamespace}IntegerArgumentSchemaType({MetadataInteger(integer.Value)})"; - case BondType.TypeParameter parameter: - { - var position = Array.FindIndex(owner.TypeParameters, candidate => candidate == parameter.Param); - if (position < 0) - { - throw new GenerationException($"Schema type parameter '{parameter.Param.Name}' is not declared by '{owner.Name}'.", owner.Location); - } - - return $"new {MetadataNamespace}TypeParameterSchemaType({position.ToString(CultureInfo.InvariantCulture)}, {Literal(parameter.Param.Name)})"; - } - case BondType.TypeReference reference: - { - var declaration = Canonical(reference.Declaration); - return $"new {MetadataNamespace}NamedSchemaType({MetadataNamespace}SchemaTypeKind.{MetadataKind(declaration)}, " + - $"{Literal(declaration.Name)}, {Literal(string.Join(".", IdlNamespace(declaration)))}, " + - $"static () => Get({MetadataIndex(declaration)}), " + - $"{MetadataArray("SchemaType", reference.TypeArguments.Select(argument => MetadataType(argument, owner)))})"; - } - default: - { - var kind = type switch - { - BondType.Int8 => "Int8", - BondType.Int16 => "Int16", - BondType.Int32 => "Int32", - BondType.Int64 => "Int64", - BondType.UInt8 => "UInt8", - BondType.UInt16 => "UInt16", - BondType.UInt32 => "UInt32", - BondType.UInt64 => "UInt64", - BondType.Float => "Float", - BondType.Double => "Double", - BondType.Bool => "Bool", - BondType.String => "String", - BondType.WString => "WString", - BondType.Blob => "Blob", - BondType.MetaName => "MetaName", - BondType.MetaFullName => "MetaFullName", - _ => throw new GenerationException($"Type '{type}' has no resolved schema metadata.", owner.Location) - }; - return $"new {MetadataNamespace}PrimitiveSchemaType({MetadataNamespace}SchemaTypeKind.{kind})"; - } - } - } - - private static string MetadataKind(Declaration declaration) => declaration switch - { - StructDeclaration or ForwardDeclaration => "Struct", - EnumDeclaration => "Enum", - AliasDeclaration => "Alias", - _ => throw new GenerationException($"Declaration '{declaration.Name}' is not a model schema.", declaration.Location) - }; - - private static string MetadataArray(string elementType, IEnumerable items) => - $"new {MetadataNamespace}{elementType}[] {{ {string.Join(", ", items)} }}"; - - private static string MetadataAttributes(Syntax.Attribute[] attributes) => - MetadataArray("SchemaAttribute", attributes.Select(attribute => - $"new {MetadataNamespace}SchemaAttribute({Literal(string.Join(".", attribute.QualifiedName))}, {Literal(attribute.Value)})")); - - private static string MetadataInteger(long value) => value.ToString(CultureInfo.InvariantCulture) + "L"; - - private static string MetadataDefault(Default? value) => value switch - { - null => "null", - Default.Bool boolean => $"new {MetadataNamespace}SchemaDefault.Boolean({(boolean.Value ? "true" : "false")})", - Default.Integer integer => $"new {MetadataNamespace}SchemaDefault.Integer(global::System.Numerics.BigInteger.Parse(" + - $"{Literal(integer.Value.ToString(CultureInfo.InvariantCulture))}, global::System.Globalization.CultureInfo.InvariantCulture))", - Default.Float number => $"new {MetadataNamespace}SchemaDefault.FloatingPoint(global::System.BitConverter.Int64BitsToDouble(" + - $"{MetadataInteger(BitConverter.DoubleToInt64Bits(number.Value))}))", - Default.String text => $"new {MetadataNamespace}SchemaDefault.String({Literal(text.Value)})", - Default.Enum member => $"new {MetadataNamespace}SchemaDefault.Enum({Literal(member.Identifier)})", - Default.Nothing => $"{MetadataNamespace}SchemaDefault.Nothing.Instance", - _ => throw new GenerationException("Unsupported schema default.", SourceLocation.Unknown) - }; - } -} diff --git a/Bond.Parser/CodeGeneration/CSharpModelEmitter.cs b/Bond.Parser/CodeGeneration/CSharpModelEmitter.cs new file mode 100644 index 0000000..046488b --- /dev/null +++ b/Bond.Parser/CodeGeneration/CSharpModelEmitter.cs @@ -0,0 +1,439 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Bond.Parser.Syntax; + +namespace Bond.Parser.CodeGeneration; + +public static partial class CSharpGenerator +{ + private sealed partial class Emitter + { + private const string Enumerable = "global::System.Linq.Enumerable."; + private const string StructuralComparer = "global::System.Collections.StructuralComparisons.StructuralEqualityComparer"; + private const string InvariantCulture = "global::System.Globalization.CultureInfo.InvariantCulture"; + + private int _modelVariables; + + /// A field of a struct or of one of its bases, typed with the struct's base type arguments. + private sealed record ModelField(string Label, string Name, BondType Type, SourceLocation Location, string? Owner) + { + // A field hidden by a derived field with the same name is accessed through its declaring type. + public string On(string instance) => Owner == null ? $"{instance}.{Name}" : $"(({Owner}){instance}).{Name}"; + } + + private void EmitModelMembers(StructDeclaration structure, string self) + { + var fields = ModelFields(structure); + var reserved = ModelMemberNames().ToHashSet(); + var names = fields.Select(field => (field.Name, field.Location)) + .Append((structure.Name, structure.Location)) + .Concat(structure.TypeParameters.Select(parameter => (parameter.Name, structure.Location))); + foreach (var (name, location) in names) + { + if (reserved.Contains(name)) + { + Fail($"'{name}' conflicts with the generated {name}() method.", location); + } + } + + if (options.GenerateCloning) + { + EmitClone(self, fields, isDerived: structure.BaseType != null); + } + + if (options.GenerateEquality) + { + EmitEquals(self, fields); + EmitGetHashCode(fields); + } + + if (options.GenerateToString) + { + EmitToString(structure.Name, fields); + } + } + + private IEnumerable ModelMemberNames() + { + if (options.GenerateCloning) + { + yield return "Clone"; + } + + if (options.GenerateEquality) + { + yield return "Equals"; + yield return "GetHashCode"; + } + + if (options.GenerateToString) + { + yield return "ToString"; + } + } + + // Fields of the struct and all of its bases, base first. Operations never depend on members generated for bases. + private List ModelFields(StructDeclaration structure) + { + var chain = new List<(StructDeclaration Declaration, BondType.TypeReference Reference)>(); + BondType? current = new BondType.TypeReference(structure, + structure.TypeParameters.Select(parameter => (BondType)new BondType.TypeParameter(parameter)).ToArray()); + while (current != null) + { + var reference = (BondType.TypeReference)UnwrapAlias(current, structure.Location); + if (Canonical(reference.Declaration) is not StructDeclaration declaration) + { + throw new GenerationException( + $"Cannot generate model members for '{structure.Name}' because base '{reference.Declaration.QualifiedName}' " + + "has no definition. Include its .bond file or disable model features.", structure.Location); + } + + chain.Add((declaration, reference)); + current = declaration.BaseType == null ? null : Substitute(declaration.BaseType, declaration, reference.TypeArguments); + } + + var duplicates = chain.SelectMany(link => link.Declaration.Fields) + .GroupBy(field => field.Name) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToHashSet(); + var derivedNames = new HashSet(); + var fields = new List(); + foreach (var (declaration, reference) in chain) + { + var owner = MapType(reference, structure.Location).Name; + fields.InsertRange(0, declaration.Fields.OrderBy(field => field.Ordinal).Select(field => new ModelField( + duplicates.Contains(field.Name) ? declaration.Name + "." + field.Name : field.Name, + Identifier(field.Name, field.Location), + Substitute(field.Type, declaration, reference.TypeArguments), + field.Location, + derivedNames.Contains(field.Name) ? owner : null))); + derivedNames.UnionWith(declaration.Fields.Select(field => field.Name)); + } + + return fields; + } + + private void EmitClone(string self, IReadOnlyList fields, bool isDerived) + { + _modelVariables = 0; + var copies = fields + .Select(field => (Field: field, Value: CloneValue(field.Type, field.On("this"), field.Location))) + .Where(copy => copy.Value != null) + .ToList(); + var hiding = isDerived ? "new " : ""; + + // Each struct re-implements ICloneable, so cloning through a base reference still copies derived fields. + Line(0); + Line(2, $"public {hiding}{self} Clone() => ({self})((global::System.ICloneable)this).Clone();"); + Line(0); + if (copies.Count == 0) + { + Line(2, "object global::System.ICloneable.Clone() => MemberwiseClone();"); + return; + } + + Line(2, "object global::System.ICloneable.Clone()"); + Line(2, "{"); + Line(3, $"{self} clone = ({self})MemberwiseClone();"); + foreach (var (field, value) in copies) + { + Line(3, $"{field.On("clone")} = {value};"); + } + + Line(3, "return clone;"); + Line(2, "}"); + } + + private void EmitEquals(string self, IReadOnlyList fields) + { + _modelVariables = 0; + Line(0); + Line(2, "public override bool Equals(object obj)"); + Line(2, "{"); + Line(3, "if (object.ReferenceEquals(this, obj))"); + Line(3, "{"); + Line(4, "return true;"); + Line(3, "}"); + Line(0); + Line(3, "if (obj == null || ((object)this).GetType() != obj.GetType())"); + Line(3, "{"); + Line(4, "return false;"); + Line(3, "}"); + Line(0); + if (fields.Count == 0) + { + Line(3, "return true;"); + } + else + { + Line(3, $"{self} other = ({self})obj;"); + for (var i = 0; i < fields.Count; i++) + { + var field = fields[i]; + var comparison = EqualsValue(field.Type, field.On("this"), field.On("other"), field.Location); + Line(i == 0 ? 3 : 4, (i == 0 ? "return " : "&& ") + comparison + (i == fields.Count - 1 ? ";" : "")); + } + } + + Line(2, "}"); + } + + private void EmitGetHashCode(IReadOnlyList fields) + { + _modelVariables = 0; + var hashes = fields + .Select(field => HashValue(field.Type, field.On("this"), field.Location)) + .OfType() + .ToList(); + + Line(0); + Line(2, "public override int GetHashCode()"); + Line(2, "{"); + Line(3, "unchecked"); + Line(3, "{"); + Line(4, "int hash = 17;"); + foreach (var value in hashes) + { + Line(4, $"hash = hash * 31 + {value};"); + } + + Line(4, "return hash;"); + Line(3, "}"); + Line(2, "}"); + } + + private void EmitToString(string name, IReadOnlyList fields) + { + _modelVariables = 0; + Line(0); + Line(2, "public override string ToString()"); + Line(2, "{"); + if (fields.Count == 0) + { + Line(3, $"return {Literal(name + " { }")};"); + } + else + { + Line(3, $"return {Literal(name + " { ")}"); + for (var i = 0; i < fields.Count; i++) + { + var field = fields[i]; + var label = (i == 0 ? "" : ", ") + field.Label + " = "; + Line(4, $"+ {Literal(label)} + {FormatValue(field.Type, field.On("this"), field.Location)}"); + } + + Line(4, "+ \" }\";"); + } + + Line(2, "}"); + } + + // Returns the type that determines how a value is copied, compared, hashed and formatted: aliases, nullable and + // nothing wrappers removed. Null means a value whose type is unknown to the generator (custom-mapped CLR types + // and type parameters): it is cloned through ICloneable and compared structurally. + private BondType? ModelType(BondType type, SourceLocation location) + { + if (MapType(type, location).IsCustom) + { + return null; + } + + while (true) + { + switch (UnwrapAlias(type, location)) + { + case BondType.Nullable nullable: + type = nullable.ElementType; + break; + case BondType.Maybe maybe: + type = maybe.ElementType; + break; + case BondType.TypeParameter: + return null; + case var unwrapped: + return unwrapped; + } + } + } + + // Null means the value is immutable or copied by MemberwiseClone. Bonded payloads are shared. + private string? CloneValue(BondType type, string value, SourceLocation location) + { + var name = MapType(type, location).Name; + switch (ModelType(type, location)) + { + // ICloneable dispatches on the runtime type, so derived structs are copied completely. + case null or BondType.TypeReference { Declaration: StructDeclaration or ForwardDeclaration }: + { + var cloneable = Fresh("cloneable"); + return $"(object){value} is global::System.ICloneable {cloneable} ? ({name}){cloneable}.Clone() : {value}"; + } + case BondType.Vector vector: + { + var item = Fresh("item"); + var copy = CloneValue(vector.ElementType, item, location); + return copy == null + ? $"{value} == null ? null : new {name}({value})" + : $"{value}?.ConvertAll({item} => {copy})"; + } + case BondType.List list: + return $"{value} == null ? null : new {name}({CloneItems(value, list.ElementType, location)})"; + case BondType.Set set: + return $"{value} == null ? null : new {name}({CloneItems(value, set.KeyType, location)}, {value}.Comparer)"; + case BondType.Map map: + { + var entry = Fresh("entry"); + var key = CloneValue(map.KeyType, $"{entry}.Key", location); + var item = CloneValue(map.ValueType, $"{entry}.Value", location); + return key == null && item == null + ? $"{value} == null ? null : new {name}({value}, {value}.Comparer)" + : $"{value} == null ? null : {Enumerable}ToDictionary({value}, {entry} => {key ?? entry + ".Key"}, " + + $"{entry} => {item ?? entry + ".Value"}, {value}.Comparer)"; + } + case BondType.Blob: + return $"{value}.Array == null ? {value} : new global::System.ArraySegment({Enumerable}ToArray({value}))"; + default: + return null; + } + } + + private string CloneItems(string value, BondType element, SourceLocation location) + { + var item = Fresh("item"); + var copy = CloneValue(element, item, location); + return copy == null ? value : $"{Enumerable}Select({value}, {item} => {copy})"; + } + + private string EqualsValue(BondType type, string left, string right, SourceLocation location) + { + switch (ModelType(type, location)) + { + case null: + return $"{StructuralComparer}.Equals({left}, {right})"; + case BondType.Vector vector: + return SequenceEquals(left, right, vector.ElementType, location); + case BondType.List list: + return SequenceEquals(left, right, list.ElementType, location); + case BondType.Set: + return $"({left} == null ? {right} == null : {right} != null && {left}.SetEquals({right}))"; + case BondType.Map map: + { + var entry = Fresh("entry"); + var item = Fresh("item"); + var equal = EqualsValue(map.ValueType, $"{entry}.Value", item, location); + return $"({left} == null ? {right} == null : {right} != null && {left}.Count == {right}.Count && " + + $"{Enumerable}All({left}, {entry} => {right}.TryGetValue({entry}.Key, out {MapType(map.ValueType, location).Name} {item}) && {equal}))"; + } + case BondType.Blob: + return $"({left}.Array == null ? {right}.Array == null : " + + $"{right}.Array != null && {Enumerable}SequenceEqual({left}, {right}))"; + case BondType.Bonded: + return $"{StructuralComparer}.Equals({left} == null ? null : (object){left}.Deserialize(), " + + $"{right} == null ? null : (object){right}.Deserialize())"; + default: + return $"global::System.Collections.Generic.EqualityComparer<{MapType(type, location).Name}>.Default.Equals({left}, {right})"; + } + } + + private string SequenceEquals(string left, string right, BondType element, SourceLocation location) + { + string items; + if (ModelType(element, location) is not (null or BondType.Vector or BondType.List or BondType.Set + or BondType.Map or BondType.Blob or BondType.Bonded)) + { + // EqualityComparer.Default matches the generated equality for values and generated structs. + items = $"{Enumerable}SequenceEqual({left}, {right})"; + } + else + { + var x = Fresh("left"); + var y = Fresh("right"); + var equal = Fresh("equal"); + items = $"{left}.Count == {right}.Count && {Enumerable}All({Enumerable}Zip({left}, {right}, " + + $"({x}, {y}) => {EqualsValue(element, x, y, location)}), {equal} => {equal})"; + } + + return $"({left} == null ? {right} == null : {right} != null && {items})"; + } + + // Null means the value does not contribute to the hash: bonded payloads are compared, but never deserialized to hash. + private string? HashValue(BondType type, string value, SourceLocation location) + { + switch (ModelType(type, location)) + { + case null: + return $"{StructuralComparer}.GetHashCode({value})"; + case BondType.Vector vector: + return HashItems(value, vector.ElementType, ordered: true, location); + case BondType.List list: + return HashItems(value, list.ElementType, ordered: true, location); + case BondType.Set set: + return HashItems(value, set.KeyType, ordered: false, location); + case BondType.Map map: + { + var hash = Fresh("hash"); + var entry = Fresh("entry"); + var key = HashValue(map.KeyType, $"{entry}.Key", location) ?? "0"; + var item = HashValue(map.ValueType, $"{entry}.Value", location) ?? "0"; + return $"({value} == null ? 0 : {Enumerable}Aggregate({value}, 0, " + + $"({hash}, {entry}) => {hash} + ({key}) * 31 + {item}))"; + } + case BondType.Blob: + return $"{value}.Count"; + case BondType.Bonded: + return null; + default: + return $"global::System.Collections.Generic.EqualityComparer<{MapType(type, location).Name}>.Default.GetHashCode({value})"; + } + } + + private string HashItems(string value, BondType element, bool ordered, SourceLocation location) + { + var hash = Fresh("hash"); + var item = Fresh("item"); + var itemHash = HashValue(element, item, location) ?? "0"; + var step = ordered ? $"{hash} * 31 + {itemHash}" : $"{hash} + {itemHash}"; + return $"({value} == null ? 0 : {Enumerable}Aggregate({value}, {(ordered ? "17" : "0")}, ({hash}, {item}) => {step}))"; + } + + private string FormatValue(BondType type, string value, SourceLocation location) + { + switch (ModelType(type, location)) + { + case BondType.String or BondType.WString or BondType.MetaName or BondType.MetaFullName: + return $"({value} == null ? \"null\" : \"\\\"\" + {value} + \"\\\"\")"; + case BondType.Vector vector: + return FormatItems(value, vector.ElementType, location); + case BondType.List list: + return FormatItems(value, list.ElementType, location); + case BondType.Set set: + return FormatItems(value, set.KeyType, location); + case BondType.Map map: + { + var entry = Fresh("entry"); + var key = FormatValue(map.KeyType, $"{entry}.Key", location); + var item = FormatValue(map.ValueType, $"{entry}.Value", location); + return $"({value} == null ? \"null\" : \"{{\" + string.Join(\", \", " + + $"{Enumerable}Select({value}, {entry} => {key} + \" = \" + {item})) + \"}}\")"; + } + case BondType.Blob: + return $"(\"blob[\" + {value}.Count + \"]\")"; + case BondType.Bonded: + return "\"bonded\""; + default: + return $"global::System.Convert.ToString((object){value} ?? \"null\", {InvariantCulture})"; + } + } + + private string FormatItems(string value, BondType element, SourceLocation location) + { + var item = Fresh("item"); + return $"({value} == null ? \"null\" : \"[\" + string.Join(\", \", " + + $"{Enumerable}Select({value}, {item} => {FormatValue(element, item, location)})) + \"]\")"; + } + + private string Fresh(string name) => name + (_modelVariables++).ToString(CultureInfo.InvariantCulture); + } +} diff --git a/Bond.Parser/CodeGeneration/CSharpModelOperationsEmitter.cs b/Bond.Parser/CodeGeneration/CSharpModelOperationsEmitter.cs deleted file mode 100644 index 493084c..0000000 --- a/Bond.Parser/CodeGeneration/CSharpModelOperationsEmitter.cs +++ /dev/null @@ -1,509 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using Bond.Parser.Syntax; - -namespace Bond.Parser.CodeGeneration; - -public static partial class CSharpGenerator -{ - private sealed partial class Emitter - { - private const string ModelSupport = "global::BondTools.Models."; - private const CSharpModelFeatures Features = CSharpModelFeatures.Descriptors - | CSharpModelFeatures.Cloning - | CSharpModelFeatures.Equality - | CSharpModelFeatures.Debugger; - - private string ModelSelf(StructDeclaration structure) => - QualifiedName(structure) + TypeArguments(structure.TypeParameters.Select(parameter => Identifier(parameter.Name, structure.Location, typeName: true))); - - private string ModelInterfaces(StructDeclaration structure) - { - var interfaces = new List(); - if ((options.ModelFeatures & Features) == Features) - { - interfaces.Add(ModelSupport + "IGeneratedModel"); - } - else - { - if (options.GenerateDescriptors) - { - interfaces.Add(ModelSupport + "IGeneratedSchemaProvider"); - } - - if (options.GenerateCloning) - { - interfaces.Add(ModelSupport + "IGeneratedCloneable"); - } - - if (options.GenerateEquality) - { - interfaces.Add(ModelSupport + "IGeneratedEquatable"); - } - - if (options.GenerateDebuggerSupport) - { - interfaces.Add(ModelSupport + "IGeneratedDebugView"); - } - } - - if (options.GenerateToString) - { - interfaces.Add(ModelSupport + "IGeneratedSummary"); - } - - if (options.GenerateCloning) - { - interfaces.Add("global::System.ICloneable"); - } - - if (options.GenerateEquality && CanEmitDefaultEquality(structure)) - { - interfaces.Add($"global::System.IEquatable<{ModelSelf(structure)}>"); - } - - return string.Join(", ", interfaces); - } - - private void EmitModelAttributes(StructDeclaration structure) - { - Line(1, $"[global::System.Diagnostics.DebuggerDisplay({Literal(structure.Name)})]"); - Line(1, $"[global::System.Diagnostics.DebuggerTypeProxy(typeof({ModelSupport}GeneratedModelDebugView))]"); - } - - private void EmitModelMembers(StructDeclaration structure) - { - var self = ModelSelf(structure); - var operations = QualifiedCompanionName(structure, "Operations") + - TypeArguments(structure.TypeParameters.Select(parameter => - Identifier(parameter.Name, structure.Location, typeName: true))); - var fields = options.GenerateCloning || options.GenerateEquality || options.GenerateDebuggerSupport - ? OperationFields(structure).ToArray() : []; - - Line(0); - if (options.GenerateDescriptors) - { - Line(2, $"{ModelSupport}SchemaDescriptor {ModelSupport}IGeneratedSchemaProvider.Descriptor => " + - $"{QualifiedCompanionName(structure, "Schema")}.Descriptor;"); - } - - if (options.GenerateCloning) - { - EmitCloneMembers(structure, self, operations, fields); - } - - if (options.GenerateEquality) - { - EmitEqualityMembers(structure, self, operations, fields); - EmitHashCodeMember(structure, self, operations, fields); - } - - if (options.GenerateDebuggerSupport) - { - EmitDebugMembers(fields); - } - - if (options.GenerateCloning && CanEmitConvenience(structure, "Clone")) - { - var hiding = BaseStructures(structure).Any(parent => CanEmitConvenience(parent, "Clone")) ? "new " : ""; - Line(2, $"public {hiding}{self} Clone() => {ModelSupport}ModelOperations.Clone(this);"); - } - - if (options.GenerateEquality && CanEmitDefaultEquality(structure)) - { - var other = ModelLocalName(structure, "Other"); - Line(2, $"public bool Equals({self} {other}) => {ModelSupport}ModelOperations.ValueEquals(this, {other});"); - Line(2, $"public override bool Equals(object {other}) => {ModelSupport}ModelOperations.ValueEquals(this, {other});"); - Line(2, $"public override int GetHashCode() => {ModelSupport}ModelOperations.ValueHashCode(this);"); - } - - if (options.GenerateToString) - { - EmitSummaryMembers(structure); - } - } - - private void EmitCloneMembers(StructDeclaration structure, string self, string operations, OperationField[] fields) - { - var context = ModelLocalName(structure, "Context"); - var clone = ModelLocalName(structure, "Clone"); - var existing = ModelLocalName(structure, "Existing"); - - Line(2, $"object global::System.ICloneable.Clone() => {ModelSupport}ModelOperations.Clone(this);"); - Line(0); - Line(2, $"{ModelSupport}IGeneratedCloneable {ModelSupport}IGeneratedCloneable.Clone({ModelSupport}CloneContext {context})"); - Line(2, "{"); - Line(3, $"if ({context}.TryGetClone<{self}>(this, out {self} {existing}))"); - Line(3, "{"); - Line(4, $"return {existing};"); - Line(3, "}"); - Line(0); - EmitRuntimeTypeGuard(self); - - Line(3, $"{self} {clone} = ({self}){ModelSupport}ModelOperations.ShallowClone(this);"); - Line(3, $"{context}.Register(this, {clone});"); - if (fields.Length != 0) - { - Line(0); - } - - for (var i = 0; i < fields.Length; i++) - { - var field = fields[i]; - var name = Identifier(field.Field.Name, field.Field.Location); - Line(3, $"(({field.Owner}){clone}).{name} = {operations}.Field{i}.Clone(" + - $"(({field.Owner})this).{name}, {context});"); - } - - Line(0); - Line(3, $"return {clone};"); - Line(2, "}"); - Line(0); - } - - private void EmitEqualityMembers(StructDeclaration structure, string self, string operations, OperationField[] fields) - { - var context = ModelLocalName(structure, "Context"); - var other = ModelLocalName(structure, "Other"); - var right = ModelLocalName(structure, "Right"); - - if (CanEmitDefaultEquality(structure)) - { - Line(2, $"bool global::System.IEquatable<{self}>.Equals({self} {other}) => " + - $"{ModelSupport}ModelOperations.ValueEquals(this, {other});"); - Line(0); - } - - Line(2, $"bool {ModelSupport}IGeneratedEquatable.ValueEquals({ModelSupport}IGeneratedEquatable {other}, " + - $"{ModelSupport}EqualityContext {context})"); - Line(2, "{"); - EmitRuntimeTypeGuard(self); - Line(3, $"if ({other} is not {self} {right} || ((object)this).GetType() != ((object){right}).GetType())"); - Line(3, "{"); - Line(4, "return false;"); - Line(3, "}"); - Line(0); - - for (var i = 0; i < fields.Length; i++) - { - var field = fields[i]; - var name = Identifier(field.Field.Name, field.Field.Location); - Line(3, $"if (!{operations}.Field{i}.Equals((({field.Owner})this).{name}, " + - $"(({field.Owner}){right}).{name}, {context}))"); - Line(3, "{"); - Line(4, "return false;"); - Line(3, "}"); - Line(0); - } - - Line(3, "return true;"); - Line(2, "}"); - Line(0); - } - - private void EmitHashCodeMember(StructDeclaration structure, string self, string operations, OperationField[] fields) - { - var context = ModelLocalName(structure, "Context"); - var hash = ModelLocalName(structure, "Hash"); - var child = ModelLocalName(structure, "Child"); - - Line(2, $"int {ModelSupport}IGeneratedEquatable.ValueHashCode({ModelSupport}HashContext {context})"); - Line(2, "{"); - EmitRuntimeTypeGuard(self); - Line(3, $"if ({context}.RemainingDepth == 0)"); - Line(3, "{"); - Line(4, "return 0;"); - Line(3, "}"); - Line(0); - - Line(3, $"{ModelSupport}HashContext {child} = {context}.Descend();"); - Line(3, $"int {hash} = 17;"); - if (fields.Length != 0) - { - Line(0); - } - - for (var i = 0; i < fields.Length; i++) - { - var field = fields[i]; - Line(3, $"{hash} = {ModelSupport}HashContext.Combine({hash}, {operations}.Field{i}.GetHashCode(" + - $"(({field.Owner})this).{Identifier(field.Field.Name, field.Field.Location)}, {child}));"); - } - - Line(0); - Line(3, $"return {hash};"); - Line(2, "}"); - Line(0); - } - - private void EmitRuntimeTypeGuard(string self) - { - Line(3, $"if (((object)this).GetType() != typeof({self}))"); - Line(3, "{"); - Line(4, $"throw {ModelSupport}ModelOperations.MissingAdapter(((object)this).GetType());"); - Line(3, "}"); - Line(0); - } - - private void EmitDebugMembers(OperationField[] fields) - { - Line(2, $"{ModelSupport}ModelDebugField[] {ModelSupport}IGeneratedDebugView.GetDebugFields()"); - Line(2, "{"); - Line(3, $"return new {ModelSupport}ModelDebugField[]"); - Line(3, "{"); - foreach (var field in fields) - { - Line(4, $"new {ModelSupport}ModelDebugField({Literal(field.DeclaringName)}, {Literal(field.Field.Name)}, " + - $"{field.Field.Ordinal.ToString(CultureInfo.InvariantCulture)}, " + - $"(({field.Owner})this).{Identifier(field.Field.Name, field.Field.Location)}),"); - } - - Line(3, "};"); - Line(2, "}"); - Line(0); - } - - private static string ModelLocalName(StructDeclaration structure, string name) - { - var candidate = "__bond" + name; - while (structure.TypeParameters.Any(parameter => parameter.Name == candidate)) - { - candidate += "_"; - } - - return candidate; - } - - private bool CanEmitConvenience(StructDeclaration structure, string name) => - structure.Name != name && - !structure.TypeParameters.Any(parameter => parameter.Name == name) && - !structure.Fields.Any(field => field.Name == name) && - !BaseStructures(structure).Any(parent => parent.Fields.Any(field => field.Name == name)); - - private bool CanEmitDefaultEquality(StructDeclaration structure) => - CanEmitConvenience(structure, "Equals") && CanEmitConvenience(structure, "GetHashCode"); - - private void EmitModelCompanions(StructDeclaration structure) - { - var arguments = structure.TypeParameters.Select((parameter, index) => - (BondType)new BondType.TypeParameter(parameter with - { - Name = "__T" + index - })).ToArray(); - var parameters = TypeArguments(arguments.Select(argument => MapType(argument, structure.Location).Name)); - var self = QualifiedName(structure) + parameters; - var fields = OperationFields(structure, arguments).ToArray(); - - Line(0, $"namespace {string.Join(".", CSharpNamespace(structure).Select(part => Identifier(part, structure.Location)))}"); - Line(0, "{"); - Line(1, $"public static class {Identifier(CompanionName(structure, "Operations"), structure.Location, typeName: true)}{parameters}"); - for (var i = 0; i < structure.TypeParameters.Length; i++) - { - if (structure.TypeParameters[i].Constraint == TypeConstraint.Value) - { - Line(2, $"where __T{i} : struct"); - } - } - - Line(1, "{"); - if (options.GenerateCloning) - { - Line(2, $"public static {self} Clone({self} value) => {ModelSupport}ModelOperations.Clone(value);"); - } - - if (options.GenerateEquality) - { - Line(2, $"public static bool Equals({self} left, {self} right) => {ModelSupport}ModelOperations.ValueEquals(left, right);"); - Line(2, $"public static int GetHashCode({self} value) => {ModelSupport}ModelOperations.ValueHashCode(value);"); - } - - if (options.GenerateToString) - { - EmitSummaryCompanionMembers(structure, self); - } - - var requiresMaterializedWrapper = false; - if (options.GenerateCloning || options.GenerateEquality) - { - for (var i = 0; i < fields.Length; i++) - { - var field = fields[i]; - var mapped = MapType(field.Type, field.Field.Location); - var adapter = ModelAdapterExpression(field.Type, field.Field.Location); - Line(2, $"internal static readonly {ModelSupport}IModelAdapter<{mapped.Name}> Field{i} = " + - $"{adapter.Code};"); - requiresMaterializedWrapper |= adapter.RequiresMaterializedWrapper; - } - } - - if (requiresMaterializedWrapper) - { - Line(0); - EmitMaterializedBondedWrapper(); - } - - Line(1, "}"); - Line(0, "}"); - Line(0); - } - - private void EmitMaterializedBondedWrapper() - { - Line(2, "[global::System.Diagnostics.DebuggerDisplay(\"bonded (materialized)\")]"); - Line(2, $"private sealed class __MaterializedBonded<__Payload> : global::Bond.IBonded<__Payload>, {ModelSupport}IMaterializedModelValue<__Payload>"); - Line(2, "{"); - Line(3, "[global::System.Diagnostics.DebuggerBrowsable(global::System.Diagnostics.DebuggerBrowsableState.Never)]"); - Line(3, $"private readonly {ModelSupport}IModelAdapter<__Payload> _adapter;"); - Line(0); - Line(3, "[global::System.Diagnostics.DebuggerBrowsable(global::System.Diagnostics.DebuggerBrowsableState.Never)]"); - Line(3, "public __Payload Value { get; }"); - Line(0); - Line(3, $"public __MaterializedBonded(__Payload value, {ModelSupport}IModelAdapter<__Payload> adapter)"); - Line(3, "{"); - Line(4, "Value = value;"); - Line(4, "_adapter = adapter;"); - Line(3, "}"); - Line(0); - Line(3, $"public __Payload Deserialize() => _adapter.Clone(Value, new {ModelSupport}CloneContext());"); - Line(0); - Line(3, "public __U Deserialize<__U>()"); - Line(3, "{"); - Line(4, "if (typeof(__U) == typeof(__Payload))"); - Line(4, "{"); - Line(5, "return (__U)(object)Deserialize();"); - Line(4, "}"); - Line(0); - Line(4, "return ((global::Bond.IBonded)new global::Bond.Bonded<__Payload>(Value)).Deserialize<__U>();"); - Line(3, "}"); - Line(0); - Line(3, "public void Serialize<__Writer>(__Writer writer) => ((global::Bond.IBonded)new global::Bond.Bonded<__Payload>(Value)).Serialize(writer);"); - Line(3, "public global::Bond.IBonded<__U> Convert<__U>() => this as global::Bond.IBonded<__U>;"); - Line(0); - Line(3, $"public static {ModelSupport}IModelAdapter> CreateAdapter(" + - $"{ModelSupport}IModelAdapter<__Payload> adapter) =>"); - Line(4, $"{ModelSupport}ModelAdapters.Materialized, __Payload>("); - Line(5, $"static value => value is {ModelSupport}IMaterializedModelValue<__Payload> materialized ? materialized.Value : value.Deserialize(),"); - Line(5, "static (value, capturedAdapter) => new __MaterializedBonded<__Payload>(value, capturedAdapter), adapter);"); - Line(2, "}"); - } - - private sealed record OperationField(string Owner, string DeclaringName, Field Field, BondType Type); - - private IEnumerable OperationFields(StructDeclaration structure, BondType[]? arguments = null) - { - BondType? current = new BondType.TypeReference(structure, - arguments ?? structure.TypeParameters.Select(parameter => (BondType)new BondType.TypeParameter(parameter)).ToArray()); - while (current != null) - { - var reference = (BondType.TypeReference)UnwrapAlias(current, structure.Location); - if (Canonical(reference.Declaration) is not StructDeclaration declaration) - { - throw new GenerationException( - $"Cannot generate model operations for '{structure.Name}' because base '{reference.Declaration.QualifiedName}' " + - "has no schema definition. Include its .bond definition or disable these operations.", structure.Location); - } - - var owner = MapType(reference, structure.Location).Name; - foreach (var field in declaration.Fields.OrderBy(field => field.Ordinal)) - { - yield return new(owner, IdlFullName(declaration), field, Substitute(field.Type, declaration, reference.TypeArguments)); - } - - current = declaration.BaseType is null ? null : Substitute(declaration.BaseType, declaration, reference.TypeArguments); - } - } - - private readonly record struct AdapterExpression( - string Code, - bool RequiresArgumentBinding = false, - bool RequiresMaterializedWrapper = false); - - private AdapterExpression ModelAdapterExpression(BondType type, SourceLocation location) - { - var mapped = MapType(type, location); - var adapters = ModelSupport + "ModelAdapters."; - if (mapped.IsCustom) - { - return new($"{adapters}Value<{mapped.Name}>()"); - } - - if (type is BondType.TypeReference reference && Canonical(reference.Declaration) is AliasDeclaration alias) - { - return ModelAdapterExpression(Substitute(alias.AliasedType, alias, reference.TypeArguments), location); - } - - if (type is BondType.TypeReference { TypeArguments.Length: > 0 } generic) - { - var arguments = new List(); - var requiresMaterializedWrapper = false; - foreach (var argument in generic.TypeArguments) - { - var argumentName = MapType(argument, location).Name; - var adapter = ModelAdapterExpression(argument, location); - if (adapter.RequiresArgumentBinding) - { - arguments.Add($"{ModelSupport}ModelAdapterArgument.Create<{argumentName}>({adapter.Code})"); - } - - requiresMaterializedWrapper |= adapter.RequiresMaterializedWrapper; - } - - if (arguments.Count > 0) - { - return new($"{adapters}WithArguments({adapters}Value<{mapped.Name}>(), {string.Join(", ", arguments)})", - RequiresArgumentBinding: true, RequiresMaterializedWrapper: requiresMaterializedWrapper); - } - } - - AdapterExpression Container(string name, params BondType[] elements) - { - var children = elements.Select(element => ModelAdapterExpression(element, location)).ToArray(); - var arguments = string.Join(", ", children.Select(child => child.Code)); - return new($"{adapters}{name}({arguments})", RequiresArgumentBinding: true, - RequiresMaterializedWrapper: children.Any(child => child.RequiresMaterializedWrapper)); - } - - AdapterExpression Optional(BondType element) - { - var elementType = MapType(element, location); - var adapter = ModelAdapterExpression(element, location); - if (elementType.Name == mapped.Name) - { - return adapter; - } - - return new($"{adapters}Nullable({adapter.Code})", RequiresArgumentBinding: true, - RequiresMaterializedWrapper: adapter.RequiresMaterializedWrapper); - } - - switch (type) - { - case BondType.Vector vector: - return Container("List", vector.ElementType); - case BondType.List list: - return Container("LinkedList", list.ElementType); - case BondType.Set set: - return Container("Set", set.KeyType); - case BondType.Map map: - return Container("Map", map.KeyType, map.ValueType); - case BondType.Blob: - return new(adapters + "Blob", RequiresArgumentBinding: true); - case BondType.Nullable nullable: - return Optional(nullable.ElementType); - case BondType.Maybe maybe: - return Optional(maybe.ElementType); - case BondType.Bonded bonded: - { - var payloadType = MapType(bonded.StructType, location).Name; - var payloadAdapter = ModelAdapterExpression(bonded.StructType, location); - return new($"__MaterializedBonded<{payloadType}>.CreateAdapter({payloadAdapter.Code})", - RequiresArgumentBinding: true, RequiresMaterializedWrapper: true); - } - default: - return new($"{adapters}Value<{mapped.Name}>()"); - } - } - } -} diff --git a/Bond.Parser/CodeGeneration/CSharpSummaryEmitter.cs b/Bond.Parser/CodeGeneration/CSharpSummaryEmitter.cs deleted file mode 100644 index a6c52f1..0000000 --- a/Bond.Parser/CodeGeneration/CSharpSummaryEmitter.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Globalization; -using Bond.Parser.Syntax; - -namespace Bond.Parser.CodeGeneration; - -public static partial class CSharpGenerator -{ - private sealed partial class Emitter - { - private void EmitSummaryMembers(StructDeclaration structure) - { - if (!options.GenerateToString) - { - return; - } - - Line(0); - Line(2, $"string {ModelSupport}IGeneratedSummary.SummaryName => {Literal(structure.Name)};"); - Line(2, $"{ModelSupport}ModelDebugField[] {ModelSupport}IGeneratedSummary.GetSummaryFields()"); - Line(2, "{"); - Line(3, $"return new {ModelSupport}ModelDebugField[]"); - Line(3, "{"); - foreach (var field in OperationFields(structure)) - { - var value = $"(({field.Owner})this).{Identifier(field.Field.Name, field.Field.Location)}"; - if (UnwrapAlias(field.Type, field.Field.Location) is BondType.Bonded) - { - value = $"{ModelSupport}ModelSummary.Opaque({value})"; - } - - Line(4, $"new {ModelSupport}ModelDebugField({Literal(field.DeclaringName)}, {Literal(field.Field.Name)}, " + - $"{field.Field.Ordinal.ToString(CultureInfo.InvariantCulture)}, {value}),"); - } - - Line(3, "};"); - Line(2, "}"); - - if (CanEmitConvenience(structure, "ToString")) - { - Line(2, $"public override string ToString() => {ModelSupport}ModelSummary.Format(this);"); - } - } - - private void EmitSummaryCompanionMembers(StructDeclaration _, string self) - { - if (options.GenerateToString) - { - Line(2, $"public static string ToString({self} value) => {ModelSupport}ModelSummary.Format(value);"); - } - } - } -} diff --git a/Bond.Parser/Compatibility/CompatibilityChecker.cs b/Bond.Parser/Compatibility/CompatibilityChecker.cs index d2f875e..8da0079 100644 --- a/Bond.Parser/Compatibility/CompatibilityChecker.cs +++ b/Bond.Parser/Compatibility/CompatibilityChecker.cs @@ -859,7 +859,7 @@ private static ContractDeclaration Instantiate(ContractDeclaration declaration, return declaration with { BaseType = declaration.BaseType is null ? null : Substitute(declaration.BaseType, arguments), - Fields = SchemaContractValidation.ReadOnly(declaration.Fields.Select(field => + Fields = declaration.Fields.Select(field => { var type = Substitute(field.Type, arguments); return field with @@ -868,7 +868,7 @@ private static ContractDeclaration Instantiate(ContractDeclaration declaration, Modifier = type.Kind is "meta_name" or "meta_full_name" ? "required_optional" : field.Modifier, Default = field.Default.Kind == "generic" ? SchemaContractBuilder.ImplicitDefault(type, declaration.Name) : field.Default }; - })) + }).ToArray() }; } @@ -881,7 +881,7 @@ private static TypeShape Substitute(TypeShape type, IReadOnlyList arg return type with { - Arguments = SchemaContractValidation.ReadOnly(type.Arguments.Select(argument => Substitute(argument, arguments))) + Arguments = type.Arguments.Select(argument => Substitute(argument, arguments)).ToArray() }; } diff --git a/Bond.Parser/Compatibility/SchemaContract.cs b/Bond.Parser/Compatibility/SchemaContract.cs index 0fdf8c3..5e1aa42 100644 --- a/Bond.Parser/Compatibility/SchemaContract.cs +++ b/Bond.Parser/Compatibility/SchemaContract.cs @@ -30,8 +30,7 @@ internal sealed record ContractMethod(string Name, string Kind, TypeShape Input, // Only type expressions are recursive; named types never contain declaration bodies. internal sealed record TypeShape(string Kind, string? Name, long? Integer, IReadOnlyList Arguments) { - internal static TypeShape Of(string kind, params TypeShape[] arguments) => - new(kind, null, null, Array.AsReadOnly(arguments)); + internal static TypeShape Of(string kind, params TypeShape[] arguments) => new(kind, null, null, arguments); internal bool Same(TypeShape? other) => other is not null && Kind == other.Kind && Name == other.Name && Integer == other.Integer && Arguments.Count == other.Arguments.Count diff --git a/Bond.Parser/Compatibility/SchemaContractBuilder.cs b/Bond.Parser/Compatibility/SchemaContractBuilder.cs index 31eb658..8e9b8ca 100644 --- a/Bond.Parser/Compatibility/SchemaContractBuilder.cs +++ b/Bond.Parser/Compatibility/SchemaContractBuilder.cs @@ -59,8 +59,8 @@ internal SchemaContract Build(Syntax.Bond schema) Shape(alias.AliasedType, Parameters(alias), new HashSet(ReferenceEqualityComparer.Instance) { alias }, Identity(alias)); } - return Normalize(new SchemaContract(includeImports, allowUnresolvedTypes, - _declarations.Values.Select(Project).ToArray())); + return new SchemaContract(includeImports, allowUnresolvedTypes, + _declarations.Values.Select(Project).OrderBy(declaration => declaration.Name, StringComparer.Ordinal).ToArray()); } internal static string Identity(Declaration declaration) @@ -188,7 +188,9 @@ private ContractDeclaration Project(Declaration declaration) .ToArray(); return new ContractDeclaration(name, declarationKind, _roots.Contains(name), constraints, - baseType is null ? null : Type(baseType), fields, _enums.GetValueOrDefault(name) ?? [], methods); + baseType is null ? null : Type(baseType), fields, + (_enums.GetValueOrDefault(name) ?? []).OrderBy(constant => constant.Name, StringComparer.Ordinal).ToArray(), + methods.OrderBy(method => method.Name, StringComparer.Ordinal).ToArray()); } private IReadOnlyList ProjectFields(StructDeclaration declaration, Dictionary parameters) @@ -238,7 +240,7 @@ private IReadOnlyList ProjectFields(StructDeclaration declaration type, DefaultValue(field.DefaultValue, type, declaration))); } - return fields; + return fields.OrderBy(field => field.Ordinal).ToArray(); } private TypeShape Shape(BondType type, Dictionary parameters, @@ -279,12 +281,12 @@ private TypeShape Shape(BondType type, Dictionary parameters, _enums.Add(Identity(enumeration), EnumValues(enumeration)); } - return new TypeShape(kind, Identity(declaration), null, ReadOnly(arguments)); + return new TypeShape(kind, Identity(declaration), null, arguments); } case BondType.UnresolvedType unresolved: Require(allowUnresolvedTypes, $"Unresolved type '{string.Join(".", unresolved.QualifiedName)}'. Resolve imports or explicitly allow unresolved types.", location, DiagnosticIds.UnresolvedType); - return new TypeShape("unresolved", string.Join(".", unresolved.QualifiedName), null, ReadOnly(unresolved.TypeArguments.Select(Child))); + return new TypeShape("unresolved", string.Join(".", unresolved.QualifiedName), null, unresolved.TypeArguments.Select(Child).ToArray()); case BondType.TypeParameter parameter: Require(parameters.TryGetValue(parameter.Param.Name, out var substitution), $"Unknown type parameter '{parameter.Param.Name}'.", location); return substitution!; @@ -350,7 +352,7 @@ private static IReadOnlyList EnumValues(EnumDeclaration enumer next = (long)value + 1; } - return ReadOnly(values); + return values; } private ContractDefault DefaultValue(Default? value, TypeShape type, Declaration owner) @@ -425,6 +427,7 @@ private static ContractDefault Floating(double number, TypeShape type) number = (float)number; } + Require(double.IsFinite(number), $"Default value is out of range for '{type.Kind}'."); return new ContractDefault("float", number.ToString("R", CultureInfo.InvariantCulture)); } } diff --git a/Bond.Parser/Compatibility/SchemaContractValidation.cs b/Bond.Parser/Compatibility/SchemaContractValidation.cs index 07d3861..be01163 100644 --- a/Bond.Parser/Compatibility/SchemaContractValidation.cs +++ b/Bond.Parser/Compatibility/SchemaContractValidation.cs @@ -8,19 +8,26 @@ namespace Bond.Parser.Compatibility; +/// +/// Builds a contract and checks the semantic rules the parser skips with --ignore-imports, or that a hand-built AST +/// may violate. Structural rules (names, duplicates, kinds) are enforced while building. +/// internal static class SchemaContractValidation { internal static SchemaContract Create(Syntax.Bond schema, bool includeImports, bool allowUnresolvedTypes) { + SchemaContract contract; try { - return new SchemaContractBuilder(includeImports, allowUnresolvedTypes).Build(schema); + contract = new SchemaContractBuilder(includeImports, allowUnresolvedTypes).Build(schema); } - catch (Exception ex) when (ex is ArgumentException or NullReferenceException or InvalidOperationException - or OverflowException) + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or OverflowException) { throw Invalid($"Malformed schema: {ex.Message}"); } + + Validate(contract); + return contract; } internal static InvalidDataException Invalid(string message, string location = "schema", @@ -32,142 +39,21 @@ internal static InvalidDataException Invalid(string message, string location = " return error; } - internal static IReadOnlyList ReadOnly(IEnumerable values) => Array.AsReadOnly(values.ToArray()); - - internal static SchemaContract Normalize(SchemaContract document) + private static void Validate(SchemaContract contract) { - ValidateContract(document); - - var declarations = document.Declarations - .OrderBy(declaration => declaration.Name, StringComparer.Ordinal) - .Select(NormalizeDeclaration); - - return document with - { - Declarations = ReadOnly(declarations) - }; - } - - private static ContractDeclaration NormalizeDeclaration(ContractDeclaration declaration) - { - var fields = declaration.Fields - .OrderBy(field => field.Ordinal) - .Select(field => field with - { - Type = Freeze(field.Type), - Default = CanonicalDefault(field.Default) - }); - var methods = declaration.Methods - .OrderBy(method => method.Name, StringComparer.Ordinal) - .Select(method => method with - { - Input = Freeze(method.Input), - Result = Freeze(method.Result) - }); - - return declaration with - { - Constraints = ReadOnly(declaration.Constraints), - BaseType = declaration.BaseType is null ? null : Freeze(declaration.BaseType), - Fields = ReadOnly(fields), - Constants = ReadOnly(declaration.Constants.OrderBy(constant => constant.Name, StringComparer.Ordinal)), - Methods = ReadOnly(methods) - }; - } - - private static TypeShape Freeze(TypeShape type) => type with - { - Arguments = ReadOnly(type.Arguments.Select(Freeze)) - }; - - private static ContractDefault CanonicalDefault(ContractDefault value) => value.Kind switch - { - "integer" => value with - { - Value = BigInteger.Parse(value.Value, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture) - }, - "float" => value with - { - Value = double.Parse(value.Value, CultureInfo.InvariantCulture).ToString("R", CultureInfo.InvariantCulture) - }, - _ => value - }; - - private static void ValidateContract(SchemaContract document) - { - Require(document.Declarations is not null, "Missing schema declarations."); - var declarations = new Dictionary(StringComparer.Ordinal); - foreach (var declaration in document.Declarations!) - { - Require(declaration is not null && ValidName(declaration.Name), "Invalid declaration name."); - if (!declarations.TryAdd(declaration.Name, declaration)) - { - throw Invalid($"Duplicate declaration '{declaration.Name}'.", declaration.Name, DiagnosticIds.DuplicateDeclaration); - } - - Require(declaration.Kind is "struct" or "forward" or "enum" or "service", - $"Invalid declaration kind '{declaration.Kind}'.", declaration.Name); - Require(declaration.Constraints is not null && declaration.Constraints.All(constraint => constraint is "none" or "value"), - "Invalid generic constraints.", declaration.Name); - Require(declaration.Fields is not null && declaration.Constants is not null && declaration.Methods is not null, - "Missing declaration members.", declaration.Name); - Require(declaration.Kind == "struct" || declaration.Fields.Count == 0, "Only structs can have fields.", declaration.Name); - Require(declaration.Kind == "enum" || declaration.Constants.Count == 0, "Only enums can have constants.", declaration.Name); - Require(declaration.Kind != "enum" || declaration.Constraints.Count == 0, "Enums cannot have generic parameters.", declaration.Name); - Require(declaration.Kind == "service" || declaration.Methods.Count == 0, "Only services can have methods.", declaration.Name); - Require(declaration.Kind is "struct" or "service" || declaration.BaseType is null, "Invalid base type.", declaration.Name); - - var ordinals = new HashSet(); - var names = new HashSet(StringComparer.Ordinal); - foreach (var field in declaration.Fields) - { - Require(field is not null && ValidName(field.Name, qualified: false), "Invalid field name.", declaration.Name); - Require(field.Ordinal >= 0 && field.Ordinal <= ushort.MaxValue && field.JsonName is not null, - "Invalid field ordinal or JSON name.", declaration.Name); - if (!ordinals.Add(field.Ordinal) || !names.Add(field.Name)) - { - throw Invalid($"Duplicate field ordinal or name '{field.Ordinal}: {field.Name}'.", declaration.Name, DiagnosticIds.DuplicateField); - } - - Require(field.Modifier is "optional" or "required" or "required_optional", "Invalid field modifier.", declaration.Name); - ValidateDefault(field.Default, declaration.Name + "." + field.Name); - } - - names.Clear(); - foreach (var constant in declaration.Constants) - { - Require(constant is not null && ValidName(constant.Name, qualified: false), "Invalid enum member.", declaration.Name); - if (!names.Add(constant.Name)) - { - throw Invalid($"Duplicate enum member '{constant.Name}'.", declaration.Name, DiagnosticIds.DuplicateEnumMember); - } - } - - names.Clear(); - foreach (var method in declaration.Methods) - { - Require(method is not null && ValidName(method.Name, qualified: false), "Invalid method name.", declaration.Name); - if (!names.Add(method.Name)) - { - throw Invalid($"Duplicate method '{method.Name}'.", declaration.Name, DiagnosticIds.DuplicateMethod); - } - - Require(method.Kind is "function" or "event", "Invalid method kind.", declaration.Name); - } - } - - foreach (var declaration in declarations.Values) + var declarations = contract.Declarations.ToDictionary(declaration => declaration.Name, StringComparer.Ordinal); + foreach (var declaration in contract.Declarations) { if (declaration.BaseType is not null) { - ValidateType(declaration.BaseType, declaration, document, declarations); + ValidateType(declaration.BaseType, declaration, contract, declarations); Require(declaration.BaseType.Kind == (declaration.Kind == "service" ? "service" : "struct") - || document.AllowsUnresolvedTypes && declaration.BaseType.Kind == "unresolved", "Invalid inheritance type.", declaration.Name); + || contract.AllowsUnresolvedTypes && declaration.BaseType.Kind == "unresolved", "Invalid inheritance type.", declaration.Name); } foreach (var field in declaration.Fields) { - ValidateType(field.Type, declaration, document, declarations); + ValidateType(field.Type, declaration, contract, declarations); Require(field.Type.Kind is not ("integer" or "void" or "stream" or "service"), "Invalid field type.", declaration.Name + "." + field.Name); ValidateFieldDefault(field, declaration.Name + "." + field.Name); @@ -175,86 +61,50 @@ private static void ValidateContract(SchemaContract document) foreach (var method in declaration.Methods) { - ValidateType(method.Input, declaration, document, declarations); - ValidateType(method.Result, declaration, document, declarations); + ValidateType(method.Input, declaration, contract, declarations); + ValidateType(method.Result, declaration, contract, declarations); } } ValidateInheritance(declarations); } - private static void ValidateType(TypeShape type, ContractDeclaration owner, SchemaContract document, - Dictionary declarations, int depth = 0) + private static void ValidateType(TypeShape type, ContractDeclaration owner, SchemaContract contract, + Dictionary declarations) { - Require(type is not null && depth < 64 && type.Arguments is not null, "Invalid or excessively nested type expression.", owner.Name); - - var argumentCount = type.Arguments.Count; - if (type.Kind is "struct" or "enum" or "service" or "unresolved") + if (type.Kind is "struct" or "enum" or "service") { - Require(ValidName(type.Name) && type.Integer is null, "Invalid named type.", owner.Name); - if (type.Kind == "unresolved") - { - Require(document.AllowsUnresolvedTypes, $"Unresolved type '{type.Name}'.", owner.Name, DiagnosticIds.UnresolvedType); - } - else if (declarations.TryGetValue(type.Name!, out var target)) + if (declarations.TryGetValue(type.Name!, out var target)) { Require(type.Kind == (target.Kind == "forward" ? "struct" : target.Kind), $"Type kind does not match '{type.Name}'.", owner.Name); - Require(argumentCount == target.Constraints.Count, $"Type '{type.Name}' has the wrong number of generic arguments.", owner.Name); - if (target.Kind == "forward" && !document.AllowsUnresolvedTypes) + Require(type.Arguments.Count == target.Constraints.Count, $"Type '{type.Name}' has the wrong number of generic arguments.", owner.Name); + if (target.Kind == "forward" && !contract.AllowsUnresolvedTypes) { throw Invalid($"No complete definition for '{type.Name}'; its layout cannot be established.", owner.Name, DiagnosticIds.IncompleteDefinition); } + + for (var i = 0; i < target.Constraints.Count; i++) + { + Require(target.Constraints[i] != "value" || IsValueType(type.Arguments[i], owner) + || contract.AllowsUnresolvedTypes && type.Arguments[i].Kind == "unresolved", + $"Type argument {i} for '{type.Name}' does not satisfy its value constraint.", owner.Name); + } } else { - Require(!document.IncludesImports || document.AllowsUnresolvedTypes, + Require(!contract.IncludesImports || contract.AllowsUnresolvedTypes, $"Missing definition for type '{type.Name}'.", owner.Name, DiagnosticIds.UnresolvedType); } } - else if (type.Kind is "parameter" or "integer") - { - Require(type.Name is null && type.Integer is not null && argumentCount == 0, "Invalid generic argument.", owner.Name); - if (type.Kind == "parameter") - { - Require(type.Integer >= 0 && type.Integer < owner.Constraints.Count, "Invalid generic parameter position.", owner.Name); - } - } - else + else if (type.Kind is not "unresolved") { - var expectedArgumentCount = type.Kind switch - { - "map" => 2, - "list" or "vector" or "set" or "nullable" or "maybe" or "bonded" or "stream" => 1, - "int8" or "int16" or "int32" or "int64" or "uint8" or "uint16" or "uint32" or "uint64" - or "float" or "double" or "string" or "wstring" or "bool" or "blob" or "meta_name" or "meta_full_name" or "void" => 0, - _ => -1 - }; - Require(expectedArgumentCount >= 0 && argumentCount == expectedArgumentCount && type.Name is null && type.Integer is null, - $"Invalid type '{type.Kind}'.", owner.Name); + Require(type.Arguments.All(argument => argument.Kind is not ("integer" or "void" or "service" or "stream")), + "Invalid container element type.", owner.Name); } foreach (var argument in type.Arguments) { - ValidateType(argument, owner, document, declarations, depth + 1); - } - - if (type.Kind is "struct" or "enum" or "service" && declarations.TryGetValue(type.Name!, out var definition)) - { - for (var i = 0; i < definition.Constraints.Count; i++) - { - if (definition.Constraints[i] == "value") - { - Require(IsValueType(type.Arguments[i], owner) - || document.AllowsUnresolvedTypes && type.Arguments[i].Kind == "unresolved", - $"Type argument {i} for '{type.Name}' does not satisfy its value constraint.", owner.Name); - } - } - } - - if (type.Kind is not ("struct" or "enum" or "service" or "unresolved")) - { - Require(type.Arguments.All(argument => argument.Kind is not ("integer" or "void" or "service" or "stream")), - "Invalid container element type.", owner.Name); + ValidateType(argument, owner, contract, declarations); } } @@ -320,21 +170,6 @@ private static void ValidateFieldDefault(ContractField field, string location) } } - private static void ValidateDefault(ContractDefault value, string location) - { - Require(value is not null && value.Value is not null, "Invalid default value.", location); - var valid = value!.Kind switch - { - "integer" => BigInteger.TryParse(value.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out _), - "float" => double.TryParse(value.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number) && double.IsFinite(number), - "bool" => value.Value is "true" or "false", - "string" or "symbol" or "meta" => true, - "nothing" or "empty" or "nullable" or "struct" or "generic" => value.Value.Length == 0, - _ => false - }; - Require(valid, $"Invalid default kind or value '{value.Kind}'.", location); - } - internal static bool ValidName(string? name, bool qualified = true) => !string.IsNullOrWhiteSpace(name) && name.Split('.').All(part => part.Length != 0 && (char.IsLetter(part[0]) || part[0] == '_') && part.All(c => char.IsLetterOrDigit(c) || c == '_')) diff --git a/Bond.Parser/Parser/ImportResolver.cs b/Bond.Parser/Parser/ImportResolver.cs index 0f3a84f..160aaaf 100644 --- a/Bond.Parser/Parser/ImportResolver.cs +++ b/Bond.Parser/Parser/ImportResolver.cs @@ -1,4 +1,6 @@ +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading.Tasks; namespace Bond.Parser.Parser; @@ -15,20 +17,34 @@ string importPath public static class DefaultImportResolver { - public static async Task<(string canonicalPath, string content)> Resolve( - string currentFile, - string importPath) - { - var currentDir = Path.GetDirectoryName(currentFile) ?? Directory.GetCurrentDirectory(); - var relativePath = importPath.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar); - var absolutePath = Path.GetFullPath(Path.Combine(currentDir, relativePath)); + /// Resolves imports relative to the importing file. + public static Task<(string canonicalPath, string content)> Resolve(string currentFile, string importPath) => + Create([])(currentFile, importPath); - if (!File.Exists(absolutePath)) + /// Resolves imports relative to the importing file, then to each import directory in order. + public static ImportResolver Create(IEnumerable importDirectories) + { + var directories = importDirectories.Select(Path.GetFullPath).ToArray(); + return async (currentFile, importPath) => { - throw new FileNotFoundException($"Imported file not found: {importPath}", absolutePath); - } + foreach (var candidate in Candidates(currentFile, importPath, directories)) + { + if (File.Exists(candidate)) + { + return (candidate, await File.ReadAllTextAsync(candidate)); + } + } - var content = await File.ReadAllTextAsync(absolutePath); - return (absolutePath, content); + throw new FileNotFoundException($"Imported file not found: {importPath}", importPath); + }; + } + + /// The paths an import may refer to, in search order. + public static IEnumerable Candidates(string currentFile, string importPath, IEnumerable importDirectories) + { + var relativePath = importPath.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar); + var currentDirectory = Path.GetDirectoryName(currentFile) ?? Directory.GetCurrentDirectory(); + return importDirectories.Prepend(currentDirectory) + .Select(directory => Path.GetFullPath(Path.Combine(directory, relativePath))); } } diff --git a/Bond.sln b/Bond.sln index 31f5873..53777ef 100644 --- a/Bond.sln +++ b/Bond.sln @@ -9,12 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bond.Parser.CLI", "Bond.Par EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bond.Parser.Tests", "tests\Bond.Parser.Tests\Bond.Parser.Tests.csproj", "{D6FEA5A7-3F47-46B7-80F8-649DD0AAD345}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bond.Models.Tests", "tests\Bond.Models.Tests\Bond.Models.Tests.csproj", "{5B1DC1AC-B47B-409C-B326-52A091F4985D}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bond.Build.Tests", "tests\Bond.Build.Tests\Bond.Build.Tests.csproj", "{D368937C-414C-48B5-9B11-BB9D6CD53481}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bond.Models", "Bond.Models\Bond.Models.csproj", "{8F0EA530-055B-47CD-995C-A99872F35058}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bond.Build", "Bond.Build\Bond.Build.csproj", "{79064C9F-73F2-4926-8E05-0FEDD522C6B6}" EndProject Global @@ -63,18 +59,6 @@ Global {D6FEA5A7-3F47-46B7-80F8-649DD0AAD345}.Release|x64.Build.0 = Release|Any CPU {D6FEA5A7-3F47-46B7-80F8-649DD0AAD345}.Release|x86.ActiveCfg = Release|Any CPU {D6FEA5A7-3F47-46B7-80F8-649DD0AAD345}.Release|x86.Build.0 = Release|Any CPU - {5B1DC1AC-B47B-409C-B326-52A091F4985D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5B1DC1AC-B47B-409C-B326-52A091F4985D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5B1DC1AC-B47B-409C-B326-52A091F4985D}.Debug|x64.ActiveCfg = Debug|Any CPU - {5B1DC1AC-B47B-409C-B326-52A091F4985D}.Debug|x64.Build.0 = Debug|Any CPU - {5B1DC1AC-B47B-409C-B326-52A091F4985D}.Debug|x86.ActiveCfg = Debug|Any CPU - {5B1DC1AC-B47B-409C-B326-52A091F4985D}.Debug|x86.Build.0 = Debug|Any CPU - {5B1DC1AC-B47B-409C-B326-52A091F4985D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5B1DC1AC-B47B-409C-B326-52A091F4985D}.Release|Any CPU.Build.0 = Release|Any CPU - {5B1DC1AC-B47B-409C-B326-52A091F4985D}.Release|x64.ActiveCfg = Release|Any CPU - {5B1DC1AC-B47B-409C-B326-52A091F4985D}.Release|x64.Build.0 = Release|Any CPU - {5B1DC1AC-B47B-409C-B326-52A091F4985D}.Release|x86.ActiveCfg = Release|Any CPU - {5B1DC1AC-B47B-409C-B326-52A091F4985D}.Release|x86.Build.0 = Release|Any CPU {D368937C-414C-48B5-9B11-BB9D6CD53481}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D368937C-414C-48B5-9B11-BB9D6CD53481}.Debug|Any CPU.Build.0 = Debug|Any CPU {D368937C-414C-48B5-9B11-BB9D6CD53481}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -87,18 +71,6 @@ Global {D368937C-414C-48B5-9B11-BB9D6CD53481}.Release|x64.Build.0 = Release|Any CPU {D368937C-414C-48B5-9B11-BB9D6CD53481}.Release|x86.ActiveCfg = Release|Any CPU {D368937C-414C-48B5-9B11-BB9D6CD53481}.Release|x86.Build.0 = Release|Any CPU - {8F0EA530-055B-47CD-995C-A99872F35058}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8F0EA530-055B-47CD-995C-A99872F35058}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8F0EA530-055B-47CD-995C-A99872F35058}.Debug|x64.ActiveCfg = Debug|Any CPU - {8F0EA530-055B-47CD-995C-A99872F35058}.Debug|x64.Build.0 = Debug|Any CPU - {8F0EA530-055B-47CD-995C-A99872F35058}.Debug|x86.ActiveCfg = Debug|Any CPU - {8F0EA530-055B-47CD-995C-A99872F35058}.Debug|x86.Build.0 = Debug|Any CPU - {8F0EA530-055B-47CD-995C-A99872F35058}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8F0EA530-055B-47CD-995C-A99872F35058}.Release|Any CPU.Build.0 = Release|Any CPU - {8F0EA530-055B-47CD-995C-A99872F35058}.Release|x64.ActiveCfg = Release|Any CPU - {8F0EA530-055B-47CD-995C-A99872F35058}.Release|x64.Build.0 = Release|Any CPU - {8F0EA530-055B-47CD-995C-A99872F35058}.Release|x86.ActiveCfg = Release|Any CPU - {8F0EA530-055B-47CD-995C-A99872F35058}.Release|x86.Build.0 = Release|Any CPU {79064C9F-73F2-4926-8E05-0FEDD522C6B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {79064C9F-73F2-4926-8E05-0FEDD522C6B6}.Debug|Any CPU.Build.0 = Debug|Any CPU {79064C9F-73F2-4926-8E05-0FEDD522C6B6}.Debug|x64.ActiveCfg = Debug|Any CPU diff --git a/Makefile b/Makefile index 172db7d..94fa275 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,7 @@ clean: ## Clean build artifacts rm -rf $(OUT_DIR) rm -rf */bin */obj -pack: build-release ## Pack the tool, parser, model support, and build integration +pack: build-release ## Pack the tool, parser, and build integration dotnet pack Bond.sln -c Release --no-build --no-restore /p:Version=$(VERSION) @echo "" @echo "Packages created in $(NUPKG_DIR)" diff --git a/README.md b/README.md index b06c874..4e9b3c2 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ bond format schema.bond --check ## C# generation ```bash -bond generate csharp MyApp/order.bond -o MyApp/Generated \ - --descriptors --clone --equality --debugger --to-string +bond generate csharp MyApp/order.bond -o MyApp/Generated --clone --equality --to-string ``` + +Generated models need only `Bond.Runtime.CSharp`. `--clone`, `--equality` and `--to-string` add deep `Clone()`, +structural `Equals()`/`GetHashCode()` and `ToString()`. For generation during `dotnet build`, see +[BondTools.Build](Bond.Build/README.md). diff --git a/tests/Bond.Build.Tests/Bond.Build.Tests.csproj b/tests/Bond.Build.Tests/Bond.Build.Tests.csproj index 164063b..a287255 100644 --- a/tests/Bond.Build.Tests/Bond.Build.Tests.csproj +++ b/tests/Bond.Build.Tests/Bond.Build.Tests.csproj @@ -25,7 +25,6 @@ - diff --git a/tests/Bond.Build.Tests/MsBuildGenerationTests.cs b/tests/Bond.Build.Tests/MsBuildGenerationTests.cs index 09f5e24..eb89bb3 100644 --- a/tests/Bond.Build.Tests/MsBuildGenerationTests.cs +++ b/tests/Bond.Build.Tests/MsBuildGenerationTests.cs @@ -2,7 +2,6 @@ using System.Diagnostics; using System.IO; using System.Linq; -using System.Text.Json.Nodes; using System.Threading.Tasks; using System.Xml.Linq; @@ -11,41 +10,13 @@ namespace Bond.Build.Tests; public sealed class MsBuildGenerationTests(MsBuildPackageFixture packages) : IClassFixture { [Fact] - public async Task GeneratesToStringIndependentlyOfOtherModelFeatures() - { - var project = packages.CreateConsumer(); - project.Write("item.bond", "namespace Contracts struct Item { 0: int32 id; }"); - project.Write("Program.cs", """ - var item = new Contracts.Item { id = 42 }; - if (item.ToString() != "Item { id = 42 }") - { - throw new System.Exception("Unexpected generated summary."); - } - - System.Console.WriteLine(item); - """); - project.Configure(new XElement("Bond", new XAttribute("Include", "item.bond"), new XAttribute("ToString", "true"))); - - (await project.Build()).AssertSuccess(); - var output = File.ReadAllText(Assert.Single(project.GeneratedFiles())); - Assert.Contains("override string ToString()", output); - Assert.DoesNotContain("SchemaDescriptor", output); - Assert.DoesNotContain("IModelAdapter", output); - - var run = await project.Run(); - run.AssertSuccess(); - Assert.Contains("Item { id = 42 }", run.Output); - } - - [Fact] - public async Task PackagedBuildGeneratesDocumentedRichModelsAndSkipsUnchangedInputs() + public async Task GeneratesModelsAndOnlyRewritesChangedOutputs() { var project = packages.CreateConsumer(); project.Write("Schemas/item.bond", """ namespace Contracts // An item documentation & details. struct Item { - // The item identifier. 0: int32 id; 1: vector values; } @@ -54,427 +25,180 @@ struct Item { using Application; using Bond.IO.Safe; using Bond.Protocols; - using BondTools.Models; var item = new Item { id = 42 }; item.values.Add(3); var copy = item.Clone(); - if (ReferenceEquals(item.values, copy.values) || !item.Equals(copy)) - { - throw new Exception("Cloning did not copy model values."); - } - - if (ItemSchema.Descriptor.FullName != "Contracts.Item") + if (ReferenceEquals(item.values, copy.values) || !item.Equals(copy) || item.ToString() != "Item { id = 42, values = [3] }") { - throw new Exception("Namespace mapping changed IDL metadata."); + throw new Exception("Model members are wrong."); } var output = new OutputBuffer(); Bond.Serialize.To(new CompactBinaryWriter(output, 2), item); var decoded = Bond.Deserialize.From(new CompactBinaryReader(new InputBuffer(output.Data), 2)); - if (!item.Equals(decoded) || new GeneratedModelDebugView(item).Fields.Length != 2) - { - throw new Exception("Generated models did not retain runtime behavior."); - } - - Console.WriteLine("consumer succeeded"); + Console.WriteLine(item.Equals(decoded) ? "consumer succeeded" : "round trip failed"); """); project.Configure( - new XElement("Bond", new XAttribute("Include", "Schemas/item.bond"), - new XAttribute("Descriptors", "true"), new XAttribute("Clone", "true"), - new XAttribute("Equality", "true"), new XAttribute("Debugger", "true")), - new XElement("BondUsing", new XAttribute("Include", "System.Collections.Generic")), - new XElement("BondNamespaceMapping", new XAttribute("Include", "Contracts=Application"))); - - var first = await project.Build(); - first.AssertSuccess(); - var outputFile = Assert.Single(project.GeneratedFiles()); - var code = await File.ReadAllTextAsync(outputFile, TestContext.Current.CancellationToken); + new XElement("PropertyGroup", + new XElement("BondClone", "true"), new XElement("BondEquality", "true"), new XElement("BondToString", "true"), + new XElement("BondUsings", "System.Collections.Generic"), + new XElement("BondNamespaceMappings", "Contracts=Application")), + new XElement("ItemGroup", new XElement("Bond", new XAttribute("Include", "Schemas/item.bond")))); + + (await project.Build()).AssertSuccess(); + var output = Assert.Single(project.GeneratedFiles()); + var code = File.ReadAllText(output); Assert.Contains("using System.Collections.Generic;", code); - Assert.Contains("///", code); var documentation = XDocument.Load(project.Binary("Consumer.xml")); Assert.Equal("An item documentation & details.", documentation.Descendants("member").Single(member => (string?)member.Attribute("name") == "T:Application.Item") .Element("summary")!.Value.Trim()); + Assert.Contains("consumer succeeded", (await project.Run()).Output); + + var timestamp = File.GetLastWriteTimeUtc(output); + File.AppendAllText(Path.Combine(project.Root, "Program.cs"), "\nConsole.WriteLine(\"rebuilt\");\n"); + (await project.Build()).AssertSuccess(); + Assert.Equal(timestamp, File.GetLastWriteTimeUtc(output)); + Assert.Contains("rebuilt", (await project.Run()).Output); - var run = await project.Run(); - run.AssertSuccess(); - Assert.Contains("consumer succeeded", run.Output); - - var timestamp = File.GetLastWriteTimeUtc(outputFile); - var second = await project.Build(); - second.AssertSuccess(); - Assert.Contains("up-to-date", second.Output, StringComparison.OrdinalIgnoreCase); - Assert.Equal(timestamp, File.GetLastWriteTimeUtc(outputFile)); - Assert.Equal(code, await File.ReadAllTextAsync(outputFile, TestContext.Current.CancellationToken)); - - var program = Path.Combine(project.Root, "Program.cs"); - File.AppendAllText(program, "\nConsole.WriteLine(\"rebuilt with cached models\");\n"); - var recompiled = await project.Build(); - recompiled.AssertSuccess(); - Assert.Contains("up-to-date", recompiled.Output, StringComparison.OrdinalIgnoreCase); - Assert.Equal(timestamp, File.GetLastWriteTimeUtc(outputFile)); - var rerun = await project.Run(); - rerun.AssertSuccess(); - Assert.Contains("rebuilt with cached models", rerun.Output); - - File.Delete(outputFile); + File.Delete(output); (await project.Build()).AssertSuccess(); - Assert.True(File.Exists(outputFile)); - Assert.Equal(code, await File.ReadAllTextAsync(outputFile, TestContext.Current.CancellationToken)); + Assert.Equal(code, File.ReadAllText(output)); } [Fact] - public async Task ImportClosureSearchPrecedenceAndOptionsInvalidateGeneration() + public async Task ImportsAreSearchedNextToTheImportingFileFirst() { var project = packages.CreateConsumer(); - project.Write("Schemas/root.bond", """ - import "wrapper.bond" - namespace Contracts - struct Root { 0: Value value; } - """); + project.Write("Schemas/root.bond", "import \"wrapper.bond\"\nnamespace Contracts\nstruct Root { 0: Value value; }"); project.Write("Imports/wrapper.bond", "import \"nested/value.bond\"\nnamespace Contracts"); project.Write("Imports/nested/value.bond", "namespace Contracts using Value = int32;"); project.Configure( - new XElement("Bond", new XAttribute("Include", "Schemas/root.bond")), - new XElement("BondImportDirectory", new XAttribute("Include", "Imports"))); + new XElement("PropertyGroup", new XElement("BondImportDirectories", "Imports")), + new XElement("ItemGroup", new XElement("Bond", new XAttribute("Include", "Schemas/root.bond")))); (await project.Build()).AssertSuccess(); var generated = Assert.Single(project.GeneratedFiles()); Assert.Contains("public int value", File.ReadAllText(generated)); project.Write("Imports/nested/value.bond", "namespace Contracts using Value = int64;"); - var changedImport = await project.Build(); - changedImport.AssertSuccess(); + (await project.Build()).AssertSuccess(); Assert.Contains("public long value", File.ReadAllText(generated)); project.Write("Schemas/wrapper.bond", "namespace Contracts using Value = string;"); (await project.Build()).AssertSuccess(); Assert.Contains("public string value", File.ReadAllText(generated)); - - File.Delete(Path.Combine(project.Root, "Schemas/wrapper.bond")); - (await project.Build()).AssertSuccess(); - Assert.Contains("public long value", File.ReadAllText(generated)); - - project.Configure( - new XElement("Bond", new XAttribute("Include", "Schemas/root.bond"), - new XAttribute("Descriptors", "true")), - new XElement("BondImportDirectory", new XAttribute("Include", "Imports")), - new XElement("BondUsing", new XAttribute("Include", "System.Text"))); - (await project.Build()).AssertSuccess(); - Assert.Contains("class RootSchema", File.ReadAllText(generated)); - Assert.Contains("using System.Text;", File.ReadAllText(generated)); } [Fact] - public async Task EqualBasenamesRemovalAndCleanOnlyAffectOwnedOutputs() + public async Task RemovedSchemasAndCleanDeleteOnlyGeneratedOutputs() { var project = packages.CreateConsumer(); project.Write("One/model.bond", "namespace One struct First { 0: int32 id; }"); project.Write("Two/model.bond", "namespace Two struct Second { 0: int32 id; }"); - project.Configure(new XElement("Bond", new XAttribute("Include", "**/*.bond"))); + project.Write("Three/order;$(v1)@('%').bond", "namespace Three struct Third { 0: int32 id; }"); + project.Configure(new XElement("ItemGroup", new XElement("Bond", new XAttribute("Include", "**/*.bond")))); (await project.Build()).AssertSuccess(); var generated = project.GeneratedFiles(); - Assert.Equal(2, generated.Length); - Assert.All(generated, path => Assert.StartsWith(project.Artifacts, path)); + Assert.Equal(3, generated.Length); - var removedOutput = generated.Single(path => File.ReadAllText(path).Contains("class Second", StringComparison.Ordinal)); - var remainingOutput = generated.Single(path => path != removedOutput); + var removed = generated.Single(path => File.ReadAllText(path).Contains("class Second", StringComparison.Ordinal)); File.Delete(Path.Combine(project.Root, "Two/model.bond")); (await project.Build()).AssertSuccess(); - Assert.False(File.Exists(removedOutput)); - Assert.Equal(remainingOutput, Assert.Single(project.GeneratedFiles())); - - project.Configure(); - (await project.Build()).AssertSuccess(); - Assert.Empty(project.GeneratedFiles()); + Assert.False(File.Exists(removed)); + Assert.Equal(2, project.GeneratedFiles().Length); - project.Configure(new XElement("Bond", new XAttribute("Include", "One/model.bond"))); - (await project.Build()).AssertSuccess(); - var unrelated = Path.Combine(Path.GetDirectoryName(Assert.Single(project.GeneratedFiles()))!, "keep.txt"); - File.WriteAllText(unrelated, "not owned by the generator"); + var unrelated = Path.Combine(Path.GetDirectoryName(removed)!, "keep.txt"); + File.WriteAllText(unrelated, "not generated"); (await project.Command("clean", project.ProjectFile, "-c", "Debug", "--nologo", "--verbosity", "minimal")).AssertSuccess(); Assert.Empty(project.GeneratedFiles()); - Assert.Equal("not owned by the generator", File.ReadAllText(unrelated)); - } - - [Theory] - [InlineData("order%20v1.bond")] - [InlineData("order%3Bv1.bond")] - [InlineData("order%25v1.bond")] - [InlineData("order;v1.bond")] - [InlineData("order$(Name);@('item').bond")] - public async Task GeneratedPathsPreserveLiteralMsBuildCharacters(string filename) - { - var project = packages.CreateConsumer(); - project.Write(filename, "namespace Contracts struct Item { 0: int32 id; }"); - project.Configure(new XElement("Bond", new XAttribute("Include", "*.bond"))); - - (await project.Build()).AssertSuccess(); - var output = Assert.Single(project.GeneratedFiles()); - Assert.Equal(Path.GetFileNameWithoutExtension(filename) + ".g.cs", Path.GetFileName(output)); - - var unchanged = await project.Build(); - unchanged.AssertSuccess(); - Assert.Contains("up-to-date", unchanged.Output, StringComparison.OrdinalIgnoreCase); - - (await project.Command("clean", project.ProjectFile, "-c", "Debug", "--nologo", "--verbosity", "minimal")).AssertSuccess(); - Assert.False(File.Exists(output)); + Assert.True(File.Exists(unrelated)); } [Fact] - public async Task PackagedTaskLoadsInDotNet8SdkHost() + public async Task SchemaErrorsFailTheBuildWithSourceLocations() { var project = packages.CreateConsumer(); - project.Write("global.json", """ - { - "sdk": { - "version": "8.0.100", - "rollForward": "latestFeature", - "allowPrerelease": false - } - } - """); - project.Write("item.bond", "namespace Contracts struct Item { 0: int32 id; }"); - project.Configure(new XElement("Bond", new XAttribute("Include", "item.bond"))); + project.Write("invalid.bond", "namespace Contracts\nstruct Invalid { 0: Missing value; }"); + project.Configure(new XElement("ItemGroup", new XElement("Bond", new XAttribute("Include", "invalid.bond")))); - var sdk = await project.Command("--version"); - sdk.AssertSuccess(); - Assert.StartsWith("8.0.", sdk.Output.Trim()); - - (await project.Build()).AssertSuccess(); - Assert.Contains("public int id", File.ReadAllText(Assert.Single(project.GeneratedFiles()))); + var result = await project.Build(); + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("invalid.bond(2,", result.Output); + Assert.Contains("error BOND1001", result.Output); } [Fact] - public async Task GenerationErrorsPreserveExistingOutputsAndRejectUnownedFiles() + public async Task BuildsThroughSymbolicLinks() { + Assert.SkipWhen(OperatingSystem.IsWindows(), "Creating symbolic links requires elevation on Windows."); var project = packages.CreateConsumer(); project.Write("item.bond", "namespace Contracts struct Item { 0: int32 id; }"); - project.Configure(new XElement("Bond", new XAttribute("Include", "*.bond"))); - (await project.Build()).AssertSuccess(); - var output = Assert.Single(project.GeneratedFiles()); - var original = File.ReadAllText(output); - var generationFiles = project.GenerationFiles(); - Assert.Contains(generationFiles, path => Path.GetFileName(path) == "manifest.json"); - var snapshots = generationFiles.ToDictionary(path => path, - path => (Contents: File.ReadAllBytes(path), Timestamp: File.GetLastWriteTimeUtc(path))); - - project.Write("item.bond", "namespace Contracts struct Item { 0: string id; }"); - project.Write("invalid.bond", "namespace Contracts struct Invalid { 0: Missing value; }"); - - var failure = await project.Build(); - Assert.NotEqual(0, failure.ExitCode); - Assert.Contains("invalid.bond", failure.Output); - Assert.Equal(original, File.ReadAllText(output)); - Assert.Single(project.GeneratedFiles()); - Assert.Equal(generationFiles, project.GenerationFiles()); - foreach (var (path, snapshot) in snapshots) - { - Assert.Equal(snapshot.Contents, File.ReadAllBytes(path)); - Assert.Equal(snapshot.Timestamp, File.GetLastWriteTimeUtc(path)); - } - - File.Delete(Path.Combine(project.Root, "invalid.bond")); - File.WriteAllText(output, "user content"); - - var overwrite = await project.Build(); - Assert.NotEqual(0, overwrite.ExitCode); - Assert.Equal("user content", File.ReadAllText(output)); - } - - [Fact] - public async Task StaleOutputOwnershipErrorsPrecedeSchemaErrorsWithoutChangingGenerationFiles() - { - var project = packages.CreateConsumer(); - project.Write("item.bond", "namespace Contracts struct Item { 0: int32 id; }"); - project.Write("stale.bond", "namespace Contracts struct Stale { 0: int32 id; }"); - project.Configure(new XElement("Bond", new XAttribute("Include", "*.bond"))); - (await project.Build()).AssertSuccess(); - var staleOutput = project.GeneratedFiles() - .Single(path => File.ReadAllText(path).Contains("class Stale", StringComparison.Ordinal)); - - File.Delete(Path.Combine(project.Root, "stale.bond")); - File.WriteAllText(staleOutput, "user content"); - project.Write("item.bond", "namespace Contracts struct Item { 0: Missing id; }"); - var generationFiles = project.GenerationFiles(); - var snapshots = generationFiles.ToDictionary(path => path, - path => (Contents: File.ReadAllBytes(path), Timestamp: File.GetLastWriteTimeUtc(path))); - - var failure = await project.Build(); - - Assert.NotEqual(0, failure.ExitCode); - Assert.Contains("Refusing to overwrite or remove a non-generated file", failure.Output); - Assert.Contains(staleOutput, failure.Output); - Assert.DoesNotContain("not found in symbol table", failure.Output); - Assert.Equal(generationFiles, project.GenerationFiles()); - foreach (var (path, snapshot) in snapshots) - { - Assert.Equal(snapshot.Contents, File.ReadAllBytes(path)); - Assert.Equal(snapshot.Timestamp, File.GetLastWriteTimeUtc(path)); - } - } - - [Theory] - [InlineData("Version", "Invalid manifest identity or version.")] - [InlineData("NullEntries", "Invalid manifest identity or version.")] - [InlineData("NullEntry", "Invalid manifest output entry.")] - [InlineData("Output", "Invalid manifest output entry.")] - [InlineData("DuplicateSource", "Invalid manifest output entry.")] - [InlineData("DuplicateOutput", "Invalid manifest output entry.")] - [InlineData("NullDependencies", "Invalid manifest output entry.")] - [InlineData("NullDependency", "Invalid manifest dependency.")] - [InlineData("DependencyHash", "Invalid manifest dependency.")] - [InlineData("DuplicateDependency", "Invalid manifest dependency.")] - [InlineData("MissingRootHash", "Manifest does not include the root input hash.")] - public async Task InvalidManifestSectionsFailWithoutChangingGenerationFiles(string field, string message) - { - var project = packages.CreateConsumer(); - project.Write("item.bond", "namespace Contracts struct Item { 0: int32 id; }"); - project.Write("other.bond", "namespace Contracts struct Other { 0: int32 id; }"); - project.Configure(new XElement("Bond", new XAttribute("Include", "*.bond"))); - (await project.Build()).AssertSuccess(); - var generationFiles = project.GenerationFiles(); - var manifestPath = generationFiles.Single(path => Path.GetFileName(path) == "manifest.json"); - var manifest = JsonNode.Parse(File.ReadAllText(manifestPath))!; - var entries = manifest["Entries"]!.AsArray(); - var dependencies = entries[0]!["Dependencies"]!.AsArray(); - switch (field) - { - case "Version": - manifest["Version"] = 0; - break; - case "NullEntries": - manifest["Entries"] = null; - break; - case "NullEntry": - entries[0] = null; - break; - case "Output": - entries[0]!["Output"] = "different.g.cs"; - break; - case "DuplicateSource": - entries.Add(entries[0]!.DeepClone()); - break; - case "DuplicateOutput": - entries[1]!["Output"] = entries[0]!["Output"]!.GetValue(); - break; - case "NullDependencies": - entries[0]!["Dependencies"] = null; - break; - case "NullDependency": - dependencies[0] = null; - break; - case "DependencyHash": - dependencies[0]!["Hash"] = "not a hash"; - break; - case "DuplicateDependency": - dependencies.Add(dependencies[0]!.DeepClone()); - break; - case "MissingRootHash": - dependencies[0]!["Hash"] = null; - break; - default: - throw new ArgumentOutOfRangeException(nameof(field)); - } + project.Configure(new XElement("ItemGroup", new XElement("Bond", new XAttribute("Include", "item.bond")))); - File.WriteAllText(manifestPath, manifest.ToJsonString()); - var snapshots = generationFiles.ToDictionary(path => path, - path => (Contents: File.ReadAllBytes(path), Timestamp: File.GetLastWriteTimeUtc(path))); - - var failure = await project.Build(); - - Assert.NotEqual(0, failure.ExitCode); - Assert.Contains(manifestPath, failure.Output); - Assert.Contains($"Bond manifest '{manifestPath}' is invalid. Run dotnet clean and rebuild, " + - $"or remove this manifest and rebuild. {message}", failure.Output); - Assert.Equal(generationFiles, project.GenerationFiles()); - foreach (var (path, snapshot) in snapshots) - { - Assert.Equal(snapshot.Contents, File.ReadAllBytes(path)); - Assert.Equal(snapshot.Timestamp, File.GetLastWriteTimeUtc(path)); - } + var link = project.Root + " link"; + Directory.CreateSymbolicLink(link, project.Root); + (await project.Command("build", Path.Combine(link, "Consumer.csproj"), "--nologo", "--verbosity", "minimal")).AssertSuccess(); + Assert.Single(project.GeneratedFiles()); } [Fact] - public async Task LinkedSchemasKeepImportsRelativeAndValidateItemOptions() + public async Task MultiTargetedProjectsGenerateForEachFramework() { var project = packages.CreateConsumer(); - var linked = Path.Combine(packages.Root, "external schemas", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(linked); - File.WriteAllText(Path.Combine(linked, "root.bond"), - "import \"types.bond\"\nnamespace Linked struct Item { 0: Value value; }"); - File.WriteAllText(Path.Combine(linked, "types.bond"), "namespace Linked using Value = int64;"); - var relative = Path.GetRelativePath(project.Root, Path.Combine(linked, "root.bond")); - project.Configure(new XElement("Bond", new XAttribute("Include", relative), - new XAttribute("Link", "Schemas/root.bond"))); + project.Write("item.bond", "namespace Contracts struct Item { 0: int32 id; 1: map> data; }"); + project.Configure( + new XElement("PropertyGroup", new XElement("TargetFramework", ""), new XElement("TargetFrameworks", "net8.0;netstandard2.1"), + new XElement("ImplicitUsings", "disable"), + new XElement("BondClone", "true"), new XElement("BondEquality", "true"), new XElement("BondToString", "true")), + new XElement("ItemGroup", new XElement("Bond", new XAttribute("Include", "item.bond")))); (await project.Build()).AssertSuccess(); - var output = Assert.Single(project.GeneratedFiles()); - Assert.Contains("public long value", File.ReadAllText(output)); - Assert.Empty(Directory.GetFiles(linked, "*.g.cs", SearchOption.AllDirectories)); - var assets = File.ReadAllText(Path.Combine(project.Artifacts, "obj", "Consumer", "project.assets.json")); - Assert.DoesNotContain("\"BondTools.Models/", assets); - - project.Configure(new XElement("Bond", new XAttribute("Include", relative), - new XAttribute("Clone", "not-a-boolean"))); - var invalid = await project.Build(); - Assert.NotEqual(0, invalid.ExitCode); - Assert.Contains("Clone", invalid.Output); + var outputs = project.GeneratedFiles(); + Assert.Equal(2, outputs.Length); + Assert.Contains(outputs, path => path.Contains("net8.0", StringComparison.Ordinal)); + Assert.Contains(outputs, path => path.Contains("netstandard2.1", StringComparison.Ordinal)); } [Fact] - public async Task DesignTimeAndTargetFrameworkBuildsHaveIndependentOutputs() + public async Task PackagedTaskLoadsInDotNet8SdkHost() { var project = packages.CreateConsumer(); + project.Write("global.json", """{ "sdk": { "version": "8.0.100", "rollForward": "latestFeature" } }"""); project.Write("item.bond", "namespace Contracts struct Item { 0: int32 id; }"); - project.Configure(new XElement("Bond", new XAttribute("Include", "item.bond"))); - (await project.Command("restore", project.ProjectFile, "--nologo", "--verbosity", "minimal")).AssertSuccess(); - (await project.Command("msbuild", project.ProjectFile, "-t:Compile", "-p:DesignTimeBuild=true", - "-p:BuildingProject=false", "-nologo", "-verbosity:minimal")).AssertSuccess(); - Assert.Single(project.GeneratedFiles()); - (await project.Build()).AssertSuccess(); + project.Configure(new XElement("ItemGroup", new XElement("Bond", new XAttribute("Include", "item.bond")))); - var model = XDocument.Load(project.ProjectFile); - model.Root!.Element("PropertyGroup")!.Element("TargetFramework")!.ReplaceWith( - new XElement("TargetFrameworks", "net8.0;netstandard2.1")); - model.Root.Element("PropertyGroup")!.Add(new XElement("LangVersion", "12.0")); - model.Save(project.ProjectFile); + var sdk = await project.Command("--version"); + sdk.AssertSuccess(); + Assert.StartsWith("8.0.", sdk.Output.Trim()); (await project.Build()).AssertSuccess(); - var frameworkOutputs = project.GeneratedFiles().Where(path => path.Contains("debug_", StringComparison.Ordinal)).ToArray(); - Assert.Equal(2, frameworkOutputs.Length); - Assert.Contains(frameworkOutputs, path => path.Contains("net8.0", StringComparison.Ordinal)); - Assert.Contains(frameworkOutputs, path => path.Contains("netstandard2.1", StringComparison.Ordinal)); + Assert.Contains("public int id", File.ReadAllText(Assert.Single(project.GeneratedFiles()))); } } public sealed class MsBuildPackageFixture : IAsyncLifetime { public string Root { get; private set; } = ""; - private string _repository = ""; private string _version = ""; private string _runtimeVersion = ""; public async ValueTask InitializeAsync() { - _repository = TestRepository.Root; - Root = Path.Combine(_repository, "out", "build integration", Guid.NewGuid().ToString("N")); + var repository = TestRepository.Root; + Root = Path.Combine(repository, "out", "build integration", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(Root); - _version = File.ReadAllText(Path.Combine(_repository, "version")).Trim(); - _runtimeVersion = XDocument.Load(Path.Combine(_repository, "Directory.Packages.props")) + _version = File.ReadAllText(Path.Combine(repository, "version")).Trim(); + _runtimeVersion = XDocument.Load(Path.Combine(repository, "Directory.Packages.props")) .Descendants("PackageVersion") .Single(item => (string?)item.Attribute("Include") == "Bond.Runtime.CSharp") .Attribute("Version")!.Value; var configuration = new DirectoryInfo(AppContext.BaseDirectory).Name.StartsWith("release", StringComparison.OrdinalIgnoreCase) ? "Release" : "Debug"; - foreach (var project in new[] { "Bond.Build", "Bond.Models" }) - { - var result = await Execute(_repository, null, "pack", - Path.Combine(_repository, project, project + ".csproj"), "-c", configuration, "--no-build", - "--no-restore", "-o", Path.Combine(Root, "feed"), "--nologo", "--verbosity", "minimal"); - result.AssertSuccess(); - } + var result = await Execute(repository, null, "pack", Path.Combine(repository, "Bond.Build", "Bond.Build.csproj"), + "-c", configuration, "--no-build", "--no-restore", "-o", Path.Combine(Root, "feed"), "--nologo", "--verbosity", "minimal"); + result.AssertSuccess(); } public ValueTask DisposeAsync() @@ -488,7 +212,6 @@ public ValueTask DisposeAsync() } public Consumer CreateConsumer() => new(this, _version, _runtimeVersion); - public string ReadExample(string path) => File.ReadAllText(Path.Combine(_repository, "examples", path)); public sealed class Consumer { @@ -507,8 +230,9 @@ internal Consumer(MsBuildPackageFixture fixture, string version, string runtimeV Root = Path.Combine(fixture.Root, "consumer " + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(Root); + // Relative to the project, so builds through a symbolic link also write through it. new XDocument(new XElement("Project", new XElement("PropertyGroup", - new XElement("ArtifactsPath", Artifacts)))) + new XElement("ArtifactsPath", "$(MSBuildThisFileDirectory)out")))) .Save(Path.Combine(Root, "Directory.Build.props")); Write("Directory.Packages.props", ""); @@ -521,9 +245,7 @@ internal Consumer(MsBuildPackageFixture fixture, string version, string runtimeV new XElement("package", new XAttribute("pattern", "BondTools.*"))), new XElement("packageSource", new XAttribute("key", "nuget.org"), new XElement("package", new XAttribute("pattern", "*")))); - - var nugetConfig = new XDocument(new XElement("configuration", packageSources, sourceMapping)); - nugetConfig.Save(Path.Combine(Root, "NuGet.Config")); + new XDocument(new XElement("configuration", packageSources, sourceMapping)).Save(Path.Combine(Root, "NuGet.Config")); } public void Write(string path, string content) @@ -533,7 +255,7 @@ public void Write(string path, string content) File.WriteAllText(fullPath, content); } - public void Configure(params XElement[] items) + public void Configure(params XElement[] groups) { var properties = new XElement("PropertyGroup", new XElement("TargetFramework", "net8.0"), @@ -546,28 +268,25 @@ public void Configure(params XElement[] items) new XAttribute("PrivateAssets", "all")), new XElement("PackageReference", new XAttribute("Include", "Bond.Runtime.CSharp"), new XAttribute("Version", _runtimeVersion))); - if (items.Any(item => item.Name == "Bond" && - new[] { "Descriptors", "Clone", "Equality", "Debugger", "ToString" }.Any(name => (string?)item.Attribute(name) == "true"))) - { - references.Add(new XElement("PackageReference", new XAttribute("Include", "BondTools.Models"), - new XAttribute("Version", _version))); - } - - new XDocument(new XElement("Project", new XAttribute("Sdk", "Microsoft.NET.Sdk"), - properties, references, new XElement("ItemGroup", items))).Save(ProjectFile); + new XDocument(new XElement("Project", new XAttribute("Sdk", "Microsoft.NET.Sdk"), properties, references, groups)) + .Save(ProjectFile); } - public string[] GeneratedFiles() => - GenerationFiles().Where(path => path.EndsWith(".g.cs", StringComparison.Ordinal)).ToArray(); - - public string[] GenerationFiles() => Directory.Exists(Artifacts) - ? Directory.GetFiles(Artifacts, "*", SearchOption.AllDirectories) - .Where(path => path.Split(Path.DirectorySeparatorChar).Contains("bond")).OrderBy(path => path).ToArray() + public string[] GeneratedFiles() => Directory.Exists(Artifacts) + ? Directory.GetFiles(Artifacts, "*.g.cs", SearchOption.AllDirectories) + .Where(path => path.Split(Path.DirectorySeparatorChar).Contains("bond")).Order().ToArray() : []; public string Binary(string filename) => Path.Combine(Artifacts, "bin", "Consumer", "debug", filename); public Task Build() => Command("build", ProjectFile, "-c", "Debug", "--nologo", "--verbosity", "minimal"); - public Task Run() => Command(Binary("Consumer.dll")); + + public async Task Run() + { + var result = await Command(Binary("Consumer.dll")); + result.AssertSuccess(); + return result; + } + public Task Command(params string[] args) => Execute(Root, Path.Combine(_fixture.Root, "packages"), args); } diff --git a/tests/Bond.Models.Tests/Bond.Models.Tests.csproj b/tests/Bond.Models.Tests/Bond.Models.Tests.csproj deleted file mode 100644 index 7bd78a4..0000000 --- a/tests/Bond.Models.Tests/Bond.Models.Tests.csproj +++ /dev/null @@ -1,35 +0,0 @@ - - - - net8.0 - Exe - true - $(TestingPlatformCommandLineArguments) --coverlet-file-prefix $(MSBuildProjectName) - enable - false - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - diff --git a/tests/Bond.Models.Tests/GeneratedBondedAdapterTests.cs b/tests/Bond.Models.Tests/GeneratedBondedAdapterTests.cs deleted file mode 100644 index ebbd6b9..0000000 --- a/tests/Bond.Models.Tests/GeneratedBondedAdapterTests.cs +++ /dev/null @@ -1,232 +0,0 @@ -using System.Linq; -using System.Threading.Tasks; -using Bond.Parser.CodeGeneration; -using Bond.Parser.Parser; - -namespace Bond.Models.Tests; - -public sealed class GeneratedBondedAdapterTests -{ - [Theory] - [InlineData(CSharpModelFeatures.Cloning)] - [InlineData(CSharpModelFeatures.All)] - public async Task MaterializedBondedPayloadsRetainContainerAdaptersForIndependentDeserializations(CSharpModelFeatures features) - { - await Check(""" - namespace Models - struct Box { 0: T value; } - struct Holder { - 0: bonded>> payload; - 1: bonded>>> nested; - 2: bonded>> linked; - 3: bonded>> flags; - } - """, """ - var original = new Models.Box> { value = new List { 7, 9 } }; - var source = new Models.Holder - { - payload = new Bond.Bonded>>(original), - nested = new Bond.Bonded>>>( - new Models.Box>> - { - value = new Dictionary> { ["data"] = original.value } - }), - linked = new Bond.Bonded>>( - new Models.Box> { value = new LinkedList(original.value) }), - flags = new Bond.Bonded>>( - new Models.Box> { value = new HashSet(original.value) }) - }; - Require(source.payload.Deserialize().value.SequenceEqual(new[] { 7, 9 }), "original payload failed"); - var copy = source.Clone(); - var first = copy.payload.Deserialize(); - var second = ((Bond.IBonded)copy.payload).Deserialize>>(); - var captured = ((BondTools.Models.IMaterializedModelValue>>)copy.payload).Value; - Require(first.value.SequenceEqual(new[] { 7, 9 }), "generic vector payload changed"); - Require(!ReferenceEquals(first, second) && !ReferenceEquals(first.value, second.value), - "repeated deserialization reused a clone context"); - Require(!ReferenceEquals(first, captured) && !ReferenceEquals(first.value, captured.value), - "deserialization exposed the captured value"); - first.value[0] = 100; - original.value[1] = 200; - Require(second.value.SequenceEqual(new[] { 7, 9 }), "deserialized values share mutable storage"); - Require(copy.payload.Deserialize().value.SequenceEqual(new[] { 7, 9 }), "captured payload was mutated"); - Require(copy.Clone().payload.Deserialize().value.SequenceEqual(new[] { 7, 9 }), - "cloning an already materialized wrapper lost its adapter"); - var nested = copy.nested.Deserialize(); - Require(nested.value["data"].SequenceEqual(new[] { 7, 9 }), "nested map/vector adapters were lost"); - nested.value["data"].Clear(); - Require(copy.nested.Deserialize().value["data"].Count == 2, "nested mutable storage was shared"); - Require(copy.linked.Deserialize().value.SequenceEqual(new[] { 7, 9 }), "linked list adapter was lost"); - Require(copy.flags.Deserialize().value.SetEquals(new[] { 7, 9 }), "set adapter was lost"); - """, features); - } - - [Fact] - public async Task NestedMaterializedBondedPayloadsRetainTheirOwnAdapters() - { - await Check(""" - namespace Models - struct Box { 0: T value; } - struct Holder { 0: bonded>>>> payload; } - """, """ - var inner = new Models.Box> { value = new List { 7, 9 } }; - var outer = new Models.Box>>> - { - value = new Bond.Bonded>>(inner) - }; - var source = new Models.Holder - { - payload = new Bond.Bonded>>>>(outer) - }; - var copy = source.Clone(); - var first = copy.payload.Deserialize(); - var second = copy.payload.Deserialize(); - Require(!ReferenceEquals(first.value, second.value), "nested wrappers were shared across deserializations"); - var firstInner = first.value.Deserialize(); - var secondInner = second.value.Deserialize(); - Require(firstInner.value.SequenceEqual(new[] { 7, 9 }), "nested bonded adapter was lost"); - firstInner.value.Clear(); - Require(secondInner.value.SequenceEqual(new[] { 7, 9 }), "nested deserializations shared storage"); - Require(copy.Clone().payload.Deserialize().value.Deserialize().value.SequenceEqual(new[] { 7, 9 }), - "nested materialized wrappers could not be cloned again"); - """, CSharpModelFeatures.Cloning); - } - - [Theory] - [InlineData(CSharpModelFeatures.Cloning)] - [InlineData(CSharpModelFeatures.All)] - public async Task OpenGenericBondedPayloadsRetainAmbientAdaptersWithoutSharingCloneContexts(CSharpModelFeatures features) - { - await Check(""" - namespace Models - struct Box { 0: T value; } - struct Holder { 0: bonded> payload; } - struct Root { - 0: Holder> holder; - 1: Holder>> nested; - 2: bonded>> outer; - } - """, """ - var numbers = new List { 7, 9 }; - var holder = new Models.Holder> - { - payload = new Bond.Bonded>>( - new Models.Box> { value = numbers }) - }; - var source = new Models.Root - { - holder = holder, - nested = new Models.Holder>> - { - payload = new Bond.Bonded>>>( - new Models.Box>> - { - value = new Dictionary> { ["data"] = numbers } - }) - }, - outer = new Bond.Bonded>>(holder) - }; - Require(source.holder.payload.Deserialize().value.SequenceEqual(new[] { 7, 9 }), - "original open-generic payload failed"); - var copy = source.Clone(); - var first = copy.holder.payload.Deserialize(); - var second = ((Bond.IBonded)copy.holder.payload).Deserialize>>(); - var captured = ((BondTools.Models.IMaterializedModelValue>>)copy.holder.payload).Value; - Require(first.value.SequenceEqual(new[] { 7, 9 }), "ambient vector adapter was lost"); - Require(!ReferenceEquals(first, second) && !ReferenceEquals(first.value, second.value), - "open-generic deserializations reused graph clones"); - Require(!ReferenceEquals(first, captured) && !ReferenceEquals(first.value, captured.value), - "open-generic deserialization reused the captured graph"); - first.value[0] = 100; - numbers[1] = 200; - Require(second.value.SequenceEqual(new[] { 7, 9 }), "open-generic deserializations shared mutable storage"); - Require(copy.holder.payload.Deserialize().value.SequenceEqual(new[] { 7, 9 }), - "captured open-generic payload was mutated"); - - var repeated = copy.Clone(); - Require(!ReferenceEquals(copy.holder.payload, repeated.holder.payload), - "cloning a materialized open-generic wrapper reused the wrapper"); - Require(repeated.holder.payload.Deserialize().value.SequenceEqual(new[] { 7, 9 }), - "re-cloning a materialized open-generic wrapper lost its bindings"); - var nested = copy.nested.payload.Deserialize(); - Require(nested.value["data"].SequenceEqual(new[] { 7, 9 }), "ambient map/vector adapter was lost"); - nested.value["data"].Clear(); - Require(copy.nested.payload.Deserialize().value["data"].Count == 2, - "ambient nested containers reused graph clones"); - var outer = copy.outer.Deserialize(); - var otherOuter = copy.outer.Deserialize(); - Require(!ReferenceEquals(outer.payload, otherOuter.payload), - "open-generic nested bonded wrappers reused graph clones"); - Require(outer.payload.Deserialize().value.SequenceEqual(new[] { 7, 9 }), - "nested open-generic bonded payload lost its bindings"); - Require(repeated.outer.Deserialize().payload.Deserialize().value.SequenceEqual(new[] { 7, 9 }), - "re-cloning nested open-generic bonded wrappers lost their bindings"); - """, features); - } - - [Fact] - public async Task OpenGenericMaterializedBondedCyclesRetainBindingsAndFreshGraphContexts() - { - await Check(""" - namespace Models - struct Box { 0: T value; 1: bonded> next; } - struct Holder { 0: bonded> payload; } - struct Root { 0: Holder> holder; } - """, """ - var payload = new Models.Box> { value = new List { 7, 9 } }; - var deferred = new DirectBonded>>(payload); - payload.next = deferred; - var source = new Models.Root - { - holder = new Models.Holder> { payload = deferred } - }; - var copy = source.Clone(); - var captured = ((BondTools.Models.IMaterializedModelValue>>)copy.holder.payload).Value; - Require(ReferenceEquals(captured.next, copy.holder.payload), "materialized bonded cycle was broken"); - var first = copy.holder.payload.Deserialize(); - var second = copy.holder.payload.Deserialize(); - Require(first.value.SequenceEqual(new[] { 7, 9 }), "cyclic payload lost ambient container bindings"); - Require(ReferenceEquals(first, - ((BondTools.Models.IMaterializedModelValue>>)first.next).Value), - "deserialization did not preserve the bonded cycle"); - Require(!ReferenceEquals(first, second) && !ReferenceEquals(first.next, second.next) - && !ReferenceEquals(first.value, second.value), "cyclic deserialization reused graph clones"); - first.value.Clear(); - Require(second.value.SequenceEqual(new[] { 7, 9 }), "cyclic deserializations shared mutable storage"); - var repeated = copy.Clone().holder.payload.Deserialize(); - Require(repeated.value.SequenceEqual(new[] { 7, 9 }), "re-cloning cyclic wrappers lost captured bindings"); - Require(ReferenceEquals(repeated, - ((BondTools.Models.IMaterializedModelValue>>)repeated.next).Value), - "re-cloning materialized wrappers broke their cycle"); - Require(deferred.Reads == 1, "materialized cycles re-read the original bonded wrapper"); - """, CSharpModelFeatures.Cloning, """ - public sealed class DirectBonded : Bond.IBonded - { - private readonly T _value; - public int Reads { get; private set; } - - public DirectBonded(T value) { _value = value; } - - public T Deserialize() - { - Reads++; - return _value; - } - - public U Deserialize() => throw new System.NotSupportedException(); - public Bond.IBonded Convert() => throw new System.NotSupportedException(); - public void Serialize(W writer) => throw new System.NotSupportedException(); - } - """); - } - - private static async Task Check(string schema, string body, CSharpModelFeatures features, string extraSource = "") - { - var parsed = await ParserFacade.ParseStringAsync(schema); - Assert.True(parsed.Success, string.Join("\n", parsed.Errors.Select(error => error.Message))); - var generated = CSharpGenerator.Generate(parsed.Ast!, "input.bond", - new CSharpGenerationOptions { ModelFeatures = features }); - Assert.True(generated.Success, string.Join("\n", generated.Errors.Select(error => error.Message))); - GeneratedScenario.Run(generated.Code!, body, extraSource); - } -} diff --git a/tests/Bond.Models.Tests/GeneratedMetadataTests.cs b/tests/Bond.Models.Tests/GeneratedMetadataTests.cs deleted file mode 100644 index 7564bca..0000000 --- a/tests/Bond.Models.Tests/GeneratedMetadataTests.cs +++ /dev/null @@ -1,718 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Numerics; -using System.Reflection; -using System.Threading.Tasks; -using Bond.Parser.CodeGeneration; -using Bond.Parser.Parser; -using Bond.TestSupport; -using BondTools.Models; - -namespace Bond.Models.Tests; - -using SchemaAttribute = global::BondTools.Models.SchemaAttribute; - -public sealed class GeneratedMetadataTests -{ - [Fact] - public async Task DescriptorsAreOptInAndDoNotEnableOtherModelFeatures() - { - var parsed = await ParserFacade.ParseStringAsync("namespace Example struct Item { 0: int32 value; }"); - Assert.True(parsed.Success); - - var plain = CSharpGenerator.Generate(parsed.Ast!, "input.bond"); - Assert.True(plain.Success); - Assert.DoesNotContain("BondTools.Models", plain.Code!); - Assert.DoesNotContain("__BondSchemaCatalog", plain.Code!); - - var plainAssembly = GeneratedCode.Compile(plain.Code!); - Assert.Null(plainAssembly.GetType("Example.ItemSchema")); - - var generated = CSharpGenerator.Generate(parsed.Ast!, "input.bond", new CSharpGenerationOptions - { - ModelFeatures = CSharpModelFeatures.Descriptors - }); - Assert.True(generated.Success, string.Join("\n", generated.Errors.Select(error => error.Message))); - Assert.DoesNotContain("IEquatable<", generated.Code!); - Assert.DoesNotContain("DebuggerTypeProxy", generated.Code!); - - var assembly = GeneratedCode.Compile(generated.Code!); - var model = assembly.GetType("Example.Item", throwOnError: true)!; - Assert.Equal(new[] { typeof(IGeneratedSchemaProvider) }, model.GetInterfaces()); - Assert.Null(model.GetMethod("Clone")); - Assert.Equal("Example.Item", Read(assembly, "Example.ItemSchema").FullName); - } - - [Fact] - public async Task DescriptorsPreserveDeclaredIdentityAttributesFieldsAndDefaults() - { - var code = await GeneratedCode.Generate(""" - namespace Contracts - namespace csharp Application.Models - [doc.category("enum")] - enum State { Unknown = -2, Ready, AlsoReady = -1, Last } - [doc.category("model")] - [doc.category("second")] - struct Record { - [doc.unit("items")] 9: required uint64 count = 18446744073709551615; - 3: required_optional State state = Ready; - 1: wstring title = "wide \u03bb"; - 4: bool enabled = true; - 5: double signed_zero = -0.0; - 6: int64 minimum = -9223372036854775808; - 7: string inferred; - } - """); - var assembly = GeneratedCode.Compile(code); - var descriptor = Read(assembly, "Application.Models.RecordSchema"); - - Assert.Equal("Record", descriptor.Name); - Assert.Equal("Contracts", descriptor.Namespace); - Assert.Equal("Contracts.Record", descriptor.FullName); - Assert.Equal("global::Application.Models.Record", descriptor.ClrName); - Assert.Equal(SchemaKind.Struct, descriptor.Kind); - Assert.Equal(new[] { new SchemaAttribute("doc.category", "model"), new SchemaAttribute("doc.category", "second") }, - descriptor.Attributes); - Assert.Equal(new ushort[] { 1, 3, 4, 5, 6, 7, 9 }, descriptor.Fields.Select(field => field.Id)); - - var title = descriptor.Fields[0]; - Assert.Equal("title", title.Name); - Assert.Equal(SchemaTypeKind.WString, title.Type.Kind); - Assert.Equal("wide \u03bb", Assert.IsType(title.DefaultValue).Value); - - var state = descriptor.Fields[1]; - Assert.Equal(SchemaFieldModifier.RequiredOptional, state.Modifier); - Assert.Equal("Ready", Assert.IsType(state.DefaultValue).Name); - - Assert.True(Assert.IsType(descriptor.Fields[2].DefaultValue).Value); - Assert.Equal(long.MinValue, BitConverter.DoubleToInt64Bits( - Assert.IsType(descriptor.Fields[3].DefaultValue).Value)); - Assert.Equal(new BigInteger(long.MinValue), Assert.IsType(descriptor.Fields[4].DefaultValue).Value); - Assert.Null(descriptor.Fields[5].DefaultValue); - - var count = descriptor.Fields[6]; - Assert.Equal(SchemaFieldModifier.Required, count.Modifier); - Assert.Equal(new BigInteger(ulong.MaxValue), Assert.IsType(count.DefaultValue).Value); - Assert.Equal(new SchemaAttribute("doc.unit", "items"), Assert.Single(count.Attributes)); - - var enumeration = Assert.IsType(state.Type).Declaration; - Assert.Same(Read(assembly, "Application.Models.StateSchema"), enumeration); - Assert.Equal("Contracts.State", enumeration.FullName); - } - - [Fact] - public async Task EnumValuesRetainEffectiveAndDeclaredNumbersWithoutLoadingTheEnum() - { - var code = await GeneratedCode.Generate(""" - namespace Example - [kind("state")] - enum State { Unknown = -2, Ready, Duplicate = -1, Last, Bits = 0xffffffff } - """); - var assembly = GeneratedCode.Compile(code); - var descriptor = Read(assembly, "Example.StateSchema"); - - Assert.Equal(SchemaKind.Enum, descriptor.Kind); - Assert.Equal(new[] - { - new EnumValueDescriptor("Unknown", -2, -2), - new EnumValueDescriptor("Ready", -1), - new EnumValueDescriptor("Duplicate", -1, -1), - new EnumValueDescriptor("Last", 0), - new EnumValueDescriptor("Bits", -1, 4294967295) - }, descriptor.EnumValues); - Assert.Equal(new SchemaAttribute("kind", "state"), Assert.Single(descriptor.Attributes)); - Assert.Empty(descriptor.Fields); - } - - [Fact] - public async Task EverySupportedTypeRemainsSymbolicRatherThanCollapsingToItsClrType() - { - var code = await GeneratedCode.Generate(""" - namespace Example - struct Child {} - struct Types { - 0: int8 a; - 1: int16 b; - 2: int32 c; - 3: int64 d; - 4: uint8 e; - 5: uint16 f; - 6: uint32 g; - 7: uint64 h; - 8: float i; - 9: double j; - 10: bool k; - 11: string l; - 12: wstring m; - 13: blob n; - 14: bond_meta::name name; - 15: bond_meta::full_name full_name; - 16: list list_values; - 17: vector vector_values; - 18: set set_values; - 19: map> map_values; - 20: nullable nullable_value; - 21: int32 absent = nothing; - 22: bonded deferred; - } - """); - var assembly = GeneratedCode.Compile(code); - var descriptor = Read(assembly, "Example.TypesSchema"); - - Assert.Equal(new[] - { - SchemaTypeKind.Int8, SchemaTypeKind.Int16, SchemaTypeKind.Int32, SchemaTypeKind.Int64, - SchemaTypeKind.UInt8, SchemaTypeKind.UInt16, SchemaTypeKind.UInt32, SchemaTypeKind.UInt64, - SchemaTypeKind.Float, SchemaTypeKind.Double, SchemaTypeKind.Bool, SchemaTypeKind.String, - SchemaTypeKind.WString, SchemaTypeKind.Blob, SchemaTypeKind.MetaName, SchemaTypeKind.MetaFullName, - SchemaTypeKind.List, SchemaTypeKind.Vector, SchemaTypeKind.Set, SchemaTypeKind.Map, - SchemaTypeKind.Nullable, SchemaTypeKind.Maybe, SchemaTypeKind.Bonded - }, descriptor.Fields.Select(field => field.Type.Kind)); - - var map = Assert.IsType(descriptor.Fields[19].Type); - Assert.Equal(SchemaTypeKind.WString, map.KeyType.Kind); - Assert.Equal(SchemaTypeKind.Int32, Assert.IsType(map.ValueType).ElementType.Kind); - Assert.Null(descriptor.Fields[20].DefaultValue); - Assert.Equal(SchemaFieldModifier.Optional, descriptor.Fields[14].Modifier); - Assert.Equal(SchemaFieldModifier.RequiredOptional, descriptor.Fields[14].EffectiveModifier); - Assert.Equal("map>", descriptor.Fields[19].Type.ToString()); - Assert.Same(SchemaDefault.Nothing.Instance, descriptor.Fields[21].DefaultValue); - - var bonded = Assert.IsType(descriptor.Fields[22].Type); - Assert.Equal("Example.Child", Assert.IsType(bonded.ElementType).Declaration.FullName); - } - - [Fact] - public async Task InheritanceAndViewsRetainOwnFieldsAndSourceSelections() - { - var code = await GeneratedCode.Generate(""" - namespace Example - struct Base { 0: T inherited; } - [source("not copied")] - struct Record : Base { - 11: string omitted; - [field("copied")] 5: required_optional int32 number = 42; - 2: T item = nothing; - } - [view("own")] - struct Selected view_of Record { number; item; number; missing; inherited } - """); - var assembly = GeneratedCode.Compile(code); - var descriptor = Read(assembly, "Example.SelectedSchema"); - - Assert.True(descriptor.IsView); - Assert.Equal(new[] { "Record" }, descriptor.ViewTarget); - Assert.Equal(new[] { "number", "item", "number", "missing", "inherited" }, descriptor.ViewFields); - Assert.Equal(new[] { "item", "number" }, descriptor.Fields.Select(field => field.Name)); - Assert.Equal(new SchemaAttribute("view", "own"), Assert.Single(descriptor.Attributes)); - Assert.Equal(new SchemaAttribute("field", "copied"), Assert.Single(descriptor.Fields[1].Attributes)); - Assert.Equal(SchemaTypeConstraint.Value, Assert.Single(descriptor.TypeParameters).Constraint); - - var bound = descriptor.Bind(new PrimitiveSchemaType(SchemaTypeKind.Int32)); - Assert.Equal(SchemaTypeKind.Int32, Assert.IsType(bound.Fields[0].Type).ElementType.Kind); - - var parent = Assert.IsType(bound.BaseType).Resolve(); - Assert.Equal("Example.Base", parent.FullName); - Assert.Equal(SchemaTypeKind.Int32, Assert.Single(parent.Fields).Type.Kind); - Assert.Same(Read(assembly, "Example.BaseSchema"), parent.Definition); - Assert.Same(descriptor, bound.Definition); - Assert.IsType(Assert.IsType(descriptor.Fields[0].Type).ElementType); - } - - [Fact] - public async Task GenericAliasesBindSimultaneouslyAndPreserveSignedErasedArguments() - { - var code = await GeneratedCode.Generate(""" - namespace Example - using Pair = map>; - using Swap = Pair; - using Erased = T; - struct Box { - 0: Swap swapped; - 1: Erased fixed_number; - } - """); - var assembly = GeneratedCode.Compile(code); - var descriptor = Read(assembly, "Example.BoxSchema"); - var bound = descriptor.Bind(new PrimitiveSchemaType(SchemaTypeKind.Int32), - new PrimitiveSchemaType(SchemaTypeKind.String)); - - var swap = Assert.IsType(bound.Fields[0].Type); - Assert.Equal(SchemaTypeKind.Alias, swap.Kind); - Assert.Same(Assert.IsType(descriptor.Fields[0].Type).Declaration, swap.Declaration); - Assert.Equal("Example.Swap", swap.Declaration.FullName); - Assert.Null(assembly.GetType("Example.SwapSchema")); - - var pair = Assert.IsType(swap.Resolve().AliasedType); - Assert.Equal(new[] { SchemaTypeKind.String, SchemaTypeKind.Int32 }, pair.TypeArguments.Select(type => type.Kind)); - var map = Assert.IsType(pair.Resolve().AliasedType); - Assert.Equal(SchemaTypeKind.String, map.KeyType.Kind); - Assert.Equal(SchemaTypeKind.Int32, Assert.IsType(map.ValueType).ElementType.Kind); - - var erased = Assert.IsType(bound.Fields[1].Type); - Assert.Equal(-32, Assert.IsType(erased.TypeArguments[1]).Value); - Assert.Equal(SchemaTypeKind.Int32, erased.Resolve().AliasedType!.Kind); - - Assert.Throws(() => descriptor.Bind(new PrimitiveSchemaType(SchemaTypeKind.Int32))); - Assert.Throws(() => descriptor.Bind(new SchemaType[] { null!, null! })); - Assert.Throws(() => descriptor.Bind(null!)); - Assert.IsType(Assert.IsType(descriptor.Fields[0].Type).TypeArguments[0]); - } - - [Fact] - public async Task RecursiveDescriptorsDoNotInitializeOrConstructModels() - { - var code = await GeneratedCode.Generate(""" - namespace Example - struct Node { - 0: Node eager; - 1: vector> children; - } - struct Other { 0: nullable> parent; } - """); - var assembly = GeneratedCode.Compile(code, """ - namespace Example - { - public partial class Node - { - private readonly int constructorTrap = Fail(); - private static int Fail() => throw new System.InvalidOperationException("Constructor must not run."); - static Node() => throw new System.InvalidOperationException("Type initializer must not run."); - } - } - """); - var descriptor = Read(assembly, "Example.NodeSchema"); - var self = Assert.IsType(descriptor.Fields[0].Type); - Assert.Same(descriptor, self.Declaration); - - var bound = descriptor.Bind(new PrimitiveSchemaType(SchemaTypeKind.Int32)); - var other = Assert.IsType(Assert.IsType(bound.Fields[1].Type).ElementType).Resolve(); - var parent = Assert.IsType(Assert.IsType(other.Fields[0].Type).ElementType); - Assert.Same(descriptor, parent.Declaration); - Assert.Equal(SchemaTypeKind.Int32, Assert.Single(parent.Resolve().TypeArguments).Kind); - Assert.Same(parent.Resolve(), parent.Resolve()); - - Assert.DoesNotContain("RuntimeSchema", code); - Assert.DoesNotContain("System.Reflection", code); - Assert.DoesNotContain("System.Linq.Expressions", code); - } - - [Fact] - public async Task UnresolvedForwardMetadataIsExplicitlyUnknownAndNeverDiscoversClrFields() - { - var code = await GeneratedCode.Generate(""" - namespace Example - struct External; - struct Node; - struct Empty {} - struct Holder { - 0: nullable> external; - 1: nullable> node; - 2: nullable empty; - } - struct Node { 0: T value; } - """); - var assembly = GeneratedCode.Compile(code, """ - namespace Example - { - public class External - { - public int OnlyKnownToClr { get; set; } - public External() => throw new System.InvalidOperationException("Constructor must not run."); - static External() => throw new System.InvalidOperationException("Type initializer must not run."); - } - } - """); - var holder = Read(assembly, "Example.HolderSchema"); - var external = Assert.IsType(Assert.IsType(holder.Fields[0].Type).ElementType); - Assert.Equal(SchemaTypeKind.Struct, external.Kind); - Assert.Equal("Example.External", external.FullName); - Assert.True(external.Declaration.IsForward); - Assert.Equal("global::Example.External", external.Declaration.ClrName); - Assert.Empty(external.Declaration.Fields); - Assert.Null(external.Declaration.BaseType); - - var bound = external.Resolve(); - Assert.True(bound.IsForward); - Assert.Same(external.Declaration, bound.Definition); - Assert.Equal(SchemaTypeKind.Int32, Assert.Single(bound.TypeArguments).Kind); - Assert.True(bound.AsType().Resolve().IsForward); - Assert.Null(assembly.GetType("Example.ExternalSchema")); - - var node = Assert.IsType(Assert.IsType(holder.Fields[1].Type).ElementType).Resolve(); - Assert.False(node.IsForward); - Assert.Same(Read(assembly, "Example.NodeSchema"), node.Definition); - Assert.Equal(SchemaTypeKind.Int32, Assert.Single(node.Fields).Type.Kind); - - var empty = Assert.IsType(Assert.IsType(holder.Fields[2].Type).ElementType).Declaration; - Assert.False(empty.IsForward); - Assert.Empty(empty.Fields); - } - - [Fact] - public async Task MappedAliasesDoNotRequireClrTypesForErasedIntegerArguments() - { - var code = await GenerateWithImports(""" - namespace Example - using Identity = T; - using Symbolic = Identity<-32>; - using Erased = int32; - using Values = vector; - struct Holder { - 0: Erased scalar; - 1: Values values; - } - """, new Dictionary(), new CSharpGenerationOptions - { - ModelFeatures = CSharpModelFeatures.Descriptors, - TypeMappings = ["Example.Erased=int", "Example.Values=System.Collections.Generic.List<{0}>"] - }); - var holder = Read(GeneratedCode.Compile(code), "Example.HolderSchema"); - var erased = Assert.IsType(holder.Fields[0].Type); - Assert.Equal("int", erased.Declaration.ClrName); - - var symbolic = Assert.IsType(Assert.Single(erased.TypeArguments)).Declaration; - Assert.Equal("Example.Symbolic", symbolic.FullName); - Assert.Null(symbolic.ClrName); - - var identity = Assert.IsType(symbolic.AliasedType); - Assert.Equal(-32, Assert.IsType(identity.Resolve().AliasedType).Value); - - var values = Assert.IsType(holder.Fields[1].Type); - Assert.Equal("global::System.Collections.Generic.List", values.Declaration.ClrName); - Assert.Equal(-64, Assert.IsType(values.TypeArguments[1]).Value); - Assert.Equal(SchemaTypeKind.Int32, Assert.IsType(values.Resolve().AliasedType).ElementType.Kind); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task ImportedMetadataUsesOneLocalCatalogWithRichOrPlainImportedModels(bool richImports) - { - const string imported = """ - namespace Library - using Text = wstring; - enum State { Ready } - struct Base { 0: T value; } - struct Node { 0: vector children; 1: Text title; } - """; - var root = await GenerateWithImports(""" - import "library.bond" - namespace Application - struct Root : Library.Base { - 0: Library.Node node; - 1: Library.Text title; - } - """, new Dictionary { ["library.bond"] = imported }); - Assert.DoesNotContain("class NodeSchema", root); - Assert.DoesNotContain("class BaseSchema", root); - Assert.DoesNotContain("class TextSchema", root); - Assert.DoesNotContain("Library.NodeSchema", root); - Assert.DoesNotContain("Library.BaseSchema", root); - Assert.DoesNotContain("Library.TextSchema", root); - - var importedCode = await GenerateWithImports(imported, new Dictionary(), - new CSharpGenerationOptions - { - ModelFeatures = richImports ? CSharpModelFeatures.Descriptors : CSharpModelFeatures.None - }); - var assembly = GeneratedCode.Compile(root, importedCode); - var descriptor = Read(assembly, "Application.RootSchema"); - var node = Assert.IsType(descriptor.Fields[0].Type).Declaration; - Assert.Equal("Library.Node", node.FullName); - Assert.Equal(new[] { "children", "title" }, node.Fields.Select(field => field.Name)); - Assert.Same(node, Assert.IsType(Assert.IsType(node.Fields[0].Type).ElementType).Declaration); - - var title = Assert.IsType(descriptor.Fields[1].Type).Declaration; - Assert.Same(Assert.IsType(node.Fields[1].Type).Declaration, title); - Assert.Equal(SchemaKind.Alias, title.Kind); - Assert.Equal(SchemaTypeKind.WString, title.AliasedType!.Kind); - Assert.Equal("string", title.ClrName); - - var parent = Assert.IsType(descriptor.BaseType).Resolve(); - Assert.Equal("Library.State", Assert.IsType(Assert.Single(parent.Fields).Type).Declaration.FullName); - Assert.Null(assembly.GetType("Library.TextSchema")); - - if (richImports) - { - var independent = Read(assembly, "Library.NodeSchema"); - Assert.NotSame(independent, node); - Assert.Equal(independent.FullName, node.FullName); - Assert.Equal(independent.ClrName, node.ClrName); - Assert.Equal(independent.Fields.Select(field => (field.Id, field.Name, field.Type.Kind)), - node.Fields.Select(field => (field.Id, field.Name, field.Type.Kind))); - } - else - { - Assert.Null(assembly.GetType("Library.NodeSchema")); - Assert.Null(assembly.GetType("Library.BaseSchema")); - } - } - - [Fact] - public async Task AliasShadowingKeepsIndependentFileLocalDeclarationIdentity() - { - const string imported = """ - namespace Example - using Value = int32; - struct Imported { 0: Value value; } - """; - var root = await GenerateWithImports(""" - import "library.bond" - namespace Example - using Value = string; - struct Root { 0: Value value; 1: Imported other; 2: Value again; } - """, new Dictionary { ["library.bond"] = imported }); - var assembly = GeneratedCode.Compile(root, await GeneratedCode.Generate(imported)); - var descriptor = Read(assembly, "Example.RootSchema"); - var local = Assert.IsType(descriptor.Fields[0].Type).Declaration; - var importedModel = Assert.IsType(descriptor.Fields[1].Type).Declaration; - var importedAlias = Assert.IsType(importedModel.Fields[0].Type).Declaration; - - Assert.Equal(local.FullName, importedAlias.FullName); - Assert.NotSame(local, importedAlias); - Assert.Equal(SchemaTypeKind.String, local.AliasedType!.Kind); - Assert.Equal(SchemaTypeKind.Int32, importedAlias.AliasedType!.Kind); - Assert.Same(local, Assert.IsType(descriptor.Fields[2].Type).Declaration); - Assert.Null(assembly.GetType("Example.ValueSchema")); - } - - [Fact] - public async Task IdenticalAliasesFromSeparateImportsStillHaveDistinctIdentity() - { - const string left = """ - namespace Shared - using Value = int32; - struct Left { 0: Value value; } - """; - const string right = """ - namespace Shared - using Value = int32; - struct Right { 0: Value value; } - """; - var root = await GenerateWithImports(""" - import "left.bond" - import "right.bond" - namespace Application - struct Root { 0: Shared.Left left; 1: Shared.Right right; } - """, new Dictionary { ["left.bond"] = left, ["right.bond"] = right }); - var assembly = GeneratedCode.Compile(root, - await GeneratedCode.Generate(left), await GeneratedCode.Generate(right)); - var descriptor = Read(assembly, "Application.RootSchema"); - var leftModel = Assert.IsType(descriptor.Fields[0].Type).Declaration; - var rightModel = Assert.IsType(descriptor.Fields[1].Type).Declaration; - var leftAlias = Assert.IsType(leftModel.Fields[0].Type).Declaration; - var rightAlias = Assert.IsType(rightModel.Fields[0].Type).Declaration; - - Assert.Equal(leftAlias.FullName, rightAlias.FullName); - Assert.Equal(leftAlias.AliasedType!.Kind, rightAlias.AliasedType!.Kind); - Assert.NotSame(leftAlias, rightAlias); - Assert.Null(assembly.GetType("Shared.ValueSchema")); - } - - [Fact] - public async Task DescriptorCompanionsDoNotCollideWithSourceModelNames() - { - var code = await GeneratedCode.Generate(""" - namespace Example - struct Item { 0: ItemSchema value; 1: Text text; } - struct ItemSchema {} - struct ItemSchema_ {} - enum State { Ready } - struct StateSchema {} - using Text = string; - struct TextSchema {} - """); - var assembly = GeneratedCode.Compile(code); - - Assert.Equal("Item", Read(assembly, "Example.ItemSchema__").Name); - Assert.Equal("ItemSchema", Read(assembly, "Example.ItemSchemaSchema").Name); - Assert.Equal("State", Read(assembly, "Example.StateSchema_").Name); - - var item = Read(assembly, "Example.ItemSchema__"); - Assert.Equal("Text", Assert.IsType(item.Fields[1].Type).Declaration.Name); - Assert.Null(assembly.GetType("Example.TextSchema_")); - } - - [Fact] - public async Task FileLocalCatalogAvoidsSourceTypeAndNamespaceNames() - { - var code = await GeneratedCode.Generate(""" - namespace __BondSchemaCatalog__ - struct __BondSchemaCatalog {} - struct __BondSchemaCatalog_ {} - struct Item { 0: __BondSchemaCatalog value; } - """); - Assert.Contains("file static class __BondSchemaCatalog___", code); - - var assembly = GeneratedCode.Compile(code); - var descriptor = Read(assembly, "__BondSchemaCatalog__.ItemSchema"); - Assert.Equal("__BondSchemaCatalog", Assert.IsType(descriptor.Fields[0].Type).Declaration.Name); - } - - [Fact] - public async Task UnusedAliasesStayPrivateAndCatalogGenerationDoesNotDependOnInputPath() - { - var parsed = await ParserFacade.ParseStringAsync(""" - namespace __BondSchemaCatalog - using Value = int32; - using Erased = T; - """); - Assert.True(parsed.Success); - - var options = new CSharpGenerationOptions { ModelFeatures = CSharpModelFeatures.Descriptors }; - var first = CSharpGenerator.Generate(parsed.Ast!, "first/input.bond", options); - var second = CSharpGenerator.Generate(parsed.Ast!, "second/renamed.bond", options); - - Assert.True(first.Success, string.Join("\n", first.Errors.Select(error => error.Message))); - Assert.Equal(first.Code, second.Code); - Assert.Contains("file static class __BondSchemaCatalog_", first.Code!); - Assert.DoesNotContain("public static class", first.Code!); - Assert.Empty(GeneratedCode.Compile(first.Code!).GetExportedTypes()); - } - - [Fact] - public async Task ExplicitMappingsChangeClrNamesButNotSourceSchemaIdentity() - { - var parsed = await ParserFacade.ParseStringAsync(""" - namespace Contracts - using Timestamp = int64; - struct Item { 0: int32 id; 1: Timestamp created; } - """); - Assert.True(parsed.Success); - - var generated = CSharpGenerator.Generate(parsed.Ast!, "input.bond", new CSharpGenerationOptions - { - ModelFeatures = CSharpModelFeatures.Descriptors, - NamespaceMappings = ["Contracts=Application"], - TypeMappings = ["Contracts.Timestamp=System.DateTime"] - }); - Assert.True(generated.Success, string.Join("\n", generated.Errors.Select(error => error.Message))); - - var assembly = GeneratedCode.Compile(generated.Code!, """ - namespace Application - { - public static class BondTypeAliasConverter - { - public static System.DateTime Convert(long value, System.DateTime ignored) => new System.DateTime(value); - public static long Convert(System.DateTime value, long ignored) => value.Ticks; - } - } - """); - var model = Read(assembly, "Application.ItemSchema"); - Assert.Equal("Contracts.Item", model.FullName); - Assert.Equal("global::Application.Item", model.ClrName); - - var alias = Assert.IsType(model.Fields[1].Type).Declaration; - Assert.Equal("Contracts.Timestamp", alias.FullName); - Assert.EndsWith("System.DateTime", alias.ClrName, StringComparison.Ordinal); - Assert.Equal(SchemaTypeKind.Int64, alias.AliasedType!.Kind); - } - - [Fact] - public async Task NamedDescriptorArgumentsCanBeSuppliedWithoutAnyClrTypeInformation() - { - var code = await GeneratedCode.Generate(""" - namespace Example - enum State { Ready } - struct Box { 0: T value; } - """); - var assembly = GeneratedCode.Compile(code); - var state = Read(assembly, "Example.StateSchema"); - var box = Read(assembly, "Example.BoxSchema"); - var bound = box.Bind(state.AsType()); - - Assert.Same(state, Assert.IsType(bound.Fields[0].Type).Resolve()); - Assert.Same(box, bound.AsType().Declaration); - Assert.Same(state, Assert.IsType(Assert.Single(bound.AsType().TypeArguments)).Declaration); - Assert.Throws(() => box.AsType()); - Assert.Same(state, state.Bind()); - } - - [Fact] - public void PublicDescriptorsDefensivelyFreezeEveryInputCollection() - { - var number = new PrimitiveSchemaType(SchemaTypeKind.Int32); - var attributes = new[] { new SchemaAttribute("key", "original") }; - var field = new FieldDescriptor(9, "value", number, attributes: attributes); - var fields = new[] { field, new FieldDescriptor(1, "first", number) }; - var parameters = new[] { new TypeParameterDescriptor("T") }; - var values = new[] { new EnumValueDescriptor("First", 1) }; - var target = new[] { "Source" }; - var selection = new[] { "value" }; - var descriptor = new SchemaDescriptor("Model", "Example", "Example.Model", SchemaKind.Struct, - fields, typeParameters: parameters, enumValues: values, attributes: attributes, - isView: true, viewTarget: target, viewFields: selection); - - var arguments = new SchemaType[] { number }; - var calls = 0; - var reference = new NamedSchemaType(SchemaTypeKind.Struct, "Model", "Example", () => - { - calls++; - return descriptor; - }, arguments); - var bound = descriptor.Bind(arguments); - Assert.Equal(0, calls); - - attributes[0] = new SchemaAttribute("key", "changed"); - fields[0] = fields[1]; - parameters[0] = new TypeParameterDescriptor("Changed"); - values[0] = new EnumValueDescriptor("Changed", 2); - target[0] = "Changed"; - selection[0] = "Changed"; - arguments[0] = new PrimitiveSchemaType(SchemaTypeKind.String); - - Assert.Equal("original", Assert.Single(field.Attributes).Value); - Assert.Equal("original", Assert.Single(descriptor.Attributes).Value); - Assert.Equal(new ushort[] { 1, 9 }, descriptor.Fields.Select(value => value.Id)); - Assert.Equal("T", Assert.Single(descriptor.TypeParameters).Name); - Assert.Equal("First", Assert.Single(descriptor.EnumValues).Name); - Assert.Equal("Source", Assert.Single(descriptor.ViewTarget)); - Assert.Equal("value", Assert.Single(descriptor.ViewFields)); - Assert.Same(number, Assert.Single(reference.TypeArguments)); - Assert.Same(number, Assert.Single(bound.TypeArguments)); - Assert.Same(descriptor, reference.Declaration); - Assert.Same(descriptor, reference.Declaration); - Assert.Equal(1, calls); - - AssertFrozen(descriptor.Fields, field); - AssertFrozen(descriptor.Attributes, attributes[0]); - AssertFrozen(field.Attributes, attributes[0]); - AssertFrozen(descriptor.TypeParameters, parameters[0]); - AssertFrozen(descriptor.EnumValues, values[0]); - AssertFrozen(descriptor.ViewTarget, "Changed"); - AssertFrozen(descriptor.ViewFields, "Changed"); - AssertFrozen(reference.TypeArguments, arguments[0]); - AssertFrozen(bound.TypeArguments, arguments[0]); - } - - private static void AssertFrozen(IReadOnlyList values, T replacement) - { - Assert.False(values is T[]); - var mutable = Assert.IsAssignableFrom>(values); - Assert.True(mutable.IsReadOnly); - Assert.Throws(() => mutable[0] = replacement); - Assert.Throws(() => mutable.Add(replacement)); - } - - private static SchemaDescriptor Read(Assembly assembly, string companion) - { - var getter = assembly.GetType(companion, throwOnError: true)!.GetProperty("Descriptor")!.GetMethod!; - return getter.CreateDelegate>()(); - } - - private static async Task GenerateWithImports(string source, IReadOnlyDictionary imports, - CSharpGenerationOptions? options = null) - { - var parsed = await ParserFacade.ParseStringAsync(source, - (_, path) => Task.FromResult((path, imports[path]))); - Assert.True(parsed.Success, string.Join("\n", parsed.Errors.Select(error => error.Message))); - - var generated = CSharpGenerator.Generate(parsed.Ast!, "input.bond", - options ?? new CSharpGenerationOptions { ModelFeatures = CSharpModelFeatures.Descriptors }); - Assert.True(generated.Success, string.Join("\n", generated.Errors.Select(error => error.Message))); - - return generated.Code!; - } -} diff --git a/tests/Bond.Models.Tests/GeneratedModelOperationsTests.cs b/tests/Bond.Models.Tests/GeneratedModelOperationsTests.cs deleted file mode 100644 index 379d8ec..0000000 --- a/tests/Bond.Models.Tests/GeneratedModelOperationsTests.cs +++ /dev/null @@ -1,698 +0,0 @@ -using System.Linq; -using System.Threading.Tasks; -using Bond.Parser.CodeGeneration; -using Bond.Parser.Parser; -using Bond.TestSupport; - -namespace Bond.Models.Tests; - -public sealed class GeneratedModelOperationsTests -{ - [Fact] - public async Task CloneOwnsMutableValuesPreservesCyclesSharingAndRuntimeTypesWithoutConstructors() - { - await Check(""" - namespace Models - struct Node { - 0: int32 id; - 1: nullable next; - 2: vector children; - 3: vector other_children; - 4: map lookup; - 5: set flags; - 6: list sequence; - 7: blob bytes; - 8: blob more_bytes; - } - struct Derived : Node { 0: int32 id; } - struct Root { 0: Node first; 1: Node second; } - """, """ - var node = new Models.Derived { id = 9 }; - ((Models.Node)node).id = 7; - node.next = node; - node.children.Add(node); - node.other_children = node.children; - node.lookup.Add("self", node); - node.flags.UnionWith(new[] { 4, 2, 8 }); - node.sequence.AddLast(1); - node.sequence.AddLast(2); - var bytes = new byte[] { 90, 1, 2, 3, 91 }; - node.bytes = new ArraySegment(bytes, 1, 3); - node.more_bytes = new ArraySegment(bytes, 2, 2); - var root = new Models.Root { first = node, second = node }; - var constructions = Models.Node.Constructions; - var copy = root.Clone(); - Require(Models.Node.Constructions == constructions, "clone invoked an ordinary constructor"); - Require(!ReferenceEquals(root, copy), "root was not cloned"); - Require(copy.first is Models.Derived, "runtime type was lost"); - var child = (Models.Derived)copy.first; - Require(ReferenceEquals(copy.first, copy.second), "shared model reference was lost"); - Require(ReferenceEquals(child, child.next), "model cycle was lost"); - Require(child.id == 9 && ((Models.Node)child).id == 7, "hidden base field was not cloned"); - Require(!ReferenceEquals(child.children, node.children), "vector was shallow copied"); - Require(ReferenceEquals(child.children, child.other_children), "shared vector was lost"); - Require(ReferenceEquals(child.children[0], child), "vector element was not cloned"); - Require(!ReferenceEquals(child.lookup, node.lookup) && ReferenceEquals(child.lookup["self"], child), "map was shallow copied"); - Require(!ReferenceEquals(child.flags, node.flags) && child.flags.SetEquals(node.flags), "set was shallow copied"); - Require(!ReferenceEquals(child.sequence, node.sequence) && child.sequence.SequenceEqual(node.sequence), "list was shallow copied"); - Require(!ReferenceEquals(child.bytes.Array, bytes), "blob backing array was shallow copied"); - Require(ReferenceEquals(child.bytes.Array, child.more_bytes.Array), "shared blob backing array was lost"); - Require(child.bytes.Offset == 1 && child.more_bytes.Offset == 2, "blob segment boundaries changed"); - Require(root.Equals(copy) && root.GetHashCode() == copy.GetHashCode(), "clone was not value-equal"); - child.bytes.Array[1] = 55; - Require(bytes[1] == 1, "mutating cloned bytes affected source"); - Require(!root.Equals(copy), "changed clone still compared equal"); - Require(!new Models.Node().Equals(new Models.Derived()), "base compared equal to derived"); - Require(!new Models.Derived().Equals(new Models.Node()), "derived compared equal to base"); - """, """ - namespace Models - { - public partial class Node - { - public static int Constructions; - private readonly int witness = ++Constructions; - } - } - """); - } - - [Fact] - public async Task StructuralEqualityAndHashRespectOrderingNothingAndDotNetFloatingPointSemantics() - { - await Check(""" - namespace Models - struct Value { - 0: double number; - 1: float single; - 2: map lookup; - 3: set flags; - 4: vector sequence; - 5: int32 absent = nothing; - 6: nullable maybe; - 7: blob bytes = nothing; - } - """, """ - var left = new Models.Value { number = double.NaN, single = -0.0f }; - var right = new Models.Value - { - number = BitConverter.Int64BitsToDouble(unchecked((long)0xfff8000000000001)), - single = 0.0f - }; - left.lookup.Add("a", 1); left.lookup.Add("b", 2); - right.lookup.Add("b", 2); right.lookup.Add("a", 1); - left.flags.UnionWith(new[] { 3, 1, 2 }); - right.flags.UnionWith(new[] { 2, 1, 3 }); - left.sequence.AddRange(new[] { 1, 2 }); - right.sequence.AddRange(new[] { 1, 2 }); - Require(left.Equals(right) && right.Equals((object)left), "value equality ignored .NET scalar or unordered semantics"); - Require(((IEquatable)left).Equals(right), "IEquatable disagreed"); - Require(left.GetHashCode() == right.GetHashCode(), "equal unordered values hashed differently"); - right.sequence.Reverse(); - Require(!left.Equals(right), "sequence order was ignored"); - right.sequence.Reverse(); - right.absent = 0; - Require(!left.Equals(right), "nothing was confused with zero"); - right.absent = null; - right.maybe = 0; - Require(!left.Equals(right), "nullable absence was confused with zero"); - right.maybe = null; - right.bytes = new ArraySegment(Array.Empty()); - Require(!left.Equals(right), "default blob was confused with owned empty blob"); - right.bytes = default; - Require(left.Equals(right), "restored values differed"); - Require(!left.Equals(null), "model equaled null"); - """); - } - - [Fact] - public async Task CyclicEqualityIgnoresSharingAndHashesBisimilarGraphsEqually() - { - await Check(""" - namespace Models - struct Node { 0: int32 id; 1: nullable next; 2: vector children; } - """, """ - var self = new Models.Node { id = 4 }; - self.next = self; - var first = new Models.Node { id = 4 }; - var second = new Models.Node { id = 4 }; - first.next = second; second.next = first; - Require(self.Equals(first) && first.Equals(self), "bisimilar cycles were unequal"); - Require(self.GetHashCode() == first.GetHashCode(), "bisimilar cycles used identity hashes"); - var shared = new Models.Node(); - shared.children.Add(self); shared.children.Add(self); - var duplicated = new Models.Node(); - duplicated.children.Add(first); duplicated.children.Add(self.Clone()); - Require(shared.Equals(duplicated), "sharing affected equality"); - Require(shared.GetHashCode() == duplicated.GetHashCode(), "sharing affected hash"); - second.id = 5; - Require(!self.Equals(first) && !first.Equals(self), "different cyclic values compared equal"); - """); - } - - [Fact] - public async Task GenericFieldsAndGenericHiddenBasesUseTypedOperations() - { - await Check(""" - namespace Models - struct Leaf { 0: int32 number; } - struct Box { 0: T value; 1: vector values; } - struct Derived : Box> { 0: T value; } - struct Scalar { 0: nullable value; } - struct ContainerRoot { 0: Box> wrapped; 1: Box>> nested; } - """, """ - var integers = new Models.Box { value = 42 }; - integers.values.Add(9); - Require(integers.Equals(integers.Clone()), "generic primitive did not clone or compare"); - var scalar = new Models.Scalar { value = double.NaN }; - Require(scalar.Equals(scalar.Clone()), "constrained nullable generic failed"); - - var leaf = new Models.Leaf { number = 7 }; - var box = new Models.Box { value = leaf }; - box.values.Add(leaf); - - var copy = box.Clone(); - - Require(!ReferenceEquals(copy.value, leaf) && ReferenceEquals(copy.value, copy.values[0]), "generic model did not preserve sharing"); - - var derived = new Models.Derived { value = leaf }; - ((Models.Box>)derived).value = new List { leaf }; - ((Models.Box>)derived).values.Add(((Models.Box>)derived).value); - - var derivedCopy = derived.Clone(); - var baseCopy = (Models.Box>)derivedCopy; - - Require(ReferenceEquals(baseCopy.value[0], derivedCopy.value), "substituted hidden base field lost shared value"); - Require(ReferenceEquals(baseCopy.value, baseCopy.values[0]), "nested substituted generic containers lost sharing"); - Require(derived.Equals(derivedCopy) && derived.GetHashCode() == derivedCopy.GetHashCode(), "generic derived operations disagreed"); - - var containers = new Models.ContainerRoot(); - containers.wrapped.value = new List { leaf }; - containers.nested.value = containers.wrapped; - - var containersCopy = containers.Clone(); - - Require(!ReferenceEquals(containersCopy.wrapped.value, containers.wrapped.value), "generic container was shallow copied"); - Require(ReferenceEquals(containersCopy.wrapped, containersCopy.nested.value), "nested generic sharing was lost"); - Require(containers.Equals(containersCopy) && containers.GetHashCode() == containersCopy.GetHashCode(), "generic container operations disagreed"); - """); - } - - [Fact] - public async Task GenericBlobClonesOwnBackingStorage() - { - await Check(""" - namespace Models - struct Box { 0: T value; 1: vector values; } - """, """ - var blobs = new Models.Box> { value = new ArraySegment(new byte[] { 1, 2 }) }; - - var blobsCopy = blobs.Clone(); - - Require(!ReferenceEquals(blobs.value.Array, blobsCopy.value.Array) && blobs.Equals(blobsCopy), "generic blob did not deep clone"); - """); - } - - [Fact] - public async Task UnknownGenericValuesRequireTypedAdapters() - { - await Check(""" - namespace Models - struct Box { 0: T value; 1: vector values; } - """, """ - var external = new External { Data = new List { 4, 5 } }; - var box = new Models.Box { value = external }; - box.values.Add(external); - - try - { - box.Clone(); - throw new Exception("unknown mutable CLR value was silently shallow copied"); - } - catch (NotSupportedException error) - { - Require(error.Message.Contains("RegisterAdapter"), "missing adapter did not include registration guidance"); - } - - ModelOperations.RegisterAdapter(new ModelAdapter( - (value, context) => - { - if (context.TryGetClone(value, out var found)) - { - return found; - } - - var clone = new External(); - context.Register(value, clone); - clone.Data = ModelAdapters.List(ModelAdapters.Value()).Clone(value.Data, context); - return clone; - }, - (left, right, context) => ModelAdapters.List(ModelAdapters.Value()).Equals(left.Data, right.Data, context), - (value, context) => ModelAdapters.List(ModelAdapters.Value()).GetHashCode(value.Data, context))); - - var copy = box.Clone(); - - Require(!ReferenceEquals(copy.value, external) && !ReferenceEquals(copy.value.Data, external.Data), "registered adapter was not used"); - Require(ReferenceEquals(copy.value, copy.values[0]), "registered adapter lost sharing"); - Require(box.Equals(copy) && box.GetHashCode() == copy.GetHashCode(), "registered value equality disagreed"); - """, """ - public sealed class External - { - public System.Collections.Generic.List Data = new(); - public override string ToString() => throw new System.Exception("unexpected formatting"); - } - """); - } - - [Fact] - public async Task GenericImmutableValuesCloneWithoutRegisteredAdapters() - { - await Check(""" - namespace Models - struct Box { 0: T value; 1: vector values; } - """, """ - var date = new Models.Box { value = new DateTime(2024, 3, 5, 6, 7, 8, DateTimeKind.Utc) }; - var guid = new Models.Box { value = Guid.NewGuid() }; - - Require(date.Equals(date.Clone()), "DateTime needed an adapter"); - Require(guid.Equals(guid.Clone()), "Guid needed an adapter"); - """); - } - - [Fact] - public async Task BondedOperationsMaterializeOnceAndPreserveCyclicSharedWrappers() - { - await Check(""" - namespace Models - struct Payload { 0: int32 number; 1: nullable owner; } - struct Holder { 0: bonded first; 1: bonded second; 2: vector> values; } - """, """ - var source = new Models.Holder(); - var payload = new Models.Payload { number = 7, owner = source }; - var wrapper = new CountingBonded(payload); - source.first = wrapper; source.second = wrapper; source.values.Add(wrapper); - var copy = source.Clone(); - Require(wrapper.Reads == 1 && wrapper.Writes == 0, "cloning did not materialize exactly once"); - Require(!ReferenceEquals(copy.first, wrapper) && ReferenceEquals(copy.first, copy.second) - && ReferenceEquals(copy.first, copy.values[0]), "bonded wrapper sharing was lost"); - var copiedPayload = ((IMaterializedModelValue)copy.first).Value; - Require(!ReferenceEquals(copiedPayload, payload) && ReferenceEquals(copiedPayload.owner, copy), "bonded payload cycle was lost"); - Require(source.Equals(copy), "materialized clone was not value equal"); - Require(wrapper.Reads == 2, "equality materialized one wrapper repeatedly"); - Require(source.GetHashCode() == copy.GetHashCode(), "materialized value hashes differed"); - Require(wrapper.Reads == 3 && wrapper.Writes == 0, "hash materialized repeatedly or serialized"); - var deserialized = copy.first.Deserialize(); - Require(!ReferenceEquals(deserialized, copiedPayload), "cloned bonded Deserialize did not clone actual data"); - Require(ReferenceEquals(((IMaterializedModelValue)deserialized.owner.first).Value, deserialized), - "cloned bonded Deserialize lost its cycle"); - copiedPayload.number = 8; - Require(!source.Equals(copy), "bonded equality compared wrapper identity rather than data"); - """, CountingBondedSource); - } - - [Fact] - public async Task CovariantBondedClonesSharePayloadsAndCyclesInEitherFieldOrder() - { - await Check(""" - namespace Models - struct Base { - 0: vector values; - 1: bonded derived_view; - 2: bonded base_view; - } - struct Derived : Base { 0: int32 number; } - struct BaseFirst { 0: bonded first; 1: bonded second; } - struct DerivedFirst { 0: bonded first; 1: bonded second; } - """, """ - foreach (var baseFirst in new[] { true, false }) - { - var payload = new Models.Derived { number = 7 }; - payload.values.Add(42); - var wrapper = new CountingDerivedBonded(payload); - payload.derived_view = wrapper; - payload.base_view = wrapper; - Bond.IBonded baseView; - Bond.IBonded derivedView; - if (baseFirst) - { - var source = new Models.BaseFirst { first = wrapper, second = wrapper }; - var copy = source.Clone(); - baseView = copy.first; - derivedView = copy.second; - } - else - { - var source = new Models.DerivedFirst { first = wrapper, second = wrapper }; - var copy = source.Clone(); - baseView = copy.second; - derivedView = copy.first; - } - - Require(wrapper.Reads == 1, "covariant views materialized the source repeatedly"); - Require(!ReferenceEquals(baseView, wrapper) && !ReferenceEquals(derivedView, wrapper), - "a covariant view retained the source wrapper"); - var copiedBase = ((IMaterializedModelValue)baseView).Value; - var copiedDerived = ((IMaterializedModelValue)derivedView).Value; - Require(ReferenceEquals(copiedBase, copiedDerived) && !ReferenceEquals(copiedDerived, payload), - "covariant views did not share an independently cloned payload"); - Require(copiedDerived.number == 7 && !ReferenceEquals(copiedDerived.values, payload.values), - "the runtime payload type or mutable data was lost"); - Require(ReferenceEquals(copiedDerived.derived_view, derivedView), - "the derived wrapper cycle was lost"); - Require(ReferenceEquals(((IMaterializedModelValue)copiedDerived.base_view).Value, copiedDerived), - "the base wrapper cycle was lost"); - copiedDerived.values.Add(99); - Require(payload.values.Count == 1, "mutating the clone affected the original payload"); - } - """, """ - public sealed class CountingDerivedBonded : Bond.IBonded - { - private readonly Models.Derived payload; - public int Reads; - public CountingDerivedBonded(Models.Derived payload) { this.payload = payload; } - public Models.Derived Deserialize() { Reads++; return payload; } - public T Deserialize() => throw new Exception("untyped deserialization"); - public Bond.IBonded Convert() => throw new Exception("unexpected conversion"); - public void Serialize(T writer) => throw new Exception("unexpected serialization"); - public override string ToString() => throw new Exception("unexpected formatting"); - } - """); - } - - [Fact] - public async Task GenericMaterializedBindingsPreserveSharedWrappersAndOwnerCycles() - { - await Check(""" - namespace Models - struct Payload { 0: int32 number; 1: nullable owner; } - struct Box { 0: T value; } - struct Holder { 0: Box> wrapped; 1: bonded direct; } - """, """ - var source = new Models.Holder(); - var payload = new Models.Payload { number = 7, owner = source }; - var wrapper = new CountingBonded(payload); - source.wrapped.value = wrapper; - source.direct = wrapper; - - var copy = source.Clone(); - var copiedPayload = ((IMaterializedModelValue)copy.direct).Value; - Require(wrapper.Reads == 1, "a standard generic binding materialized the wrapper repeatedly"); - Require(ReferenceEquals(copy.wrapped.value, copy.direct), "a standard generic binding split same-view wrappers"); - Require(ReferenceEquals(copiedPayload.owner, copy), "a standard generic binding split the owner cycle"); - Require(!ReferenceEquals(copy.direct, wrapper) && !ReferenceEquals(copiedPayload, payload), - "generic materialized cloning retained original mutable data"); - Require(source.Equals(copy) && source.GetHashCode() == copy.GetHashCode(), - "generic materialized equality or hashing changed"); - """, CountingBondedSource); - } - - [Fact] - public async Task GenericNullableContainerAliasesPreserveMaterializedWrappersAndOwnerCycles() - { - await Check(""" - namespace Models - using Payloads = nullable>>; - struct Payload { 0: int32 number; 1: nullable owner; } - struct Box { 0: T value; } - struct Holder { 0: Box wrapped; 1: bonded direct; } - """, """ - var source = new Models.Holder(); - var payload = new Models.Payload { number = 7, owner = source }; - var wrapper = new CountingBonded(payload); - source.wrapped.value = new List> { wrapper }; - source.direct = wrapper; - - var copy = source.Clone(); - var copiedPayload = ((IMaterializedModelValue)copy.direct).Value; - - Require(wrapper.Reads == 1 && wrapper.Writes == 0, - "a nullable container alias did not materialize the shared wrapper exactly once"); - Require(ReferenceEquals(copy.wrapped.value[0], copy.direct), - "a nullable container alias split same-view wrappers"); - Require(ReferenceEquals(copiedPayload.owner, copy), - "a nullable container alias split the owner cycle"); - Require(!ReferenceEquals(copy.wrapped.value, source.wrapped.value) - && !ReferenceEquals(copy.direct, wrapper) && !ReferenceEquals(copiedPayload, payload), - "a nullable container alias retained original mutable data"); - - Require(source.Equals(copy), "a nullable container alias clone was not value equal"); - Require(wrapper.Reads == 2, "nullable container alias equality materialized repeatedly"); - Require(source.GetHashCode() == copy.GetHashCode(), - "nullable container alias clone hashes differed"); - Require(wrapper.Reads == 3 && wrapper.Writes == 0, - "nullable container alias hashing materialized repeatedly or serialized"); - """, CountingBondedSource); - } - - [Fact] - public async Task GenericMaterializedProjectionsRetainTheirContextualSemantics() - { - await Check(""" - namespace Models - struct Box { 0: T value; } - """, """ - var ordered = ModelAdapters.WithArguments(ModelAdapters.Value>>(), - ModelAdapterArgument.Create(ModelAdapters.List(ModelAdapters.Value()))); - var unordered = ModelAdapters.WithArguments(ModelAdapters.Value>>(), - ModelAdapterArgument.Create(ModelAdapters.Materialized, HashSet>( - values => new HashSet(values), values => new List(values), - ModelAdapters.Set(ModelAdapters.Value())))); - var projected = ModelAdapters.WithArguments(ModelAdapters.Value>>(), - ModelAdapterArgument.Create(ModelAdapters.Materialized, int>( - values => values.Count, count => new List { count }, ModelAdapters.Value()))); - var left = new Models.Box> { value = new List { 1, 2 } }; - var right = new Models.Box> { value = new List { 2, 1 } }; - var equality = new EqualityContext(); - Require(unordered.Equals(left, right, equality), "contextual projection comparison failed"); - Require(!ordered.Equals(left, right, equality), "a projection contaminated ordinary generic comparison"); - - var hashes = new HashContext(); - var unorderedHash = unordered.GetHashCode(left, hashes); - var orderedHash = ordered.GetHashCode(left, hashes); - Require(unorderedHash != orderedHash && orderedHash == ordered.GetHashCode(left, new HashContext()), - "a projection contaminated ordinary generic hashing"); - var clones = new CloneContext(); - var projectedCopy = projected.Clone(left, clones); - var orderedCopy = ordered.Clone(left, clones); - Require(!ReferenceEquals(projectedCopy, orderedCopy) && - projectedCopy.value.SequenceEqual(new[] { 2 }) && orderedCopy.value.SequenceEqual(new[] { 1, 2 }), - "a projection contaminated ordinary generic cloning"); - Require(!ReferenceEquals(projectedCopy.value, left.value) && !ReferenceEquals(orderedCopy.value, left.value), - "generic projection cloning retained original mutable data"); - """); - } - - [Fact] - public async Task GenericAdapterBindingsIsolateMemoizationAndPreserveEquivalentScopesAndCycles() - { - await Check(""" - namespace Models - struct Box { 0: T value; 1: nullable> next; } - """, """ - var exactList = ModelAdapters.List(ModelAdapters.Value()); - var offsetElement = new ModelAdapter( - (value, context) => value + 100, - (left, right, context) => left == right, - (value, context) => value + 100); - var offsetList = ModelAdapters.List(offsetElement); - var parityList = ModelAdapters.List(new ModelAdapter( - (value, context) => value, - (left, right, context) => (left & 1) == (right & 1), - (value, context) => value & 1)); - var exact = ModelAdapters.WithArguments(ModelAdapters.Value>>(), - ModelAdapterArgument.Create(exactList)); - var offset = ModelAdapters.WithArguments(ModelAdapters.Value>>(), - ModelAdapterArgument.Create(offsetList)); - var parity = ModelAdapters.WithArguments(ModelAdapters.Value>>(), - ModelAdapterArgument.Create(parityList)); - var left = new Models.Box> { value = new List { 1 } }; - var right = new Models.Box> { value = new List { 3 } }; - left.next = left; - right.next = right; - var comparisons = new EqualityContext(); - Require(parity.Equals(left, right, comparisons), "contextual parity comparison failed"); - Require(!exact.Equals(left, right, comparisons), "parity memoization contaminated exact generic comparison"); - - var hashes = new HashContext(); - var exactHash = exact.GetHashCode(left, hashes); - var offsetHash = offset.GetHashCode(left, hashes); - Require(exactHash != offsetHash && offsetHash == offset.GetHashCode(left, new HashContext()), - "generic hash memoization ignored contextual bindings"); - var clones = new CloneContext(); - var exactCopy = exact.Clone(left, clones); - var offsetCopy = offset.Clone(left, clones); - Require(!ReferenceEquals(exactCopy, offsetCopy) && exactCopy.value[0] == 1 && offsetCopy.value[0] == 101, - "clone memoization ignored contextual bindings"); - Require(ReferenceEquals(exactCopy.next, exactCopy) && ReferenceEquals(offsetCopy.next, offsetCopy), - "scoping generic adapters broke cycle termination"); - Require(!ReferenceEquals(exactCopy.value, left.value) && !ReferenceEquals(offsetCopy.value, left.value), - "contextual cloning retained original mutable data"); - - var equivalent = ModelAdapters.WithArguments(ModelAdapters.Value>>(), - ModelAdapterArgument.Create(ModelAdapters.List(offsetElement))); - Require(ReferenceEquals(offsetCopy, equivalent.Clone(left, clones)), - "equivalent bindings did not preserve clone sharing"); - Require(equivalent.GetHashCode(left, hashes) == offsetHash, - "equivalent bindings did not preserve hash semantics"); - """); - } - - [Fact] - public async Task DebuggerSnapshotsNeverMaterializeEnumerateOrFormatImmediateValues() - { - await Check(""" - namespace Models - struct Payload { 0: int32 number; 1: nullable owner; } - struct Holder { 0: bonded first; 1: vector values; } - struct Derived : Holder { 0: T item; 1: int32 first; } - """, """ - var wrapper = new CountingBonded(new Models.Payload()); - var source = new Models.Derived { first = 42, item = new External() }; - ((Models.Holder)source).first = wrapper; - source.values = new ThrowingList(); - var view = new GeneratedModelDebugView(source); - Require(view.Fields.Length == 4, "debugger did not include hidden inherited fields"); - Require(ReferenceEquals(view.Fields.Single(field => field.DeclaringType == "Models.Holder" && field.Name == "first").Value, wrapper), - "debugger did not capture the immediate bonded wrapper"); - Require(ReferenceEquals(view.Fields.Single(field => field.Name == "values").Value, source.values), "debugger replaced or inspected the collection"); - Require(wrapper.Reads == 0 && wrapper.Writes == 0, "debugger materialized or serialized"); - var display = (System.Diagnostics.DebuggerDisplayAttribute)Attribute.GetCustomAttribute(typeof(Models.Derived), - typeof(System.Diagnostics.DebuggerDisplayAttribute), inherit: false); - Require(display.Value == "Derived", "debugger label evaluates expressions"); - """, CountingBondedSource + """ - public sealed class External - { - public override string ToString() => throw new System.Exception("debugger formatted a value"); - } - public sealed class ThrowingList : System.Collections.Generic.List, - System.Collections.Generic.IEnumerable - { - System.Collections.Generic.IEnumerator System.Collections.Generic.IEnumerable.GetEnumerator() - => throw new System.Exception("debugger enumerated a collection"); - System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() - => throw new System.Exception("debugger enumerated a collection"); - } - """); - } - - [Fact] - public async Task FieldTypeAndCompanionNameCollisionsRetainInterfaceAndCompanionOperations() - { - await Check(""" - namespace Models - struct Awkward { - 0: int32 Clone; - 1: int32 Equals; - 2: int32 GetHashCode; - 3: int32 Metadata; - 4: int32 Descriptor; - 5: int32 MemberwiseClone; - 6: int32 GetType; - 7: int32 GetDebugFields; - } - struct AwkwardOperations {} - struct AwkwardSchema {} - struct Clone { 0: int32 value; } - struct Equals { 0: int32 value; } - struct GetHashCode { 0: int32 value; } - struct Generic { 0: Clone a; 1: Equals b; 2: GetHashCode c; } - """, """ - var source = new Models.Awkward { Clone = 3, Equals = 4, GetHashCode = 5, Metadata = 6, Descriptor = 7, - MemberwiseClone = 8, GetType = 9, GetDebugFields = 10 }; - var copy = Models.AwkwardOperations_.Clone(source); - Require(!ReferenceEquals(source, copy) && copy.Clone == 3 && copy.MemberwiseClone == 8, "colliding clone was not available"); - Require(((IGeneratedEquatable)source).ValueEquals(copy, new EqualityContext()), "explicit value equality was not available"); - Require(!EqualityComparer.Default.Equals(source, copy), - "default equality changed without a matching overridable hash method"); - Require(Models.AwkwardOperations_.Equals(source, copy), "companion equality was not available"); - Require(Models.AwkwardOperations_.GetHashCode(source) == Models.AwkwardOperations_.GetHashCode(copy), "companion hash was not available"); - Require(((IGeneratedModel)source).Descriptor.Name == "Awkward", "descriptor collision was not handled"); - Require(new GeneratedModelDebugView(source).Fields.Length == 8, "debug helper collision was not handled"); - Require(((ICloneable)source).Clone() is Models.Awkward, "explicit ICloneable was not available"); - Require(Models.CloneOperations.Clone(new Models.Clone()).value == 0, "type-name Clone collision failed"); - Require(Models.EqualsOperations.Equals(new Models.Equals(), new Models.Equals()), "type-name Equals collision failed"); - Require(Models.GetHashCodeOperations.GetHashCode(new Models.GetHashCode()) == - Models.GetHashCodeOperations.GetHashCode(new Models.GetHashCode()), "type-name GetHashCode collision failed"); - var generic = new Models.Generic { a = 1, b = 2, c = 3 }; - Require(Models.GenericOperations.Equals(generic, - Models.GenericOperations.Clone(generic)), "type-parameter collision failed"); - """); - } - - [Fact] - public async Task CustomMappedAliasUsesRegisteredTypedOperations() - { - var parsed = await ParserFacade.ParseStringAsync(""" - namespace Models - using ExternalValue = string; - struct Container { 0: ExternalValue value; 1: vector values; } - """); - Assert.True(parsed.Success); - - var generated = CSharpGenerator.Generate(parsed.Ast!, "operations.bond", new CSharpGenerationOptions - { - ModelFeatures = CSharpModelFeatures.All, - TypeMappings = ["Models.ExternalValue=global::External"] - }); - Assert.True(generated.Success, string.Join("\n", generated.Errors.Select(error => error.Message))); - - GeneratedScenario.Run(generated.Code!, """ - var source = new Models.Container { value = new External { Number = 8 } }; - source.values.Add(source.value); - - try - { - source.Clone(); - throw new Exception("mapped mutable type was silently shallow copied"); - } - catch (NotSupportedException) { } - - ModelOperations.RegisterAdapter(new ModelAdapter( - (value, context) => - { - if (context.TryGetClone(value, out var found)) - { - return found; - } - - var clone = new External { Number = value.Number }; - context.Register(value, clone); - return clone; - }, - (left, right, context) => left.Number == right.Number, - (value, context) => value.Number)); - - var copy = source.Clone(); - - Require(!ReferenceEquals(source.value, copy.value) && ReferenceEquals(copy.value, copy.values[0]), - "mapped field and container did not use the same typed adapter"); - Require(source.Equals(copy) && source.GetHashCode() == copy.GetHashCode(), "mapped equality disagreed"); - """, """ - public sealed class External { public int Number; } - namespace Models - { - public static class BondTypeAliasConverter - { - public static External Convert(string value, External ignored) => new External(); - } - } - """); - } - - private static async Task Check(string schema, string body, string extraSource = "") => - GeneratedScenario.Run(await GeneratedCode.Generate(schema), body, extraSource); - - private const string CountingBondedSource = """ - public sealed class CountingBonded : global::Bond.IBonded - { - private readonly Models.Payload payload; - public int Reads; - public int Writes; - public CountingBonded(Models.Payload payload) { this.payload = payload; } - public Models.Payload Deserialize() { Reads++; return payload; } - public T Deserialize() => throw new System.Exception("untyped deserialization"); - public global::Bond.IBonded Convert() => throw new System.Exception("unexpected conversion"); - public void Serialize(T writer) { Writes++; throw new System.Exception("unexpected serialization"); } - public override string ToString() => throw new System.Exception("unexpected bonded formatting"); - } - """; -} diff --git a/tests/Bond.Models.Tests/GeneratedScenario.cs b/tests/Bond.Models.Tests/GeneratedScenario.cs deleted file mode 100644 index 30d5635..0000000 --- a/tests/Bond.Models.Tests/GeneratedScenario.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using Bond.TestSupport; - -namespace Bond.Models.Tests; - -internal static class GeneratedScenario -{ - public static void Run(string generated, string body, string extraSource = "") - { - var checks = """ - using System; - using System.Collections.Generic; - using System.Linq; - using BondTools.Models; - public static class Scenario - { - private static void Require(bool condition, string message) - { - if (!condition) - { - throw new Exception(message); - } - } - - public static void Run() - { - """ + body + """ - } - } - """ + extraSource; - - var assembly = GeneratedCode.Compile(generated, checks); - var run = assembly.GetType("Scenario", true)! - .GetMethod("Run")! - .CreateDelegate(); - - run(); - } -} diff --git a/tests/Bond.Models.Tests/GeneratedScenarioTests.cs b/tests/Bond.Models.Tests/GeneratedScenarioTests.cs deleted file mode 100644 index 26f8f03..0000000 --- a/tests/Bond.Models.Tests/GeneratedScenarioTests.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; - -namespace Bond.Models.Tests; - -public sealed class GeneratedScenarioTests -{ - [Fact] - public void ScenarioFailuresPreserveTheirOriginalExceptionAndMessage() - { - var error = Assert.Throws(() => GeneratedScenario.Run("", """ - Require(true, "a passing requirement should not throw"); - throw new InvalidOperationException("scenario failed"); - """)); - - Assert.Equal("scenario failed", error.Message); - Assert.Contains("Scenario.Run", error.StackTrace); - } - - [Fact] - public void FailedRequirementsPreserveTheirDiagnosticMessage() - { - var error = Assert.Throws(() => GeneratedScenario.Run("", """ - Require(false, "shared model reference was lost"); - """)); - - Assert.Equal("shared model reference was lost", error.Message); - } -} diff --git a/tests/Bond.Models.Tests/GeneratedSummaryTests.cs b/tests/Bond.Models.Tests/GeneratedSummaryTests.cs deleted file mode 100644 index 5b5d119..0000000 --- a/tests/Bond.Models.Tests/GeneratedSummaryTests.cs +++ /dev/null @@ -1,268 +0,0 @@ -using System; -using System.Collections; -using System.Globalization; -using System.Linq; -using System.Reflection; -using System.Threading.Tasks; -using Bond.Parser.CodeGeneration; -using Bond.Parser.Parser; -using Bond.TestSupport; -using BondTools.Models; - -namespace Bond.Models.Tests; - -public sealed class GeneratedSummaryTests -{ - [Fact] - public async Task FormatsCompactNamedValuesWithoutEnablingOtherFeatures() - { - var assembly = await Compile(""" - namespace Example - enum State { Draft, Submitted } - struct Order { - 0: int32 id; - 1: State status = Draft; - } - """); - var value = New(assembly, "Example.Order"); - Set(value, "id", 42); - Set(value, "status", Enum.Parse(assembly.GetType("Example.State", true)!, "Submitted")); - - Assert.Equal("Order { id = 42, status = Submitted }", value.ToString()); - Assert.IsAssignableFrom(value); - Assert.False(value is IGeneratedCloneable or IGeneratedEquatable or IGeneratedSchemaProvider or IGeneratedDebugView); - Assert.Equal(typeof(object), value.GetType().GetMethod("Equals", [typeof(object)])!.DeclaringType); - Assert.Null(assembly.GetType("Example.OrderSchema")); - } - - [Fact] - public async Task FormatsNestedCollectionsNullsStringsAndBlobs() - { - var assembly = await Compile(""" - namespace Example - struct Child { 0: string name; } - struct Value { - 0: Child child; - 1: vector numbers; - 2: map scores; - 3: nullable missing; - 4: blob bytes; - 5: string text; - } - """); - var value = New(assembly, "Example.Value"); - var child = value.GetType().GetProperty("child")!.GetValue(value)!; - Set(child, "name", "Ada"); - ((IList)value.GetType().GetProperty("numbers")!.GetValue(value)!).Add(7); - ((IDictionary)value.GetType().GetProperty("scores")!.GetValue(value)!).Add("a", 9); - Set(value, "bytes", new ArraySegment([1, 2, 255])); - Set(value, "text", "line\n\"quoted\"\\tail"); - - var text = value.ToString()!; - Assert.Contains("child = Child { name = \"Ada\" }", text); - Assert.Contains("numbers = [7]", text); - Assert.Contains("\"a\"", text); - Assert.Contains("9", text); - Assert.Contains("missing = null", text); - Assert.Contains("\\n", text); - Assert.Contains("\\\"quoted\\\"", text); - Assert.DoesNotContain("\n", text); - Assert.Contains("bytes =", text); - } - - [Fact] - public async Task SummariesAreCycleSafeBoundedAndDoNotMistakeSharingForCycles() - { - var assembly = await Compile(""" - namespace Example - struct Node { - 0: string text; - 1: nullable next; - 2: vector children; - } - """); - var root = New(assembly, "Example.Node"); - Set(root, "next", root); - Assert.Contains("cycle", root.ToString()!, StringComparison.OrdinalIgnoreCase); - - Set(root, "next", null); - var child = New(assembly, "Example.Node"); - Set(child, "text", "shared"); - var children = (IList)root.GetType().GetProperty("children")!.GetValue(root)!; - children.Add(child); - children.Add(child); - Assert.DoesNotContain("cycle", root.ToString()!, StringComparison.OrdinalIgnoreCase); - - for (var index = 0; index < 100; index++) - { - children.Add(child); - } - - Set(root, "text", new string('x', 10000)); - var summary = root.ToString()!; - Assert.True(summary.Length <= 2048); - Assert.Contains("...", summary); - Assert.DoesNotContain(new string('x', 161), summary); - Assert.True(summary.Split("shared", StringSplitOptions.None).Length <= 17); - - var current = root; - for (var depth = 0; depth < 20; depth++) - { - var next = New(assembly, "Example.Node"); - Set(current, "next", next); - current = next; - } - - Assert.True(root.ToString()!.Length <= 2048); - } - - [Fact] - public async Task LazyPayloadsAndUnknownClrToStringMethodsAreNeverInvoked() - { - var assembly = await Compile(""" - namespace Example - struct Child {} - struct Holder { - 0: bonded first; - 1: vector> values; - } - struct Box { 0: T value; } - """, """ - public sealed class CountingBonded : Bond.IBonded - { - public static int Calls; - public Example.Child Deserialize() { Calls++; throw new System.Exception("materialized"); } - public T Deserialize() { Calls++; throw new System.Exception("materialized"); } - public void Serialize(T writer) { Calls++; throw new System.Exception("serialized"); } - public Bond.IBonded Convert() { Calls++; throw new System.Exception("converted"); } - public override string ToString() { Calls++; throw new System.Exception("formatted"); } - } - public sealed class External - { - public static int Calls; - public override string ToString() { Calls++; throw new System.Exception("formatted"); } - public override int GetHashCode() { Calls++; throw new System.Exception("hashed"); } - } - """); - var value = New(assembly, "Example.Holder"); - var bonded = New(assembly, "CountingBonded"); - Set(value, "first", bonded); - ((IList)value.GetType().GetProperty("values")!.GetValue(value)!).Add(bonded); - - Assert.NotEmpty(value.ToString()!); - Assert.Equal(0, bonded.GetType().GetField("Calls")!.GetValue(null)); - - var external = New(assembly, "External"); - var box = Activator.CreateInstance(assembly.GetType("Example.Box`1", true)!.MakeGenericType(external.GetType()))!; - Set(box, "value", external); - Assert.Contains("External", box.ToString()!); - Assert.Equal(0, external.GetType().GetField("Calls")!.GetValue(null)); - } - - [Fact] - public async Task HandlesInheritedFieldsAndToStringNameCollisions() - { - var assembly = await Compile(""" - namespace Example - struct Base { 0: int32 id; } - struct Derived : Base { 0: string id; } - struct Awkward { 0: int32 ToString; 1: int32 SummaryName; 2: int32 GetSummaryFields; } - struct ToString { 0: int32 id; } - struct Generic { 0: ToString value; } - """); - var derived = New(assembly, "Example.Derived"); - var baseType = assembly.GetType("Example.Base", true)!; - baseType.GetProperty("id")!.SetValue(derived, 7); - derived.GetType().GetProperty("id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)! - .SetValue(derived, "derived"); - var summary = derived.ToString()!; - Assert.Contains("Base.id = 7", summary); - Assert.Contains("Derived.id = \"derived\"", summary); - - var awkward = New(assembly, "Example.Awkward"); - Set(awkward, "ToString", 42); - Assert.Contains("ToString = 42", ModelSummary.Format(awkward)); - var companion = assembly.GetType("Example.AwkwardOperations", true)!; - Assert.Equal(ModelSummary.Format(awkward), companion.GetMethod("ToString", [awkward.GetType()])! - .Invoke(null, [awkward])); - Assert.Contains("ToString { id = 0 }", ModelSummary.Format(New(assembly, "Example.ToString"))); - - var generic = Activator.CreateInstance(assembly.GetType("Example.Generic`1", true)!.MakeGenericType(typeof(int)))!; - Set(generic, "value", 8); - Assert.Contains("value = 8", ModelSummary.Format(generic)); - } - - [Fact] - public async Task NumericOutputDoesNotDependOnCurrentCulture() - { - var previous = CultureInfo.CurrentCulture; - try - { - CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); - var assembly = await Compile("namespace Example struct Numbers { 0: double value = 1.25; }"); - var value = New(assembly, "Example.Numbers"); - Assert.Equal("Numbers { value = 1.25 }", value.ToString()); - - Set(value, "value", double.NaN); - Assert.Equal("Numbers { value = NaN }", value.ToString()); - } - finally - { - CultureInfo.CurrentCulture = previous; - } - } - - [Fact] - public void EnumerationAndOutputHaveHardLimits() - { - var values = new InfiniteValues(); - var summary = ModelSummary.Format(values); - Assert.Equal(16, values.Moves); - Assert.True(values.Disposed); - Assert.Contains("...", summary); - - var text = ModelSummary.Format(Enumerable.Repeat(new string('x', 160), 100).ToArray()); - Assert.True(text.Length <= 2048); - Assert.EndsWith("...", text); - } - - private sealed class InfiniteValues : IEnumerable, IEnumerator, IDisposable - { - public int Moves { get; private set; } - public bool Disposed { get; private set; } - - public object Current => Moves; - - public IEnumerator GetEnumerator() => this; - - public bool MoveNext() - { - Moves++; - return true; - } - - public void Reset() => throw new NotSupportedException(); - - public void Dispose() => Disposed = true; - } - - private static async Task Compile(string schema, string extra = "") - { - var parsed = await ParserFacade.ParseStringAsync(schema); - Assert.True(parsed.Success, string.Join("\n", parsed.Errors.Select(error => error.Message))); - - var result = CSharpGenerator.Generate(parsed.Ast!, "summary.bond", - new CSharpGenerationOptions { ModelFeatures = CSharpModelFeatures.StringRepresentation }); - Assert.True(result.Success, string.Join("\n", result.Errors.Select(error => error.Message))); - Assert.DoesNotContain("IModelAdapter", result.Code!); - Assert.DoesNotContain("SchemaDescriptor", result.Code!); - - return GeneratedCode.Compile(result.Code!, extra); - } - - private static object New(Assembly assembly, string name) => - Activator.CreateInstance(assembly.GetType(name, true)!)!; - - private static void Set(object value, string property, object? fieldValue) => - value.GetType().GetProperty(property)!.SetValue(value, fieldValue); -} diff --git a/tests/Bond.Models.Tests/ModelAdapterTests.cs b/tests/Bond.Models.Tests/ModelAdapterTests.cs deleted file mode 100644 index a93d264..0000000 --- a/tests/Bond.Models.Tests/ModelAdapterTests.cs +++ /dev/null @@ -1,318 +0,0 @@ -using System; -using System.Collections.Generic; -using BondTools.Models; - -namespace Bond.Models.Tests; - -public sealed class ModelAdapterTests -{ - [Fact] - public void ByteArrayAdaptersPreserveSharedStorageWithBlobs() - { - var source = new byte[] { 1, 2, 3 }; - var context = new CloneContext(); - var array = ModelAdapters.Value().Clone(source, context); - var segment = ModelAdapters.Blob.Clone(new ArraySegment(source, 1, 2), context); - Assert.NotSame(source, array); - Assert.Same(array, segment.Array); - Assert.True(ModelOperations.ValueEquals(source, array)); - Assert.Equal(ModelOperations.ValueHashCode(source), ModelOperations.ValueHashCode(array)); - } - - [Fact] - public void ImmutableDateAndDurationValuesCloneWithoutRegisteredAdapters() - { - var duration = TimeSpan.FromHours(3); - var date = new DateOnly(2024, 3, 5); - - Assert.Equal(duration, ModelOperations.Clone(duration)); - Assert.Equal(date, ModelOperations.Clone(date)); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public void MaterializedProjectionsDoNotShareCollectionEqualityOrHashCaches(bool projectionFirst) - { - var ordered = ModelAdapters.List(ModelAdapters.Value()); - var unordered = ModelAdapters.Materialized, HashSet>( - values => new HashSet(values), - values => new List(values), - ModelAdapters.Set(ModelAdapters.Value())); - var left = new List { 1, 2 }; - var right = new List { 2, 1 }; - var equality = new EqualityContext(); - var hashes = new HashContext(); - if (projectionFirst) - { - Assert.True(unordered.Equals(left, right, equality)); - Assert.False(ordered.Equals(left, right, equality)); - Assert.Equal(622, unordered.GetHashCode(left, hashes)); - Assert.Equal(507472, ordered.GetHashCode(left, hashes)); - } - else - { - Assert.False(ordered.Equals(left, right, equality)); - Assert.True(unordered.Equals(left, right, equality)); - Assert.Equal(507472, ordered.GetHashCode(left, hashes)); - Assert.Equal(622, unordered.GetHashCode(left, hashes)); - } - - Assert.Equal(ordered.GetHashCode(left, new HashContext()), ordered.GetHashCode(left, hashes)); - Assert.Equal(unordered.GetHashCode(left, new HashContext()), unordered.GetHashCode(left, hashes)); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public void MaterializedProjectionsDoNotShareCollectionCloneCaches(bool projectionFirst) - { - var ordered = ModelAdapters.List(ModelAdapters.Value()); - var projected = ModelAdapters.Materialized, int>( - values => values.Count, count => new List { count }, ModelAdapters.Value()); - var source = new List { 2, 1, 2 }; - var clones = new CloneContext(); - List orderedCopy; - List projectedCopy; - if (projectionFirst) - { - projectedCopy = projected.Clone(source, clones); - orderedCopy = ordered.Clone(source, clones); - } - else - { - orderedCopy = ordered.Clone(source, clones); - projectedCopy = projected.Clone(source, clones); - } - - Assert.Equal(source, orderedCopy); - Assert.Equal(new[] { 3 }, projectedCopy); - Assert.NotSame(source, orderedCopy); - Assert.NotSame(source, projectedCopy); - Assert.NotSame(orderedCopy, projectedCopy); - Assert.Same(orderedCopy, ordered.Clone(source, clones)); - Assert.Same(projectedCopy, projected.Clone(source, clones)); - projectedCopy.Add(99); - Assert.Equal(3, source.Count); - Assert.Equal(source, orderedCopy); - } - - [Fact] - public void SharedAndSeparateEqualListsHaveEqualHashesWithDifferentFieldAdapters() - { - var exact = ModelAdapters.List(ModelAdapters.Value()); - var offset = ModelAdapters.List(new ModelAdapter( - (value, context) => value, - (left, right, context) => left == right, - (value, context) => value + 100)); - var shared = new List { 1 }; - var separate = new List { 1 }; - var equality = new EqualityContext(); - Assert.True(exact.Equals(shared, shared, equality)); - Assert.True(offset.Equals(shared, separate, equality)); - - var sharedContext = new HashContext(); - var separateContext = new HashContext(); - var sharedHash = HashContext.Combine(exact.GetHashCode(shared, sharedContext), - offset.GetHashCode(shared, sharedContext)); - var separateHash = HashContext.Combine(exact.GetHashCode(shared, separateContext), - offset.GetHashCode(separate, separateContext)); - Assert.Equal(separateHash, sharedHash); - } - - [Fact] - public void ParityComparisonsDoNotContaminateExactComparisonsOrAcceptedBranches() - { - var parity = ModelAdapters.List(new ModelAdapter( - (value, context) => value, - (left, right, context) => (left & 1) == (right & 1), - (value, context) => value & 1)); - var exact = ModelAdapters.List(ModelAdapters.Value()); - var left = new List { 1 }; - var right = new List { 3 }; - var context = new EqualityContext(); - var branch = context.Fork(); - Assert.True(parity.Equals(left, right, branch)); - context.Accept(branch); - Assert.False(exact.Equals(left, right, context)); - Assert.False(exact.Equals(left, right, context.Fork())); - } - - [Fact] - public void CloneCachesSeparateDifferentAdaptersButShareEquivalentContainerAdapters() - { - var element = new ModelAdapter( - (value, context) => value + 100, - (left, right, context) => left == right, - (value, context) => value); - var source = new List { 1 }; - var context = new CloneContext(); - var exact = ModelAdapters.List(ModelAdapters.Value()).Clone(source, context); - var offset = ModelAdapters.List(element).Clone(source, context); - - Assert.Equal(1, exact[0]); - Assert.Equal(101, offset[0]); - Assert.NotSame(source, exact); - Assert.NotSame(source, offset); - Assert.NotSame(exact, offset); - Assert.Same(exact, ModelAdapters.List(ModelAdapters.Value()).Clone(source, context)); - Assert.Same(offset, ModelAdapters.List(element).Clone(source, context)); - } - - [Fact] - public void DelegateAdaptersScopePublicContextMemoizationAndPreserveCycles() - { - IModelAdapter? exact = null; - IModelAdapter? parity = null; - exact = CreateNodeAdapter(false, () => exact!); - parity = CreateNodeAdapter(true, () => parity!); - var left = new Node { Number = 1 }; - var right = new Node { Number = 3 }; - left.Next = left; - right.Next = right; - var comparisons = new EqualityContext(); - Assert.True(parity.Equals(left, right, comparisons)); - Assert.False(exact.Equals(left, right, comparisons)); - - var clones = new CloneContext(); - var exactCopy = exact.Clone(left, clones); - var parityCopy = parity.Clone(left, clones); - Assert.NotSame(left, exactCopy); - Assert.NotSame(left, parityCopy); - Assert.NotSame(exactCopy, parityCopy); - Assert.Same(exactCopy, exactCopy.Next); - Assert.Same(parityCopy, parityCopy.Next); - Assert.Same(exactCopy, exact.Clone(left, clones)); - } - - [Fact] - public void MaterializedAdaptersCaptureBindingsWithoutRetainingCloneCaches() - { - var materialized = ModelAdapters.Materialized>( - value => value.Value, - (value, adapter) => new LazyList(value, adapter), - ModelAdapters.Value>()); - var scoped = ModelAdapters.WithArguments(materialized, - ModelAdapterArgument.Create(ModelAdapters.List(ModelAdapters.Value()))); - var source = new LazyList(new List { 1 }, ModelAdapters.Value>()); - var originalContext = new CloneContext(); - var copy = scoped.Clone(source, originalContext); - Assert.NotSame(source.Value, copy.Value); - - var first = copy.Deserialize(); - var second = copy.Deserialize(); - Assert.NotSame(copy.Value, first); - Assert.NotSame(copy.Value, second); - Assert.NotSame(first, second); - first.Add(2); - Assert.Single(source.Value); - Assert.Single(copy.Value); - Assert.Single(second); - - var later = copy.Adapter.Clone(copy.Value, originalContext); - Assert.NotSame(first, later); - Assert.NotSame(second, later); - Assert.Throws(() => - ModelAdapters.Value>().Clone(new List(), originalContext)); - Assert.Throws(() => materialized.Clone(source, new CloneContext())); - } - - [Fact] - public void NestedMaterializedAdaptersPublishWrapperShellsBeforeCloningCyclicChildren() - { - IModelAdapter>>? outer = null; - var payloadAdapter = new ModelAdapter( - (value, context) => - { - if (context.TryGetClone(value, out var existing)) - { - return existing; - } - - var clone = new NestedPayload(); - context.Register(value, clone); - clone.Wrapper = outer!.Clone(value.Wrapper!, context); - return clone; - }, - (left, right, context) => true, - (value, context) => 0); - var inner = ModelAdapters.Materialized, NestedPayload>( - value => value.Value, value => new PayloadBox(value), payloadAdapter); - outer = ModelAdapters.Materialized>, PayloadBox>( - value => value.Value, value => new PayloadBox>(value), inner); - var payload = new NestedPayload(); - var source = new PayloadBox>(new PayloadBox(payload)); - payload.Wrapper = source; - - var copy = outer.Clone(source, new CloneContext()); - Assert.NotSame(source, copy); - Assert.NotSame(source.Value, copy.Value); - Assert.NotSame(payload, copy.Value.Value); - Assert.Same(copy, copy.Value.Value.Wrapper); - } - - [Fact] - public void ExactUriIsImmutableButMutableSubclassesRequireAnAdapter() - { - var exact = new Uri("https://example.test/value"); - Assert.Same(exact, ModelOperations.Clone(exact)); - Assert.True(ModelOperations.ValueEquals(exact, new Uri(exact.AbsoluteUri))); - Assert.Equal(exact.GetHashCode(), ModelOperations.ValueHashCode(exact)); - - var mutable = new MutableUri(); - mutable.Values.Add(7); - Assert.Throws(() => ModelOperations.Clone(mutable)); - Assert.Throws(() => ModelOperations.Clone(mutable)); - Assert.Throws(() => ModelOperations.Clone(mutable)); - Assert.Throws(() => ModelOperations.ValueEquals(mutable, new MutableUri())); - Assert.Throws(() => ModelOperations.ValueHashCode(mutable)); - } - - private static IModelAdapter CreateNodeAdapter(bool parity, Func> self) => - new ModelAdapter( - (value, context) => - { - if (context.TryGetClone(value, out var found)) - { - return found; - } - - var clone = new Node { Number = value.Number }; - context.Register(value, clone); - clone.Next = self().Clone(value.Next!, context); - return clone; - }, - (left, right, context) => context.CompareReferences(left, right, () => - (parity ? (left.Number & 1) == (right.Number & 1) : left.Number == right.Number) && - self().Equals(left.Next!, right.Next!, context)), - (value, context) => parity ? value.Number & 1 : value.Number); - - private sealed class Node - { - public int Number { get; set; } - public Node? Next { get; set; } - } - - private sealed class MutableUri() : Uri("https://example.test/value") - { - public List Values { get; } = new(); - } - - private sealed class LazyList(List value, IModelAdapter> adapter) - { - public List Value { get; } = value; - public IModelAdapter> Adapter { get; } = adapter; - - public List Deserialize() => Adapter.Clone(Value, new CloneContext()); - } - - private sealed class PayloadBox(T value) - { - public T Value { get; } = value; - } - - private sealed class NestedPayload - { - public PayloadBox>? Wrapper { get; set; } - } -} diff --git a/tests/Bond.Models.Tests/ModelBindingScopeTests.cs b/tests/Bond.Models.Tests/ModelBindingScopeTests.cs deleted file mode 100644 index 0a60474..0000000 --- a/tests/Bond.Models.Tests/ModelBindingScopeTests.cs +++ /dev/null @@ -1,311 +0,0 @@ -using System; -using System.Collections.Generic; -using BondTools.Models; - -namespace Bond.Models.Tests; - -public sealed class ModelBindingScopeTests -{ - [Fact] - public void RegisteredAdaptersTakePrecedenceOverContextualBindings() - { - var registered = new ModelAdapter( - (value, context) => new RegisteredValue(value.Number + 100), - (left, right, context) => left.Number == right.Number, - (value, context) => value.Number); - var contextual = new ModelAdapter( - (value, context) => throw new Exception("contextual clone was used"), - (left, right, context) => throw new Exception("contextual equality was used"), - (value, context) => throw new Exception("contextual hash was used")); - ModelOperations.RegisterAdapter(registered); - var adapter = ModelAdapters.WithArguments(ModelAdapters.List(ModelAdapters.Value()), - ModelAdapterArgument.Create(contextual)); - var source = new List { new(7) }; - var equivalent = new List { new(7) }; - - var copy = adapter.Clone(source, new CloneContext()); - - Assert.NotSame(source, copy); - Assert.NotSame(source[0], copy[0]); - Assert.Equal(107, copy[0].Number); - Assert.True(adapter.Equals(source, equivalent, new EqualityContext())); - Assert.Equal(ModelAdapters.List(registered).GetHashCode(source, new HashContext()), - adapter.GetHashCode(source, new HashContext())); - } - - [Fact] - public void MaterializedProjectionKeysUseRegisteredRatherThanContextualAdapters() - { - var clones = 0; - var comparisons = 0; - var hashes = 0; - var registered = new ModelAdapter( - (value, context) => - { - clones++; - return new ProjectedValue(value.Number + 100); - }, - (left, right, context) => - { - comparisons++; - return left.Number == right.Number; - }, - (value, context) => - { - hashes++; - return value.Number; - }); - var contextual = new ModelAdapter( - (value, context) => throw new Exception("contextual clone was used"), - (left, right, context) => throw new Exception("contextual equality was used"), - (value, context) => throw new Exception("contextual hash was used")); - ModelOperations.RegisterAdapter(registered); - var dispatched = ModelAdapters.Materialized( - box => box.Value, value => new ProjectedBox(value), ModelAdapters.Value()); - var explicitAdapter = ModelAdapters.Materialized( - box => box.Value, value => new ProjectedBox(value), registered); - var adapter = ModelAdapters.WithArguments(new ModelAdapter( - (value, context) => - { - var copy = dispatched.Clone(value, context); - Assert.Same(copy, explicitAdapter.Clone(value, context)); - return copy; - }, - (left, right, context) => - { - var equal = dispatched.Equals(left, right, context); - Assert.Equal(equal, explicitAdapter.Equals(left, right, context)); - return equal; - }, - (value, context) => - { - var hash = dispatched.GetHashCode(value, context); - Assert.Equal(hash, explicitAdapter.GetHashCode(value, context)); - return hash; - }), ModelAdapterArgument.Create(contextual)); - var source = new ProjectedBox(new ProjectedValue(7)); - var equivalent = new ProjectedBox(new ProjectedValue(7)); - - Assert.Equal(107, adapter.Clone(source, new CloneContext()).Value.Number); - Assert.True(adapter.Equals(source, equivalent, new EqualityContext())); - Assert.Equal(7, adapter.GetHashCode(source, new HashContext())); - Assert.Equal(1, clones); - Assert.Equal(1, comparisons); - Assert.Equal(1, hashes); - } - - [Fact] - public void DispatchGuardsDoNotInvokeRegisteredAdapters() - { - ModelOperations.RegisterAdapter(new ModelAdapter( - (value, context) => throw new Exception("clone guard invoked the adapter"), - (left, right, context) => throw new Exception("equality guard invoked the adapter"), - (value, context) => throw new Exception("hash guard invoked the adapter"))); - var adapter = ModelAdapters.Value(); - var value = new GuardedValue(); - var exhausted = new HashContext(); - while (exhausted.RemainingDepth > 0) - { - exhausted = exhausted.Descend(); - } - - Assert.Null(adapter.Clone(null!, new CloneContext())); - Assert.True(adapter.Equals(value, value, new EqualityContext())); - Assert.True(adapter.Equals(null!, null!, new EqualityContext())); - Assert.False(adapter.Equals(value, null!, new EqualityContext())); - Assert.False(adapter.Equals(null!, value, new EqualityContext())); - Assert.Equal(0, adapter.GetHashCode(null!, new HashContext())); - Assert.Equal(0, adapter.GetHashCode(value, exhausted)); - } - - [Fact] - public void NestedArgumentScopesInheritBindingsButCapturedScopesReplaceThem() - { - var parity = OffsetParityAdapter(); - var ordinary = ModelAdapters.List(ModelAdapters.Value()); - var nested = ModelAdapters.WithArguments(ordinary, ModelAdapterArgument.Create(ModelAdapters.Value())); - var inherited = ModelAdapters.WithArguments(nested, ModelAdapterArgument.Create(parity)); - var materialized = ModelAdapters.Materialized>( - value => value.Value, (value, adapter) => new CapturedList(value, adapter), - ModelAdapters.Value>()); - var capture = ModelAdapters.WithArguments(materialized, ModelAdapterArgument.Create(ordinary)) - .Clone(new CapturedList([1], ordinary), new CloneContext()); - var dispatcher = ModelAdapters.Value(); - var capturedOperations = new ModelAdapter>( - (value, context) => - { - var copy = capture.Adapter.Clone(value, context); - Assert.Equal(101, dispatcher.Clone(1, context)); - return copy; - }, - (left, right, context) => - { - var equal = capture.Adapter.Equals(left, right, context); - Assert.True(dispatcher.Equals(1, 3, context)); - return equal; - }, - (value, context) => - { - var hash = capture.Adapter.GetHashCode(value, context); - Assert.Equal(101, dispatcher.GetHashCode(1, context)); - return hash; - }); - var captured = ModelAdapters.WithArguments(capturedOperations, ModelAdapterArgument.Create(parity)); - var left = new List { 1 }; - var right = new List { 3 }; - - var inheritedCopy = inherited.Clone(left, new CloneContext()); - var capturedCopy = captured.Clone(left, new CloneContext()); - - Assert.Equal(new[] { 101 }, inheritedCopy); - Assert.Equal(new[] { 1 }, capturedCopy); - Assert.NotSame(left, inheritedCopy); - Assert.NotSame(left, capturedCopy); - Assert.True(inherited.Equals(left, right, new EqualityContext())); - Assert.False(captured.Equals(left, right, new EqualityContext())); - Assert.Equal(ModelAdapters.List(parity).GetHashCode(left, new HashContext()), - inherited.GetHashCode(left, new HashContext())); - Assert.Equal(ordinary.GetHashCode(left, new HashContext()), - captured.GetHashCode(left, new HashContext())); - } - - [Fact] - public void NestedArgumentScopesMaskMatchingOuterBindingsWithoutRecursing() - { - var ordinary = ModelAdapters.List(ModelAdapters.Value()); - var inner = ModelAdapters.WithArguments(ordinary, ModelAdapterArgument.Create(ModelAdapters.Value())); - var outer = ModelAdapters.WithArguments(inner, ModelAdapterArgument.Create(OffsetParityAdapter())); - var left = new List { 1 }; - var right = new List { 3 }; - - Assert.Equal(new[] { 1 }, outer.Clone(left, new CloneContext())); - Assert.False(outer.Equals(left, right, new EqualityContext())); - Assert.Equal(ordinary.GetHashCode(left, new HashContext()), outer.GetHashCode(left, new HashContext())); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public void FailingBindingScopesRestoreOuterBindingsForEveryOperation(bool captureBindings) - { - var fail = false; - var inner = new ModelAdapter>( - (value, context) => fail ? throw new InvalidOperationException("clone failed") : new List(value), - (left, right, context) => throw new InvalidOperationException("equality failed"), - (value, context) => throw new InvalidOperationException("hash failed")); - IModelAdapter> scoped; - if (captureBindings) - { - var materialized = ModelAdapters.Materialized>( - value => value.Value, (value, adapter) => new CapturedList(value, adapter), inner); - var capture = ModelAdapters.WithArguments(materialized, - ModelAdapterArgument.Create(ModelAdapters.Value())); - scoped = capture.Clone(new CapturedList([1], inner), new CloneContext()).Adapter; - } - else - { - scoped = ModelAdapters.WithArguments(inner, ModelAdapterArgument.Create(ModelAdapters.Value())); - } - - fail = true; - var dispatcher = ModelAdapters.Value(); - var outer = ModelAdapters.WithArguments(new ModelAdapter>( - (value, context) => - { - var error = Assert.Throws(() => scoped.Clone(value, context)); - Assert.Equal("clone failed", error.Message); - return new List { dispatcher.Clone(value[0], context) }; - }, - (left, right, context) => - { - var error = Assert.Throws(() => scoped.Equals(left, right, context)); - Assert.Equal("equality failed", error.Message); - return dispatcher.Equals(left[0], right[0], context); - }, - (value, context) => - { - var error = Assert.Throws(() => scoped.GetHashCode(value, context)); - Assert.Equal("hash failed", error.Message); - return dispatcher.GetHashCode(value[0], context); - }), ModelAdapterArgument.Create(OffsetParityAdapter())); - var source = new List { 1 }; - var equivalent = new List { 3 }; - var clones = new CloneContext(); - var equality = new EqualityContext(); - var hashes = new HashContext(); - - Assert.Equal(new[] { 101 }, outer.Clone(source, clones)); - Assert.True(outer.Equals(source, equivalent, equality)); - Assert.Equal(101, outer.GetHashCode(source, hashes)); - - Assert.Equal(1, dispatcher.Clone(1, clones)); - Assert.False(dispatcher.Equals(1, 3, equality)); - Assert.Equal(1, dispatcher.GetHashCode(1, hashes)); - } - - [Fact] - public void HashCachesShareResultsAtTheSameDepthAndSeparateDepthsAndSemantics() - { - var calls = 0; - var adapter = ModelAdapters.List(new ModelAdapter( - (value, context) => value, - (left, right, context) => left == right, - (value, context) => - { - calls++; - return context.RemainingDepth; - })); - var other = ModelAdapters.List(new ModelAdapter( - (value, context) => value, - (left, right, context) => left == right, - (value, context) => context.RemainingDepth + 100)); - var source = new List { 1 }; - var hashes = new HashContext(); - - var rootHash = adapter.GetHashCode(source, hashes); - var childHash = adapter.GetHashCode(source, hashes.Descend()); - - Assert.NotEqual(rootHash, childHash); - Assert.Equal(rootHash, adapter.GetHashCode(source, hashes)); - Assert.Equal(childHash, adapter.GetHashCode(source, hashes.Descend())); - Assert.Equal(2, calls); - - var otherRootHash = other.GetHashCode(source, hashes); - var otherChildHash = other.GetHashCode(source, hashes.Descend()); - Assert.NotEqual(rootHash, otherRootHash); - Assert.NotEqual(childHash, otherChildHash); - Assert.Equal(other.GetHashCode(source, new HashContext()), otherRootHash); - Assert.Equal(other.GetHashCode(source, new HashContext().Descend()), otherChildHash); - Assert.Equal(rootHash, adapter.GetHashCode(source, hashes)); - Assert.Equal(childHash, adapter.GetHashCode(source, hashes.Descend())); - Assert.Equal(2, calls); - } - - private static IModelAdapter OffsetParityAdapter() => new ModelAdapter( - (value, context) => value + 100, - (left, right, context) => (left & 1) == (right & 1), - (value, context) => (value & 1) + 100); - - private sealed class RegisteredValue(int number) - { - public int Number { get; } = number; - } - - private sealed class GuardedValue { } - - private sealed class ProjectedValue(int number) - { - public int Number { get; } = number; - } - - private sealed class ProjectedBox(ProjectedValue value) - { - public ProjectedValue Value { get; } = value; - } - - private sealed class CapturedList(List value, IModelAdapter> adapter) - { - public List Value { get; } = value; - public IModelAdapter> Adapter { get; } = adapter; - } -} diff --git a/tests/Bond.Parser.Tests/Bond.Parser.Tests.csproj b/tests/Bond.Parser.Tests/Bond.Parser.Tests.csproj index 9ca4198..4457661 100644 --- a/tests/Bond.Parser.Tests/Bond.Parser.Tests.csproj +++ b/tests/Bond.Parser.Tests/Bond.Parser.Tests.csproj @@ -29,7 +29,6 @@ - diff --git a/tests/Bond.Parser.Tests/CSharpGeneratorTests.cs b/tests/Bond.Parser.Tests/CSharpGeneratorTests.cs index 929d51c..79aa179 100644 --- a/tests/Bond.Parser.Tests/CSharpGeneratorTests.cs +++ b/tests/Bond.Parser.Tests/CSharpGeneratorTests.cs @@ -253,21 +253,11 @@ public async Task GenerationIsDeterministicAndIndependentOfInputPath() Assert.NotNull(first.Code); Assert.Equal($"{CSharpGenerator.GeneratedHeader}\n// bond-tools {CSharpGenerator.Version}\n", first.Code[..first.Code.IndexOf("#nullable", StringComparison.Ordinal)]); - Assert.DoesNotContain("BondTools.Models", first.Code!); Assert.DoesNotContain("\r", first.Code!); Assert.True(first.Code!.IndexOf("Bond.Id(1)", StringComparison.Ordinal) < first.Code.IndexOf("Bond.Id(9)", StringComparison.Ordinal)); } - [Fact] - public async Task RichOutputIdentifiesItsMatchingSupportPackage() - { - var code = await Generate("namespace Example struct Item { 0: int32 id; }"); - Assert.Equal($"{CSharpGenerator.GeneratedHeader}\n// bond-tools {CSharpGenerator.Version}\n" + - $"// BondTools.Models {CSharpGenerator.Version}\n", - code[..code.IndexOf("#nullable", StringComparison.Ordinal)]); - } - [Theory] [InlineData("enum Invalid { value__ }", "cannot be represented")] [InlineData("struct Same { 0: int32 Same; }", "same name")] diff --git a/tests/Bond.Parser.Tests/CSharpMappingFeatureTests.cs b/tests/Bond.Parser.Tests/CSharpMappingFeatureTests.cs index 1cdf6b0..adaf01d 100644 --- a/tests/Bond.Parser.Tests/CSharpMappingFeatureTests.cs +++ b/tests/Bond.Parser.Tests/CSharpMappingFeatureTests.cs @@ -1,12 +1,9 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; -using System.Reflection; using System.Threading.Tasks; using Bond.Parser.CodeGeneration; using Bond.Parser.Parser; -using BondTools.Models; using static Bond.TestSupport.GeneratedCode; namespace Bond.Parser.Tests; @@ -14,7 +11,7 @@ namespace Bond.Parser.Tests; public sealed class CSharpMappingFeatureTests { public static IEnumerable FeatureSelections() => - Enumerable.Range(0, 32).Select(value => new object[] { (CSharpModelFeatures)value }); + Enumerable.Range(0, (int)CSharpModelFeatures.All + 1).Select(value => new object[] { (CSharpModelFeatures)value }); [Theory] [MemberData(nameof(FeatureSelections))] @@ -23,30 +20,15 @@ public async Task FeaturesAreIndependentAndPlainModelsRemainTheDefault(CSharpMod var result = await GenerateResult("namespace Example struct Item { 0: int32 id; }", new CSharpGenerationOptions { ModelFeatures = features }); Assert.True(result.Success, Messages(result)); - var assembly = Compile(result.Code!); - var type = assembly.GetType("Example.Item", true)!; - Assert.Equal(features.HasFlag(CSharpModelFeatures.Descriptors), - assembly.GetType("Example.ItemSchema") != null); - Assert.Equal(features.HasFlag(CSharpModelFeatures.Descriptors), - typeof(IGeneratedSchemaProvider).IsAssignableFrom(type)); - Assert.Equal(features.HasFlag(CSharpModelFeatures.Cloning), - typeof(ICloneable).IsAssignableFrom(type)); - Assert.Equal(features.HasFlag(CSharpModelFeatures.Equality), - typeof(IGeneratedEquatable).IsAssignableFrom(type)); - Assert.Equal(features.HasFlag(CSharpModelFeatures.Debugger), - type.GetCustomAttribute() != null); - Assert.Equal(features.HasFlag(CSharpModelFeatures.StringRepresentation), - typeof(IGeneratedSummary).IsAssignableFrom(type)); + var type = Compile(result.Code!).GetType("Example.Item", true)!; + Assert.Equal(features.HasFlag(CSharpModelFeatures.Cloning), typeof(ICloneable).IsAssignableFrom(type)); + Assert.Equal(features.HasFlag(CSharpModelFeatures.Cloning), type.GetMethod("Clone", Type.EmptyTypes) != null); Assert.Equal(features.HasFlag(CSharpModelFeatures.StringRepresentation), type.GetMethod("ToString", Type.EmptyTypes)!.DeclaringType == type); Assert.Equal(features.HasFlag(CSharpModelFeatures.Equality), type.GetMethod("Equals", [typeof(object)])!.DeclaringType == type); Assert.Equal(features.HasFlag(CSharpModelFeatures.Equality), type.GetMethod("GetHashCode", Type.EmptyTypes)!.DeclaringType == type); - if (features == CSharpModelFeatures.None) - { - Assert.DoesNotContain("BondTools.Models", result.Code!); - } var value = Activator.CreateInstance(type)!; type.GetProperty("id")!.SetValue(value, 42); @@ -61,7 +43,6 @@ public async Task FeaturesAreIndependentAndPlainModelsRemainTheDefault(CSharpMod [Theory] [InlineData(CSharpModelFeatures.Cloning)] [InlineData(CSharpModelFeatures.Equality)] - [InlineData(CSharpModelFeatures.Debugger)] [InlineData(CSharpModelFeatures.StringRepresentation)] public async Task IncompleteBaseDefinitionsCannotSilentlyProducePartialOperations(CSharpModelFeatures feature) { @@ -75,16 +56,13 @@ public async Task IncompleteBaseDefinitionsCannotSilentlyProducePartialOperation Assert.Contains(generated.Errors, error => error.Message.Contains("base 'Example.Base'", StringComparison.Ordinal)); } - [Theory] - [InlineData(CSharpModelFeatures.None)] - [InlineData(CSharpModelFeatures.Descriptors)] - public async Task ExternalBasesStillSupportPlainModelsAndSymbolicDescriptors(CSharpModelFeatures feature) + [Fact] + public async Task ExternalBasesStillSupportPlainModels() { var parsed = await ParserFacade.ParseStringAsync("namespace Example struct Base; struct Child : Base {}"); Assert.True(parsed.Success); - var generated = CSharpGenerator.Generate(parsed.Ast!, "child.bond", - new CSharpGenerationOptions { ModelFeatures = feature }); + var generated = CSharpGenerator.Generate(parsed.Ast!, "child.bond"); Assert.True(generated.Success, string.Join("\n", generated.Errors.Select(error => error.Message))); Compile(generated.Code!, """ namespace Example { @@ -102,17 +80,9 @@ public async Task NamespaceMappingsApplyExactlyAndKeepIdlIdentity() namespace Contracts namespace csharp Application struct Item { 0: int32 id; } - """, new CSharpGenerationOptions - { - ModelFeatures = CSharpModelFeatures.Descriptors, - NamespaceMappings = ["Application=Renamed"] - }); + """, new CSharpGenerationOptions { NamespaceMappings = ["Application=Renamed"] }); Assert.True(result.Success, Messages(result)); - var assembly = Compile(result.Code!); - var type = assembly.GetType("Renamed.Item", true)!; - var descriptor = ((IGeneratedSchemaProvider)Activator.CreateInstance(type)!).Descriptor; - Assert.Equal("Contracts.Item", descriptor.FullName); - Assert.Equal("global::Renamed.Item", descriptor.ClrName); + var type = Compile(result.Code!).GetType("Renamed.Item", true)!; Assert.Contains("\"qualified_name\":\"Contracts.Item\"", SchemaJson(type)); } @@ -305,7 +275,6 @@ struct Item { ] }); Assert.True(result.Success, Messages(result)); - Assert.DoesNotContain("BondTools.Models", result.Code!); var assembly = Compile(result.Code!, """ namespace Example { @@ -406,8 +375,8 @@ public class Item [InlineData("type", "Example.Value=System.Collections.Generic.List<{x}>")] [InlineData("type", "Example.Value=System.Collections.Generic.List<{1}>")] [InlineData("type", "Example.Value=System.Collections.Generic.List<")] - [InlineData("type", "Example.Value=UnqualifiedType")] - [InlineData("type", "Example.Value=global::int")] + [InlineData("type", "Example.Value=System.Collections.Generic.List>")] + [InlineData("type", "Example.Value=System.DateTime\nclass Injected {}")] public async Task MalformedMappingsFailBeforeEmittingCode(string kind, string specification) { var options = kind == "namespace" diff --git a/tests/Bond.Parser.Tests/CSharpModelFeatureTests.cs b/tests/Bond.Parser.Tests/CSharpModelFeatureTests.cs new file mode 100644 index 0000000..9d4c1fd --- /dev/null +++ b/tests/Bond.Parser.Tests/CSharpModelFeatureTests.cs @@ -0,0 +1,296 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Bond.Parser.CodeGeneration; +using Bond.Parser.Parser; +using static Bond.TestSupport.GeneratedCode; + +namespace Bond.Parser.Tests; + +public sealed class CSharpModelFeatureTests +{ + [Fact] + public async Task CloneCopiesMutableDataAndKeepsTheRuntimeType() + { + await Run(""" + namespace Models + struct Leaf { 0: int32 value; 1: vector items; } + struct Tree { + 0: vector leaves; + 1: list names; + 2: set ids; + 3: map> groups; + 4: nullable single; + 5: blob bytes; + 6: vector chunks; + 7: nullable> absent; + } + """, """ + var leaf = new Models.Leaf { value = 1 }; + leaf.items.Add(1); + var tree = new Models.Tree { single = leaf, bytes = new ArraySegment(new byte[] { 9, 1, 2, 9 }, 1, 2) }; + tree.leaves.Add(leaf); + tree.names.AddLast("a"); + tree.ids.Add(7); + tree.groups["g"] = new List { leaf }; + tree.chunks.Add(new ArraySegment(new byte[] { 3 })); + + var copy = tree.Clone(); + Require(copy.Equals(tree), "clone differs"); + Require(!ReferenceEquals(copy.leaves, tree.leaves) && !ReferenceEquals(copy.leaves[0], leaf), "vector shared"); + Require(!ReferenceEquals(copy.leaves[0].items, leaf.items), "nested vector shared"); + Require(!ReferenceEquals(copy.names, tree.names) && !ReferenceEquals(copy.ids, tree.ids), "list or set shared"); + Require(!ReferenceEquals(copy.groups["g"], tree.groups["g"]) && !ReferenceEquals(copy.groups["g"][0], leaf), "map shared"); + Require(!ReferenceEquals(copy.single, leaf), "nullable struct shared"); + Require(copy.bytes.Array != tree.bytes.Array && copy.bytes.SequenceEqual(new byte[] { 1, 2 }), "blob shared"); + Require(copy.chunks[0].Array != tree.chunks[0].Array, "nested blob shared"); + Require(copy.absent == null, "null container was materialized"); + + leaf.items.Add(2); + Require(!copy.Equals(tree), "clone observed a mutation"); + Require(((ICloneable)new Derived()).Clone() is Derived, "runtime type lost"); + """, extra: "class Derived : Models.Leaf { }"); + } + + [Fact] + public async Task EqualityAndHashFollowValueSemantics() + { + await Run(""" + namespace Models + struct Value { + 0: double number; + 1: float single; + 2: map lookup; + 3: set flags; + 4: vector sequence; + 5: int32 absent = nothing; + 6: nullable maybe; + 7: blob bytes = nothing; + 8: map> nested; + } + """, """ + var left = new Models.Value { number = double.NaN, single = -0.0f }; + var right = new Models.Value + { + number = BitConverter.Int64BitsToDouble(unchecked((long)0xfff8000000000001)), + single = 0.0f + }; + left.lookup.Add("a", 1); left.lookup.Add("b", 2); + right.lookup.Add("b", 2); right.lookup.Add("a", 1); + left.flags.UnionWith(new[] { 3, 1, 2 }); + right.flags.UnionWith(new[] { 2, 1, 3 }); + left.sequence.AddRange(new[] { 1, 2 }); + right.sequence.AddRange(new[] { 1, 2 }); + left.nested["x"] = new List> { new ArraySegment(new byte[] { 1, 2 }) }; + right.nested["x"] = new List> { new ArraySegment(new byte[] { 0, 1, 2 }, 1, 2) }; + Require(left.Equals(right) && right.Equals((object)left), "value equality ignored .NET or unordered semantics"); + Require(left.GetHashCode() == right.GetHashCode(), "equal values hashed differently"); + + right.sequence.Reverse(); + Require(!left.Equals(right), "sequence order was ignored"); + right.sequence.Reverse(); + right.absent = 0; + Require(!left.Equals(right), "nothing was confused with zero"); + right.absent = null; + right.maybe = 0; + Require(!left.Equals(right), "nullable absence was confused with zero"); + right.maybe = null; + right.bytes = new ArraySegment(Array.Empty()); + Require(!left.Equals(right), "default blob was confused with an empty blob"); + right.bytes = default; + right.lookup = null; + Require(!left.Equals(right) && !right.Equals(left), "null map equaled a map"); + right.lookup = new Dictionary { ["a"] = 1, ["b"] = 3 }; + Require(!left.Equals(right), "map values were ignored"); + right.lookup["b"] = 2; + Require(left.Equals(right), "restored values differed"); + Require(!left.Equals(null) && !left.Equals(new Derived()), "model equaled null or another runtime type"); + + var large = new Models.Value(); + var reversed = new Models.Value(); + for (var i = 0; i < 20000; i++) { large.lookup["k" + i] = i; reversed.lookup["k" + (19999 - i)] = 19999 - i; } + Require(large.Equals(reversed) && large.GetHashCode() == reversed.GetHashCode(), "large maps differed"); + Require(new Derived().GetHashCode() == new Derived().GetHashCode(), "subclass hashing failed"); + """, extra: "class Derived : Models.Value { }"); + } + + [Fact] + public async Task InheritedHiddenAndGenericFieldsParticipate() + { + await Run(""" + namespace Models + struct Item { 0: int32 id; } + struct Base { 0: string id; 1: vector values; } + struct Derived : Base { 0: string id; 1: nullable item; } + """, """ + var value = new Models.Derived { id = "derived" }; + ((Models.Base)value).id = "base"; + value.values.Add(new Models.Item { id = 1 }); + + var copy = value.Clone(); + Require(((Models.Base)copy).id == "base" && copy.id == "derived", "hidden fields not copied"); + Require(!ReferenceEquals(copy.values[0], value.values[0]), "generic struct values were shared"); + Require(copy.Equals(value) && copy.GetHashCode() == value.GetHashCode(), "copy differs"); + + ((Models.Base)copy).id = "other"; + Require(!copy.Equals(value), "hidden base field was ignored"); + Require(value.ToString() == "Derived { Base.id = \"base\", values = [Item { id = 1 }], Derived.id = \"derived\", item = null }", + value.ToString()); + + value.item = new Models.Item { id = 2 }; + var viaBase = ((Models.Base)value).Clone() as Models.Derived; + Require(viaBase != null && viaBase.Equals(value) && !ReferenceEquals(viaBase.item, value.item), "base Clone() shared derived fields"); + """); + } + + [Fact] + public async Task BondedPayloadsAreSharedByCloneAndComparedByValue() + { + await Run(""" + namespace Models + struct Payload { 0: string text; } + struct Envelope { 0: bonded payload; } + struct Box { 0: bonded payload; 1: vector> many; } + """, """ + Require(new Models.Box().Equals(new Models.Box()), "generic bonded payloads differed"); + var envelope = new Models.Envelope { payload = new Bond.Bonded(new Models.Payload { text = "a" }) }; + var output = new Bond.IO.Safe.OutputBuffer(); + Bond.Serialize.To(new Bond.Protocols.CompactBinaryWriter(output), envelope); + var restored = Bond.Deserialize.From( + new Bond.Protocols.CompactBinaryReader(new Bond.IO.Safe.InputBuffer(output.Data))); + + Require(envelope.Equals(restored) && envelope.GetHashCode() == restored.GetHashCode(), "bonded payloads differed"); + Require(ReferenceEquals(envelope.Clone().payload, envelope.payload), "bonded payload was not shared"); + Require(envelope.ToString() == "Envelope { payload = bonded }", envelope.ToString()); + """); + } + + [Fact] + public async Task ToStringIsCompactAndCultureInvariant() + { + await Run(""" + namespace Models + enum Color { Red, Green } + struct Point { 0: double x; 1: Color color = Green; } + struct Shape { + 0: string name; + 1: vector points; + 2: map labels; + 3: blob data; + 4: nullable center; + 5: set tags; + } + struct Empty {} + """, """ + var culture = System.Globalization.CultureInfo.CurrentCulture; + System.Globalization.CultureInfo.CurrentCulture = new System.Globalization.CultureInfo("it-IT"); + try + { + var shape = new Models.Shape { name = "box", data = new ArraySegment(new byte[] { 1, 2, 3 }) }; + shape.points.Add(new Models.Point { x = 1.5 }); + shape.labels[1] = null; + var text = shape.ToString(); + Require(text == "Shape { name = \"box\", points = [Point { x = 1.5, color = Green }], labels = {1 = null}, " + + "data = blob[3], center = null, tags = [] }", text); + Require(new Models.Empty().ToString() == "Empty { }", new Models.Empty().ToString()); + } + finally + { + System.Globalization.CultureInfo.CurrentCulture = culture; + } + """); + } + + [Theory] + [MemberData(nameof(Fixtures))] + public async Task EveryFixtureCompilesWithAllFeatures(string fixture) + { + var directory = Path.Combine(AppContext.BaseDirectory, "Fixtures"); + ImportResolver resolver = async (currentFile, importPath) => + { + var relative = importPath.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar); + var local = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(currentFile)!, relative)); + var path = File.Exists(local) ? local : Path.Combine(directory, "imports", relative); + return (path, await File.ReadAllTextAsync(path, TestContext.Current.CancellationToken)); + }; + var parsed = await ParserFacade.ParseFileAsync(Path.Combine(directory, fixture), resolver, + TestContext.Current.CancellationToken); + Assert.True(parsed.Success, string.Join("\n", parsed.Errors.Select(error => error.Message))); + + var result = CSharpGenerator.Generate(parsed.Ast!, fixture, + new CSharpGenerationOptions { ModelFeatures = CSharpModelFeatures.All }); + Assert.True(result.Success, string.Join("\n", result.Errors.Select(error => error.Message))); + + var external = """ + namespace tests { [Bond.Schema] public partial class Bar {} } + namespace empty { [Bond.Schema] public partial class Empty {} } + """; + foreach (var type in Compile(result.Code!, external).GetTypes().Where(type => + typeof(ICloneable).IsAssignableFrom(type) && !type.IsGenericTypeDefinition)) + { + var value = Activator.CreateInstance(type)!; + var copy = ((ICloneable)value).Clone(); + Assert.NotSame(value, copy); + Assert.True(value.Equals(copy), type.FullName); + Assert.Equal(value.GetHashCode(), copy.GetHashCode()); + Assert.StartsWith(type.Name, value.ToString()); + } + } + + // complex_inheritance is invalid, and schemadef inherits from a type parameter, which C# cannot represent. + public static TheoryData Fixtures() => new(Directory + .GetFiles(Path.Combine(AppContext.BaseDirectory, "Fixtures"), "*.bond") + .Select(path => Path.GetFileName(path)) + .Where(name => name is not ("complex_inheritance.bond" or "schemadef.bond")) + .Order(StringComparer.Ordinal)); + + [Theory] + [InlineData(CSharpModelFeatures.Cloning, "Clone", "struct Base { 0: int32 Clone; } struct Item : Base {}")] + [InlineData(CSharpModelFeatures.Equality, "Equals", "struct Base { 0: int32 Equals; } struct Item : Base {}")] + [InlineData(CSharpModelFeatures.Equality, "GetHashCode", "struct Item { 0: int32 GetHashCode; }")] + [InlineData(CSharpModelFeatures.StringRepresentation, "ToString", "struct Item { 0: int32 ToString; }")] + [InlineData(CSharpModelFeatures.Cloning, "Clone", "struct Clone { 0: int32 value; }")] + [InlineData(CSharpModelFeatures.StringRepresentation, "ToString", "struct Item { 0: int32 value; }")] + public async Task NamesCannotShadowGeneratedMembers(CSharpModelFeatures feature, string name, string declarations) + { + var parsed = await ParserFacade.ParseStringAsync("namespace Models " + declarations); + Assert.True(parsed.Success); + + var result = CSharpGenerator.Generate(parsed.Ast!, "input.bond", new CSharpGenerationOptions { ModelFeatures = feature }); + Assert.False(result.Success); + Assert.Contains(result.Errors, error => error.Message.Contains($"generated {name}()", StringComparison.Ordinal)); + Assert.True(CSharpGenerator.Generate(parsed.Ast!, "input.bond").Success); + } + + private static async Task Run(string schema, string body, string extra = "") + { + var scenario = $$""" + using System; + using System.Collections.Generic; + using System.Linq; + + public static class Scenario + { + private static void Require(bool condition, string message) + { + if (!condition) + { + throw new Exception(message); + } + } + + public static void Run() + { + {{body}} + } + } + + {{extra}} + """; + + var assembly = Compile(await Generate(schema), scenario); + var run = assembly.GetType("Scenario", true)!.GetMethod("Run")!.CreateDelegate(); + run(); + } +} diff --git a/tests/Shared/GeneratedCode.cs b/tests/Shared/GeneratedCode.cs index 1d1d3bc..623fea0 100644 --- a/tests/Shared/GeneratedCode.cs +++ b/tests/Shared/GeneratedCode.cs @@ -35,8 +35,7 @@ public static Assembly Compile(params string[] sources) .Concat([ typeof(global::Bond.SchemaAttribute).Assembly.Location, typeof(global::Bond.Serializer<>).Assembly.Location, - typeof(SimpleJsonWriter).Assembly.Location, - typeof(global::BondTools.Models.SchemaDescriptor).Assembly.Location + typeof(SimpleJsonWriter).Assembly.Location ]).Distinct(StringComparer.Ordinal); var compilation = CSharpCompilation.Create( diff --git a/version b/version index f0bb29e..88c5fb8 100644 --- a/version +++ b/version @@ -1 +1 @@ -1.3.0 +1.4.0