From fa65c1542c4107e2562c1f34182814edce06a8f0 Mon Sep 17 00:00:00 2001 From: Ark Tarusov Date: Fri, 31 Jul 2026 18:23:41 +0200 Subject: [PATCH 01/10] ignore .net build output and pin lf line endings under tools --- .gitignore | 9 +++++++++ tools/.gitattributes | 3 +++ 2 files changed, 12 insertions(+) create mode 100644 .gitignore create mode 100644 tools/.gitattributes diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0374046 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# .NET build output of the tools; scoped so that a "bin" or "obj" directory of +# skill content elsewhere in the repository is still tracked. +tools/**/bin/ +tools/**/obj/ + +# IDE state for the tool solution. +.idea/ +.vs/ +*.user diff --git a/tools/.gitattributes b/tools/.gitattributes new file mode 100644 index 0000000..6cdb072 --- /dev/null +++ b/tools/.gitattributes @@ -0,0 +1,3 @@ +# The tool sources are compared against string literals that embed the line endings of the file they +# are written in, so a checkout that rewrote them to CRLF would break the tests. +* text=auto eol=lf From b750a8be6a786a889e3aa773008a855cb93e4b84 Mon Sep 17 00:00:00 2001 From: Ark Tarusov Date: Fri, 31 Jul 2026 21:47:12 +0200 Subject: [PATCH 02/10] fetch and unpack package versions from the upm registry --- .../CommandRefGen.Tests.csproj | 21 ++++ .../HighestVersionTests.cs | 50 +++++++++ .../PackageExtractionTests.cs | 91 ++++++++++++++++ tools/command-ref-gen/CommandRefGen.sln | 48 +++++++++ .../CommandRefGen/CommandRefGen.csproj | 16 +++ .../CommandRefGen/PackageRegistry.cs | 101 ++++++++++++++++++ 6 files changed, 327 insertions(+) create mode 100644 tools/command-ref-gen/CommandRefGen.Tests/CommandRefGen.Tests.csproj create mode 100644 tools/command-ref-gen/CommandRefGen.Tests/HighestVersionTests.cs create mode 100644 tools/command-ref-gen/CommandRefGen.Tests/PackageExtractionTests.cs create mode 100644 tools/command-ref-gen/CommandRefGen.sln create mode 100644 tools/command-ref-gen/CommandRefGen/CommandRefGen.csproj create mode 100644 tools/command-ref-gen/CommandRefGen/PackageRegistry.cs diff --git a/tools/command-ref-gen/CommandRefGen.Tests/CommandRefGen.Tests.csproj b/tools/command-ref-gen/CommandRefGen.Tests/CommandRefGen.Tests.csproj new file mode 100644 index 0000000..e2c9809 --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen.Tests/CommandRefGen.Tests.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + + + + + + diff --git a/tools/command-ref-gen/CommandRefGen.Tests/HighestVersionTests.cs b/tools/command-ref-gen/CommandRefGen.Tests/HighestVersionTests.cs new file mode 100644 index 0000000..44713d1 --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen.Tests/HighestVersionTests.cs @@ -0,0 +1,50 @@ +using Xunit; + +namespace CommandRefGen.Tests; + +/// +/// Resolving `latest` decides which package version gets documented, so a wrong answer here silently +/// documents the wrong release. The ordering itself comes from NuGet.Versioning; what these cases pin is +/// that UPM's version strings mean to it what the tool assumes they mean. +/// +public class HighestVersionTests +{ + [Fact] + public void Picks_the_highest_of_the_versions_the_registry_publishes() => + Assert.Equal( + "0.4.0-exp.1", + PackageRegistry.HighestVersion( + new[] { "0.2.0-exp.2", "0.4.0-exp.1", "0.3.1-exp.1", "0.3.0-exp.1" }, + _ => Assert.Fail("no version should have been excluded"))); + + [Theory] + [InlineData("1.0.0", "1.0.0-exp.1")] + [InlineData("0.10.0", "0.9.9")] + [InlineData("1.0.0", "0.99.99")] + [InlineData("1.0.0-exp.10", "1.0.0-exp.9")] + [InlineData("1.0.0-beta", "1.0.0-1")] + public void Orders_the_shapes_upm_publishes(string higher, string lower) => + Assert.Equal(higher, PackageRegistry.HighestVersion(new[] { lower, higher }, _ => { })); + + [Fact] + public void Answers_with_the_string_the_registry_published() + { + // The result is looked up in the registry's own version map, so a normalised "1.2.0" would find + // nothing where the registry published "1.2". + Assert.Equal("1.2", PackageRegistry.HighestVersion(new[] { "1.1.9", "1.2" }, _ => { })); + } + + [Fact] + public void Excludes_and_reports_a_version_it_cannot_order() + { + var warnings = new List(); + var highest = PackageRegistry.HighestVersion(new[] { "1.0.0", "1.0.x" }, warnings.Add); + + Assert.Equal("1.0.0", highest); + Assert.Contains(warnings, w => w.Contains("1.0.x")); + } + + [Fact] + public void Fails_when_no_version_can_be_ordered() => + Assert.Throws(() => PackageRegistry.HighestVersion(new[] { "nightly" }, _ => { })); +} diff --git a/tools/command-ref-gen/CommandRefGen.Tests/PackageExtractionTests.cs b/tools/command-ref-gen/CommandRefGen.Tests/PackageExtractionTests.cs new file mode 100644 index 0000000..0101a14 --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen.Tests/PackageExtractionTests.cs @@ -0,0 +1,91 @@ +using System.Formats.Tar; +using System.IO.Compression; +using System.Text; +using Xunit; + +namespace CommandRefGen.Tests; + +/// +/// The tarball is downloaded from a public registry, so its entry names are untrusted input to a write +/// loop. These tests pin what the extraction actually does with them. +/// +public class PackageExtractionTests : IDisposable +{ + private readonly string root = Path.Combine(Path.GetTempPath(), "commandrefgen-tests", Guid.NewGuid().ToString("N")); + + [Fact] + public async Task Unpacks_the_package_directory_npm_tarballs_nest_everything_under() + { + var archive = TarGz(("package/Editor/Commands/Thing.cs", "// source")); + + var packageRoot = await PackageRegistry.ExtractAsync(archive, root, CancellationToken.None); + + Assert.Equal(Path.Combine(root, "package"), packageRoot); + Assert.Equal("// source", await File.ReadAllTextAsync(Path.Combine(packageRoot, "Editor", "Commands", "Thing.cs"))); + } + + [Fact] + public async Task Falls_back_to_the_destination_when_the_archive_has_no_package_directory() + { + var archive = TarGz(("Thing.cs", "// source")); + + Assert.Equal(root, await PackageRegistry.ExtractAsync(archive, root, CancellationToken.None)); + } + + [Fact] + public async Task Reports_a_body_that_is_not_an_archive_at_all() + { + // A truncated download, or a proxy answering 200 with an HTML page in place of the tarball. The + // exception type is what the entry point's catch filter has to name to turn this into an error + // message rather than a stack trace. + var notAnArchive = new MemoryStream("Sign in to continue"u8.ToArray()); + + await Assert.ThrowsAsync(() => PackageRegistry.ExtractAsync(notAnArchive, root, CancellationToken.None)); + } + + [Fact] + public async Task Refuses_an_entry_that_would_land_outside_the_destination() + { + var archive = TarGz(("../escaped.txt", "owned")); + + await Assert.ThrowsAnyAsync(() => PackageRegistry.ExtractAsync(archive, root, CancellationToken.None)); + Assert.False(File.Exists(Path.Combine(root, "..", "escaped.txt"))); + } + + private static Stream TarGz(params (string Name, string Content)[] entries) + { + var tar = new MemoryStream(); + using (var writer = new TarWriter(tar, leaveOpen: true)) + { + foreach (var (name, content) in entries) + { + var entry = new PaxTarEntry(TarEntryType.RegularFile, name) + { + DataStream = new MemoryStream(Encoding.UTF8.GetBytes(content)), + }; + writer.WriteEntry(entry); + } + } + + tar.Position = 0; + var gzipped = new MemoryStream(); + using (var gzip = new GZipStream(gzipped, CompressionMode.Compress, leaveOpen: true)) + tar.CopyTo(gzip); + + gzipped.Position = 0; + return gzipped; + } + + public void Dispose() + { + try + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // A scanner still holding a freshly written file must not fail a test that already passed. + } + } +} diff --git a/tools/command-ref-gen/CommandRefGen.sln b/tools/command-ref-gen/CommandRefGen.sln new file mode 100644 index 0000000..c972629 --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen.sln @@ -0,0 +1,48 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommandRefGen", "CommandRefGen\CommandRefGen.csproj", "{DF5F36C2-6F50-4042-B1A5-E52A3494DE50}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommandRefGen.Tests", "CommandRefGen.Tests\CommandRefGen.Tests.csproj", "{57C832DE-D615-45B7-ADFE-67E5301948DC}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {DF5F36C2-6F50-4042-B1A5-E52A3494DE50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DF5F36C2-6F50-4042-B1A5-E52A3494DE50}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DF5F36C2-6F50-4042-B1A5-E52A3494DE50}.Debug|x64.ActiveCfg = Debug|Any CPU + {DF5F36C2-6F50-4042-B1A5-E52A3494DE50}.Debug|x64.Build.0 = Debug|Any CPU + {DF5F36C2-6F50-4042-B1A5-E52A3494DE50}.Debug|x86.ActiveCfg = Debug|Any CPU + {DF5F36C2-6F50-4042-B1A5-E52A3494DE50}.Debug|x86.Build.0 = Debug|Any CPU + {DF5F36C2-6F50-4042-B1A5-E52A3494DE50}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DF5F36C2-6F50-4042-B1A5-E52A3494DE50}.Release|Any CPU.Build.0 = Release|Any CPU + {DF5F36C2-6F50-4042-B1A5-E52A3494DE50}.Release|x64.ActiveCfg = Release|Any CPU + {DF5F36C2-6F50-4042-B1A5-E52A3494DE50}.Release|x64.Build.0 = Release|Any CPU + {DF5F36C2-6F50-4042-B1A5-E52A3494DE50}.Release|x86.ActiveCfg = Release|Any CPU + {DF5F36C2-6F50-4042-B1A5-E52A3494DE50}.Release|x86.Build.0 = Release|Any CPU + {57C832DE-D615-45B7-ADFE-67E5301948DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {57C832DE-D615-45B7-ADFE-67E5301948DC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {57C832DE-D615-45B7-ADFE-67E5301948DC}.Debug|x64.ActiveCfg = Debug|Any CPU + {57C832DE-D615-45B7-ADFE-67E5301948DC}.Debug|x64.Build.0 = Debug|Any CPU + {57C832DE-D615-45B7-ADFE-67E5301948DC}.Debug|x86.ActiveCfg = Debug|Any CPU + {57C832DE-D615-45B7-ADFE-67E5301948DC}.Debug|x86.Build.0 = Debug|Any CPU + {57C832DE-D615-45B7-ADFE-67E5301948DC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {57C832DE-D615-45B7-ADFE-67E5301948DC}.Release|Any CPU.Build.0 = Release|Any CPU + {57C832DE-D615-45B7-ADFE-67E5301948DC}.Release|x64.ActiveCfg = Release|Any CPU + {57C832DE-D615-45B7-ADFE-67E5301948DC}.Release|x64.Build.0 = Release|Any CPU + {57C832DE-D615-45B7-ADFE-67E5301948DC}.Release|x86.ActiveCfg = Release|Any CPU + {57C832DE-D615-45B7-ADFE-67E5301948DC}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/tools/command-ref-gen/CommandRefGen/CommandRefGen.csproj b/tools/command-ref-gen/CommandRefGen/CommandRefGen.csproj new file mode 100644 index 0000000..0bcd5bf --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen/CommandRefGen.csproj @@ -0,0 +1,16 @@ + + + + net8.0 + enable + enable + CommandRefGen + true + true + + + + + + + diff --git a/tools/command-ref-gen/CommandRefGen/PackageRegistry.cs b/tools/command-ref-gen/CommandRefGen/PackageRegistry.cs new file mode 100644 index 0000000..a46faa1 --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen/PackageRegistry.cs @@ -0,0 +1,101 @@ +using System.Formats.Tar; +using System.IO.Compression; +using System.Text.Json; +using NuGet.Versioning; + +namespace CommandRefGen; + +/// +/// Talks to the public Unity UPM registry: lists published versions and unpacks a version's tarball. +/// No authentication and no Unity installation are involved — the package sources are the only input +/// the reference is built from. +/// +public sealed class PackageRegistry(HttpClient http, string registryBaseUrl) +{ + /// Registry metadata for one package: every published version and its tarball URL. + public sealed record PackageVersions(IReadOnlyDictionary Tarballs, string? LatestTag); + + /// Fetches the package document and extracts the version → tarball-URL map. + public async Task FetchVersionsAsync(string packageName, CancellationToken cancellationToken) + { + var url = $"{registryBaseUrl.TrimEnd('/')}/{packageName}"; + await using var stream = await http.GetStreamAsync(url, cancellationToken); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + + if (!document.RootElement.TryGetProperty("versions", out var versions)) + throw new InvalidOperationException($"{url}: registry document has no 'versions' object"); + + var tarballs = new Dictionary(StringComparer.Ordinal); + foreach (var version in versions.EnumerateObject()) + { + if (!version.Value.TryGetProperty("dist", out var dist) || + !dist.TryGetProperty("tarball", out var tarball) || + tarball.GetString() is not { Length: > 0 } tarballUrl) + { + throw new InvalidOperationException($"{url}: version {version.Name} has no dist.tarball"); + } + + tarballs[version.Name] = tarballUrl; + } + + string? latestTag = null; + if (document.RootElement.TryGetProperty("dist-tags", out var tags) && + tags.TryGetProperty("latest", out var latest)) + { + latestTag = latest.GetString(); + } + + return new PackageVersions(tarballs, latestTag); + } + + /// Downloads the tarball and unpacks it into . + /// The package root inside the archive (npm tarballs nest everything under package/). + public async Task DownloadAndExtractAsync(string tarballUrl, string destination, CancellationToken cancellationToken) + { + await using var response = await http.GetStreamAsync(tarballUrl, cancellationToken); + return await ExtractAsync(response, destination, cancellationToken); + } + + /// + /// Unpacks a gzipped tar into . + /// + /// The archive comes off the network, so its entry names are untrusted input to a write loop; an + /// entry pointing outside the destination makes TarFile throw rather than write there, which + /// the test suite pins. + /// + /// The package root inside the archive (npm tarballs nest everything under package/). + public static async Task ExtractAsync(Stream gzippedTar, string destination, CancellationToken cancellationToken) + { + Directory.CreateDirectory(destination); + + await using var gzip = new GZipStream(gzippedTar, CompressionMode.Decompress); + await TarFile.ExtractToDirectoryAsync(gzip, destination, overwriteFiles: true, cancellationToken); + + var packageRoot = Path.Combine(destination, "package"); + return Directory.Exists(packageRoot) ? packageRoot : destination; + } + + /// + /// Picks the highest published version. UPM declares semantic versioning, so the ordering is + /// NuGetVersion's: a prerelease sorts below the release it precedes, and prerelease + /// identifiers compare numerically where they are numbers. A version the parser rejects cannot be + /// ordered against the rest, so it is reported and left out rather than silently treated as zero. + /// + public static string HighestVersion(IEnumerable versions, Action warn) + { + var ordered = new List(); + foreach (var version in versions) + { + if (NuGetVersion.TryParse(version, out var parsed)) + ordered.Add(parsed); + else + warn($"version '{version}' is not a semantic version — excluded when resolving 'latest'"); + } + + if (ordered.Count == 0) + throw new InvalidOperationException("the registry lists no version this tool can order"); + + // OriginalVersion, not ToString(): the registry is keyed by the exact string it published. + return ordered.Max()!.OriginalVersion!; + } +} From be64f8d3769f42e97b1c9c64a3a94c5641baebeb Mon Sep 17 00:00:00 2001 From: Ark Tarusov Date: Sat, 1 Aug 2026 19:05:56 +0200 Subject: [PATCH 03/10] model commands and fold the constant expressions their attributes use --- .../ConstantEvaluatorTests.cs | 93 ++++++++ .../CommandRefGen/CommandModel.cs | 38 ++++ .../CommandRefGen/CommandRefGen.csproj | 1 + .../CommandRefGen/ConstantEvaluator.cs | 205 ++++++++++++++++++ tools/command-ref-gen/CommandRefGen/Prose.cs | 23 ++ 5 files changed, 360 insertions(+) create mode 100644 tools/command-ref-gen/CommandRefGen.Tests/ConstantEvaluatorTests.cs create mode 100644 tools/command-ref-gen/CommandRefGen/CommandModel.cs create mode 100644 tools/command-ref-gen/CommandRefGen/ConstantEvaluator.cs create mode 100644 tools/command-ref-gen/CommandRefGen/Prose.cs diff --git a/tools/command-ref-gen/CommandRefGen.Tests/ConstantEvaluatorTests.cs b/tools/command-ref-gen/CommandRefGen.Tests/ConstantEvaluatorTests.cs new file mode 100644 index 0000000..21adcb5 --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen.Tests/ConstantEvaluatorTests.cs @@ -0,0 +1,93 @@ +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Xunit; + +namespace CommandRefGen.Tests; + +/// +/// Constant folding decides what the reference prints as an argument's default, so a wrong answer here +/// is a wrong default in published documentation. +/// +public class ConstantEvaluatorTests +{ + [Theory] + [InlineData("\"idle\"", "idle")] + [InlineData("\"a\" + \"b\"", "ab")] + [InlineData("@\"raw\\path\"", @"raw\path")] + public void Folds_string_expressions(string expression, string expected) => + Assert.Equal(expected, Evaluate(expression)); + + [Theory] + [InlineData("42", 42)] + [InlineData("-7", -7)] + [InlineData("1024 * 1024", 1024 * 1024)] + [InlineData("(2 + 3) * 4", 20)] + [InlineData("1 << 10", 1024)] + public void Folds_integer_arithmetic(string expression, int expected) => + Assert.Equal(expected, Evaluate(expression)); + + [Fact] + public void Folds_boolean_and_null() => + Assert.Equal(new object?[] { true, false, null }, new[] { Evaluate("true"), Evaluate("false"), Evaluate("null") }); + + [Fact] + public void Reads_limits_of_the_primitive_types() => + Assert.Equal(float.MinValue, Evaluate("float.MinValue")); + + [Fact] + public void Follows_a_constant_declared_in_another_file() + { + var evaluator = EvaluatorFor( + "class Levels { public const string Default = \"log\"; }", + "class Other { }"); + + Assert.Equal("log", evaluator.Evaluate(SyntaxFactory.ParseExpression("Levels.Default"))); + } + + [Fact] + public void Follows_a_chain_of_constants() + { + var evaluator = EvaluatorFor( + "class Sizes { public const int Kilobyte = 1024; public const int Megabyte = Kilobyte * 1024; }"); + + Assert.Equal(1024 * 1024, evaluator.Evaluate(SyntaxFactory.ParseExpression("Sizes.Megabyte"))); + } + + [Fact] + public void Resolves_an_unqualified_constant_against_the_enclosing_type() + { + // The default is read where it appears in the source, so the enclosing type is what makes the + // bare name resolvable — evaluating the same text out of context could not work. + var source = "class Commands { const int Tail = 100; static void Run(int tail = Tail) { } }"; + var evaluator = EvaluatorFor(source); + var tree = CSharpSyntaxTree.ParseText(source); + var parameterDefault = tree.GetRoot().DescendantNodes().OfType().Single().Default!.Value; + + Assert.Equal(100, evaluator.Evaluate(parameterDefault)); + } + + [Theory] + [InlineData("SomeCall()")] + [InlineData("Missing.Constant")] + [InlineData("1.5 * 2")] + public void Refuses_to_guess_at_what_it_cannot_fold(string expression) => + Assert.Throws(() => Evaluate(expression)); + + [Fact] + public void Reports_a_constant_declared_twice_with_different_values() + { + var warnings = new List(); + Index(warnings.Add, "class A { public const int X = 1; }", "class A { public const int X = 2; }"); + + Assert.Contains(warnings, w => w.Contains("A.X")); + } + + private static object? Evaluate(string expression) => + EvaluatorFor().Evaluate(SyntaxFactory.ParseExpression(expression)); + + private static ConstantEvaluator EvaluatorFor(params string[] sources) => + new(Index(_ => { }, sources)); + + private static Dictionary Index(Action warn, params string[] sources) => + ConstantEvaluator.IndexConstants(sources.Select(s => CSharpSyntaxTree.ParseText(s).GetRoot()), warn); +} diff --git a/tools/command-ref-gen/CommandRefGen/CommandModel.cs b/tools/command-ref-gen/CommandRefGen/CommandModel.cs new file mode 100644 index 0000000..077da96 --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen/CommandModel.cs @@ -0,0 +1,38 @@ +namespace CommandRefGen; + +/// One [CliArg]-described parameter of a command. +/// CLI argument name (--name value). +/// Human text, whitespace already collapsed. +/// .NET type name as the running server reports it (Type.Name), e.g. String, Boolean, Single[]. +/// True when the argument must be supplied. +/// Default as a JSON literal (e.g. "idle", true, 0), or null when there is none. +/// +/// Fields of the nested object this argument carries, empty for a plain value. A structured argument is +/// passed as a JSON object, and its members are what the caller actually has to fill in. +/// +public sealed record CommandArg( + string Name, + string Description, + string Type, + bool Required, + string? DefaultValue, + IReadOnlyList Members); + +/// One [CliCommand]-marked method found in the package sources. +/// Command name as typed on the CLI. +/// Human text, whitespace already collapsed. +/// False when the command runs while the main thread is busy. +/// True when the editor server hides the command from its listing. +/// Arguments in declaration order. +/// Preprocessor conditions the method is compiled under, outermost first. +/// Package-relative path of the declaring file, forward slashes. +/// 1-based line of the attribute in that file. +public sealed record CommandInfo( + string Name, + string Description, + bool MainThreadRequired, + bool RuntimeOnly, + IReadOnlyList Args, + IReadOnlyList Gates, + string SourcePath, + int SourceLine); diff --git a/tools/command-ref-gen/CommandRefGen/CommandRefGen.csproj b/tools/command-ref-gen/CommandRefGen/CommandRefGen.csproj index 0bcd5bf..16088d2 100644 --- a/tools/command-ref-gen/CommandRefGen/CommandRefGen.csproj +++ b/tools/command-ref-gen/CommandRefGen/CommandRefGen.csproj @@ -10,6 +10,7 @@ + diff --git a/tools/command-ref-gen/CommandRefGen/ConstantEvaluator.cs b/tools/command-ref-gen/CommandRefGen/ConstantEvaluator.cs new file mode 100644 index 0000000..1594be6 --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen/ConstantEvaluator.cs @@ -0,0 +1,205 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace CommandRefGen; + +/// Raised when an attribute argument is not a compile-time constant this tool can read. +public sealed class UnsupportedExpressionException(string message) : Exception(message); + +/// +/// Folds the constant expressions that appear inside [CliCommand] / [CliArg] arguments and +/// parameter defaults: literals, parenthesised expressions, string concatenation, integer arithmetic, +/// const fields (including ones declared in another file of the package), and the limit constants +/// of the primitive types. Anything else throws instead of being guessed at — a wrong default in the +/// reference is worse than a failed run. +/// +/// All Type.Field constants declared by the package. +public sealed class ConstantEvaluator(IReadOnlyDictionary constants) +{ + private const int MaxDepth = 16; + + /// Evaluates to a boxed constant (string, bool, number, or null). + public object? Evaluate(ExpressionSyntax expression) => Evaluate(expression, 0); + + private object? Evaluate(ExpressionSyntax expression, int depth) + { + if (depth > MaxDepth) + throw Unsupported(expression, "constant reference chain is too deep (a cycle?)"); + + switch (expression) + { + // `default` is a literal whose token value is the word itself; folding it would print + // (default "default") for what the server reports as 0, null or false. + case LiteralExpressionSyntax literal when literal.IsKind(SyntaxKind.DefaultLiteralExpression): + throw Unsupported(literal, "`default` does not name a value this tool can print"); + + case DefaultExpressionSyntax: + throw Unsupported(expression, "`default(T)` does not name a value this tool can print"); + + case LiteralExpressionSyntax literal: + return literal.Token.Value; + + case ParenthesizedExpressionSyntax parenthesized: + return Evaluate(parenthesized.Expression, depth + 1); + + case CastExpressionSyntax cast: + // e.g. (float)1 — the cast target does not change the value the reference prints. + return Evaluate(cast.Expression, depth + 1); + + case PrefixUnaryExpressionSyntax unary when unary.IsKind(SyntaxKind.UnaryMinusExpression): + return Negate(Evaluate(unary.Operand, depth + 1), unary); + + case PrefixUnaryExpressionSyntax unary when unary.IsKind(SyntaxKind.UnaryPlusExpression): + return Evaluate(unary.Operand, depth + 1); + + case BinaryExpressionSyntax binary: + return EvaluateBinary(binary, depth); + + case IdentifierNameSyntax identifier: + return Evaluate(ResolveUnqualified(identifier), depth + 1); + + case MemberAccessExpressionSyntax member: + return EvaluateMemberAccess(member, depth); + + default: + throw Unsupported(expression, $"unsupported expression kind {expression.Kind()}"); + } + } + + /// + /// Indexes every const field of the parsed sources by Type.Field. Namespaces are dropped, + /// so two same-named types would collide; that is reported rather than resolved silently. + /// + public static Dictionary IndexConstants(IEnumerable roots, Action warn) + { + var index = new Dictionary(StringComparer.Ordinal); + + foreach (var root in roots) + foreach (var type in root.DescendantNodes().OfType()) + foreach (var field in type.Members.OfType().Where(f => f.Modifiers.Any(SyntaxKind.ConstKeyword))) + foreach (var variable in field.Declaration.Variables.Where(v => v.Initializer is not null)) + { + var key = $"{type.Identifier.ValueText}.{variable.Identifier.ValueText}"; + if (index.TryGetValue(key, out var existing)) + { + if (existing.ToString() != variable.Initializer!.Value.ToString()) + warn($"constant {key} is declared more than once with different values — using `{existing}`"); + continue; + } + + index[key] = variable.Initializer!.Value; + } + + return index; + } + + /// + /// Handles the two shapes that occur in command metadata: a description split across source lines with + /// '+', and a size constant written as arithmetic (1024 * 1024). + /// + private object EvaluateBinary(BinaryExpressionSyntax binary, int depth) + { + var left = Evaluate(binary.Left, depth + 1); + var right = Evaluate(binary.Right, depth + 1); + + if (binary.IsKind(SyntaxKind.AddExpression) && (left is string || right is string)) + return string.Concat(left, right); + + if (left is null || right is null) + throw Unsupported(binary, "arithmetic on a null constant"); + + // Integer arithmetic covers every numeric constant the package writes; a fractional operand would + // silently lose precision here, so it is rejected instead. + if (left is not (int or long) || right is not (int or long)) + throw Unsupported(binary, "arithmetic is only supported on integer constants"); + + var a = Convert.ToInt64(left); + var b = Convert.ToInt64(right); + var result = binary.Kind() switch + { + SyntaxKind.AddExpression => a + b, + SyntaxKind.SubtractExpression => a - b, + SyntaxKind.MultiplyExpression => a * b, + SyntaxKind.DivideExpression when b != 0 => a / b, + SyntaxKind.LeftShiftExpression => a << (int)b, + SyntaxKind.RightShiftExpression => a >> (int)b, + _ => throw Unsupported(binary, $"unsupported operator {binary.OperatorToken.Text}"), + }; + + // Keep the narrower type when both operands were ints, so the printed default matches the source. + // A conditional expression would widen the int branch back to long, hence the explicit return. + if (left is int && right is int && result is >= int.MinValue and <= int.MaxValue) + return (int)result; + + return result; + } + + /// Resolves float.MinValue-style limits and SomeType.SomeConst references. + private object? EvaluateMemberAccess(MemberAccessExpressionSyntax member, int depth) + { + var typeName = member.Expression.ToString(); + var memberName = member.Name.Identifier.ValueText; + + var limit = PrimitiveLimit(typeName, memberName); + if (limit is not null) + return limit; + + // A qualified name may carry a namespace; the index is keyed by the simple type name. + var simpleTypeName = typeName[(typeName.LastIndexOf('.') + 1)..]; + if (constants.TryGetValue($"{simpleTypeName}.{memberName}", out var initializer)) + return Evaluate(initializer, depth + 1); + + throw Unsupported( + member, + $"'{simpleTypeName}.{memberName}' is not a const field of this package — an enum member or a " + + "computed value has no text this tool can print"); + } + + private static object? PrimitiveLimit(string typeName, string memberName) => (typeName, memberName) switch + { + ("float" or "Single", "MinValue") => float.MinValue, + ("float" or "Single", "MaxValue") => float.MaxValue, + ("float" or "Single", "Epsilon") => float.Epsilon, + ("double" or "Double", "MinValue") => double.MinValue, + ("double" or "Double", "MaxValue") => double.MaxValue, + ("double" or "Double", "Epsilon") => double.Epsilon, + ("int" or "Int32", "MinValue") => int.MinValue, + ("int" or "Int32", "MaxValue") => int.MaxValue, + ("long" or "Int64", "MinValue") => long.MinValue, + ("long" or "Int64", "MaxValue") => long.MaxValue, + ("string" or "String", "Empty") => string.Empty, + _ => null, + }; + + /// Resolves a bare identifier against the const fields of the types enclosing it. + private ExpressionSyntax ResolveUnqualified(IdentifierNameSyntax identifier) + { + var name = identifier.Identifier.ValueText; + + foreach (var type in identifier.Ancestors().OfType()) + { + if (constants.TryGetValue($"{type.Identifier.ValueText}.{name}", out var initializer)) + return initializer; + } + + throw Unsupported(identifier, $"no const field '{name}' is in scope"); + } + + private static object Negate(object? value, ExpressionSyntax context) => value switch + { + int i => -i, + long l => -l, + float f => -f, + double d => -d, + decimal m => -m, + _ => throw Unsupported(context, "unary minus on a non-numeric constant"), + }; + + private static UnsupportedExpressionException Unsupported(ExpressionSyntax expression, string reason) + { + var location = expression.GetLocation().GetLineSpan(); + return new UnsupportedExpressionException( + $"{location.Path}({location.StartLinePosition.Line + 1}): cannot evaluate `{expression}` — {reason}"); + } +} diff --git a/tools/command-ref-gen/CommandRefGen/Prose.cs b/tools/command-ref-gen/CommandRefGen/Prose.cs new file mode 100644 index 0000000..2da64b8 --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen/Prose.cs @@ -0,0 +1,23 @@ +using System.Text.RegularExpressions; + +namespace CommandRefGen; + +/// Text shaping shared by the parser and the writer. +public static partial class Prose +{ + /// + /// Descriptions are never truncated — the cut-off details (default shader names, selector syntax) + /// are exactly what a reader needs. Past this length the generator only complains, so an accidental + /// wall of text in the package sources is visible instead of silently reshaping the reference. + /// + public const int LongDescriptionThreshold = 1000; + + [GeneratedRegex(@"\s+")] + private static partial Regex WhitespaceRun(); + + /// + /// Collapses newlines and whitespace runs into single spaces so that a multi-line source description + /// still renders as one markdown list item. Nothing is dropped. + /// + public static string Collapse(string value) => WhitespaceRun().Replace(value, " ").Trim(); +} From f9b648230803f4b5d5810fc39115091cfe8c26a1 Mon Sep 17 00:00:00 2001 From: Ark Tarusov Date: Sat, 1 Aug 2026 22:38:07 +0200 Subject: [PATCH 04/10] read the [CliCommand] surface from the sources with roslyn --- .../CommandRefGen.Tests/CommandParserTests.cs | 321 ++++++++++++ .../StructuredInputTests.cs | 335 +++++++++++++ .../CommandRefGen/CommandParser.cs | 469 ++++++++++++++++++ .../CommandRefGen/StructuredInputReader.cs | 333 +++++++++++++ 4 files changed, 1458 insertions(+) create mode 100644 tools/command-ref-gen/CommandRefGen.Tests/CommandParserTests.cs create mode 100644 tools/command-ref-gen/CommandRefGen.Tests/StructuredInputTests.cs create mode 100644 tools/command-ref-gen/CommandRefGen/CommandParser.cs create mode 100644 tools/command-ref-gen/CommandRefGen/StructuredInputReader.cs diff --git a/tools/command-ref-gen/CommandRefGen.Tests/CommandParserTests.cs b/tools/command-ref-gen/CommandRefGen.Tests/CommandParserTests.cs new file mode 100644 index 0000000..228a321 --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen.Tests/CommandParserTests.cs @@ -0,0 +1,321 @@ +using Xunit; + +namespace CommandRefGen.Tests; + +/// +/// The parser decides every name, type, default and flag that reaches the reference. Running it against +/// the real package proves it agrees with that package, not that it applies the rules the package's +/// CommandRegistry applies — where the two happen to coincide today, only a written-out case can +/// tell them apart. +/// +public class CommandParserTests : IDisposable +{ + private readonly string packageRoot = Path.Combine(Path.GetTempPath(), "commandrefgen-tests", Guid.NewGuid().ToString("N")); + private readonly List warnings = new(); + + [Fact] + public void Reads_the_name_description_and_flags_from_the_attribute() + { + var command = Assert.Single(Parse(""" + [CliCommand("editor_play", "Enter play mode", MainThreadRequired = false)] + public static void Play() { } + """)); + + Assert.Equal("editor_play", command.Name); + Assert.Equal("Enter play mode", command.Description); + Assert.False(command.MainThreadRequired); + Assert.False(command.RuntimeOnly); + } + + [Fact] + public void Defaults_the_attribute_flags_the_way_the_attribute_declares_them() + { + var command = Assert.Single(Parse(""" + [CliCommand("editor_play", "Enter play mode")] + public static void Play() { } + """)); + + Assert.True(command.MainThreadRequired); + Assert.False(command.RuntimeOnly); + } + + [Fact] + public void Collapses_a_description_split_across_lines() + { + var command = Assert.Single(Parse(""" + [CliCommand("menu", + "Execute a menu item, " + + "or list the available ones")] + public static void Menu() { } + """)); + + Assert.Equal("Execute a menu item, or list the available ones", command.Description); + } + + [Fact] + public void Prefers_the_csharp_default_over_the_one_on_the_attribute() + { + // CommandRegistry.DiscoverParameters reads param.DefaultValue first and only falls back to the + // attribute, so a disagreement between the two must resolve towards the C# default. + var command = Assert.Single(Parse(""" + [CliCommand("console", "Read the console")] + public static void Console( + [CliArg("tail", "How many entries", DefaultValue = 25)] int tail = 100) { } + """)); + + Assert.Equal("100", Assert.Single(command.Args).DefaultValue); + } + + [Fact] + public void Falls_back_to_the_attribute_default_when_the_parameter_has_none() + { + var command = Assert.Single(Parse(""" + [CliCommand("console", "Read the console")] + public static void Console( + [CliArg("level", "Minimum severity", DefaultValue = "warn")] string level) { } + """)); + + Assert.Equal("\"warn\"", Assert.Single(command.Args).DefaultValue); + } + + [Fact] + public void Marks_an_argument_required_exactly_as_the_server_would() + { + var args = Assert.Single(Parse(""" + [CliCommand("open_scene", "Open a scene")] + public static void Open( + [CliArg("path", "Scene path", Required = true)] string path = "", + [CliArg("additive", "Load additively")] bool additive = false, + int bare) { } + """)).Args; + + // With an attribute the flag decides, even against a C# default; without one, having no C# + // default is what makes the argument required. + Assert.Equal(new[] { true, false, true }, args.Select(a => a.Required)); + } + + [Fact] + public void Names_and_describes_an_unattributed_parameter_after_itself() + { + var arg = Assert.Single(Assert.Single(Parse(""" + [CliCommand("thing", "Do a thing")] + public static void Thing(string somePath) { } + """)).Args); + + Assert.Equal("somePath", arg.Name); + Assert.Equal("Parameter: somePath", arg.Description); + } + + [Theory] + [InlineData("string", "String")] + [InlineData("bool", "Boolean")] + [InlineData("float", "Single")] + [InlineData("int?", "Int32")] + [InlineData("float[][]", "Single[][]")] + [InlineData("ObjectRef", "ObjectRef")] + public void Renders_the_declared_type_the_way_the_listing_does(string declared, string expected) + { + var arg = Assert.Single(Assert.Single(Parse($$""" + [CliCommand("thing", "Do a thing")] + public static void Thing([CliArg("value", "A value")] {{declared}} value) { } + """)).Args); + + Assert.Equal(expected, arg.Type); + } + + [Theory] + [InlineData("int tail = default")] + [InlineData("int tail = default(int)")] + public void Refuses_a_default_that_names_no_value(string parameter) + { + // `default` parses as a literal whose token text is the word itself; folding it would print + // (default "default") where the server reports 0. + Assert.Throws(() => Parse($$""" + [CliCommand("console", "Read the console")] + public static void Console([CliArg("tail", "How many entries")] {{parameter}}) { } + """)); + } + + [Fact] + public void Reads_a_name_and_description_written_as_named_arguments() + { + var command = Assert.Single(Parse(""" + [CliCommand(description: "Enter play mode", name: "editor_play")] + public static void Play() { } + """)); + + Assert.Equal("editor_play", command.Name); + Assert.Equal("Enter play mode", command.Description); + } + + [Fact] + public void Separates_the_commands_the_editor_hides_from_its_listing() + { + var commands = Parse(""" + [CliCommand("editor_play", "Enter play mode")] + public static void Play() { } + + [CliCommand("quit", "Quit the application", RuntimeOnly = true)] + public static void Quit() { } + """); + + Assert.Equal(new[] { false, true }, commands.OrderBy(c => c.Name).Select(c => c.RuntimeOnly)); + } + + [Fact] + public void Records_the_condition_a_command_is_compiled_under() + { + var command = Assert.Single(Parse(""" + #if UNITY_6000_7_OR_NEWER + [CliCommand("capture_editor_element", "Capture an element")] + public static void Capture() { } + #endif + """)); + + Assert.Equal(new[] { "UNITY_6000_7_OR_NEWER" }, command.Gates); + } + + [Fact] + public void Reports_a_command_left_in_an_inactive_branch() + { + // Every symbol a file mentions is defined, so the #else body never reaches the tree; dropping it + // without a word would take a command out of the reference invisibly. + var commands = Parse(""" + #if UNITY_6000_7_OR_NEWER + [CliCommand("modern", "New way")] + public static void Modern() { } + #else + [CliCommand("legacy", "Old way")] + public static void Legacy() { } + #endif + """); + + Assert.Equal("modern", Assert.Single(commands).Name); + Assert.Contains(warnings, w => w.Contains("inactive conditional branch")); + } + + [Fact] + public void Skips_and_reports_a_non_static_command_method() + { + var commands = Parse(""" + [CliCommand("instance", "Never registered")] + public void Instance() { } + """); + + Assert.Empty(commands); + Assert.Contains(warnings, w => w.Contains("non-static")); + } + + [Fact] + public void Reports_a_command_declared_outside_the_documented_roots() + { + var commands = Parse( + "[CliCommand(\"editor_play\", \"Enter play mode\")] public static void Play() { }", + strayPath: "Editor/Tools/StrayCommands.cs", + stray: "[CliCommand(\"stray\", \"Somewhere else\")] public static void Stray() { }"); + + Assert.Equal("editor_play", Assert.Single(commands).Name); + Assert.Contains(warnings, w => w.Contains("Editor/Tools/StrayCommands.cs") && w.Contains("'Stray'")); + } + + [Fact] + public void Ignores_a_command_in_a_directory_unity_does_not_compile() + { + // Unity compiles nothing inside a directory whose name ends with '~', so a [CliCommand] there is + // not part of the package: documenting it would advertise a command no editor answers to, and it + // is not a stray to be reported either. + var commands = Parse( + "[CliCommand(\"editor_play\", \"Enter play mode\")] public static void Play() { }", + strayPath: "Samples~/HotReload/SampleCommands.cs", + stray: "[CliCommand(\"sample\", \"Only in a sample\")] public static void Sample() { }"); + + Assert.Equal("editor_play", Assert.Single(commands).Name); + Assert.Empty(warnings); + } + + [Fact] + public void Reads_a_command_from_a_directory_that_merely_happens_to_be_called_Tests() + { + // Only the package's own Tests assembly, at the root, is out of scope. A directory of that name + // inside a compiled assembly is compiled like any other, so a command there is a real command. + var commands = Parse( + "[CliCommand(\"editor_play\", \"Enter play mode\")] public static void Play() { }", + strayPath: "Editor/Commands/Tests/TestCommands.cs", + stray: "[CliCommand(\"run_tests\", \"Run the tests\")] public static void Run() { }"); + + Assert.Equal(new[] { "editor_play", "run_tests" }, commands.Select(c => c.Name).OrderBy(n => n, StringComparer.Ordinal)); + Assert.Empty(warnings); + } + + [Fact] + public void Reports_the_same_argument_declared_twice() + { + Parse(""" + [CliCommand("log", "Write a message")] + public static void Log( + [CliArg("message", "Text")] string message = "", + [CliArg("message", "Text again")] string other = "") { } + """); + + Assert.Contains(warnings, w => w.Contains("'message'")); + } + + [Fact] + public void Expands_a_structured_argument_into_the_fields_its_schema_advertises() + { + var settings = Assert.Single(Assert.Single(Parse( + """ + [CliCommand("set_tags_layers", "Change tags")] + public static void Set([CliArg("settings", "Changes")] TagsLayersInput settings = null) { } + """, + strayPath: "Runtime/Common/Inputs.cs", + stray: """ + public class TagsLayersInput : IStructuredCommandInput + { + [CliArg("addTags", "Tags to add")] public string[] AddTags { get; set; } + [Newtonsoft.Json.JsonProperty(PropertyName = "renamed")] public int Renamed { get; set; } + [Newtonsoft.Json.JsonIgnore] public int Hidden { get; set; } + public int ReadOnlyMember { get; } + [CliArg("layers", "Layer assignments")] public LayerAssignment[] Layers { get; set; } + } + + public class LayerAssignment : IStructuredCommandInput + { + [CliArg("index", "Layer index", Required = true)] public int Index { get; set; } + } + """)).Args); + + Assert.Equal(new[] { "addTags", "renamed", "layers" }, settings.Members.Select(m => m.Name)); + Assert.Equal("index", Assert.Single(settings.Members.Last().Members).Name); + Assert.True(settings.Members.Last().Members.Single().Required); + } + + private List Parse(string body, string? strayPath = null, string? stray = null) + { + Write("Editor/Commands/Commands.cs", $"public static class Commands {{\n{body}\n}}"); + if (strayPath is not null && stray is not null) + Write(strayPath, stray.Contains("class ") ? stray : $"public static class Stray {{\n{stray}\n}}"); + + return new CommandParser(warnings.Add).Parse(packageRoot); + } + + private void Write(string relativePath, string contents) + { + var path = Path.Combine(packageRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, contents); + } + + public void Dispose() + { + try + { + if (Directory.Exists(packageRoot)) + Directory.Delete(packageRoot, recursive: true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // A scanner still holding a freshly written file must not fail a test that already passed. + } + } +} diff --git a/tools/command-ref-gen/CommandRefGen.Tests/StructuredInputTests.cs b/tools/command-ref-gen/CommandRefGen.Tests/StructuredInputTests.cs new file mode 100644 index 0000000..29b3174 --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen.Tests/StructuredInputTests.cs @@ -0,0 +1,335 @@ +using Xunit; + +namespace CommandRefGen.Tests; + +/// +/// A structured argument is passed as a JSON object, and its fields exist nowhere in a live listing, so +/// what the reference prints for them cannot be checked against a running editor — only against the rules +/// the package's own schema generator applies. +/// +public class StructuredInputTests : IDisposable +{ + private readonly string packageRoot = Path.Combine(Path.GetTempPath(), "commandrefgen-tests", Guid.NewGuid().ToString("N")); + private readonly List warnings = new(); + + [Fact] + public void Takes_the_members_of_every_partial_part_and_of_the_base_type() + { + var members = Expand(""" + namespace A + { + public abstract class SettingsBase : IStructuredCommandInput + { + [CliArg("inherited", "From the base")] public string Inherited { get; set; } + } + + public partial class ThingInput : SettingsBase + { + [CliArg("first", "First half")] public string First { get; set; } + } + + public partial class ThingInput + { + [CliArg("second", "Second half")] public string Second { get; set; } + } + } + """); + + Assert.Equal(new[] { "first", "second", "inherited" }, members.Select(m => m.Name)); + } + + [Fact] + public void Finds_the_marker_on_whichever_partial_part_declares_it() + { + // The parts of a partial type are that one type, so reflection sees the interface whichever part + // carries it; stopping at the first part would drop the fields of a perfectly ordinary DTO. + var members = Expand(""" + namespace A + { + public class ThingInput : SettingsBase + { + [CliArg("own", "Declared here")] public string Own { get; set; } + } + + public partial class SettingsBase + { + [CliArg("first", "From the first part")] public string First { get; set; } + } + + public partial class SettingsBase : IStructuredCommandInput + { + [CliArg("second", "From the part carrying the marker")] public string Second { get; set; } + } + } + """); + + Assert.Equal(new[] { "own", "first", "second" }, members.Select(m => m.Name)); + } + + [Fact] + public void Leaves_an_abstract_type_unexpanded_as_the_schema_generator_does() + { + var members = Expand(""" + namespace A + { + public abstract class ThingInput : IStructuredCommandInput + { + [CliArg("value", "A value")] public string Value { get; set; } + } + } + """); + + Assert.Empty(members); + Assert.Empty(warnings); + } + + [Fact] + public void Reports_a_name_it_cannot_resolve_rather_than_merging_two_types() + { + // Two unrelated types share a simple name, which is all a parameter gives this tool to go on. + // Printing the union of their members would describe an object the server does not accept. + var members = Expand(""" + namespace A + { + public class ThingInput : IStructuredCommandInput + { + [CliArg("alpha", "From the real input")] public string Alpha { get; set; } + } + } + + namespace B + { + public class ThingInput + { + [CliArg("beta", "From an unrelated type")] public string Beta { get; set; } + } + } + """); + + Assert.Empty(members); + Assert.Contains(warnings, w => w.Contains("ThingInput") && w.Contains("more than one namespace")); + } + + [Fact] + public void Reports_the_name_even_when_the_other_declaration_is_an_interface() + { + var members = Expand(""" + namespace A + { + public class ThingInput : IStructuredCommandInput + { + [CliArg("alpha", "From the real input")] public string Alpha { get; set; } + } + } + + namespace B + { + public interface ThingInput { } + } + """); + + Assert.Empty(members); + Assert.Contains(warnings, w => w.Contains("ThingInput")); + } + + [Fact] + public void Reports_a_base_type_whose_name_it_cannot_resolve() + { + // The argument's own type is unambiguous, so only the base name is in doubt — inheriting the + // members of whichever same-named type came first would document fields of an unrelated class. + var members = Expand(""" + namespace A + { + public class ThingInput : SettingsBase, IStructuredCommandInput + { + [CliArg("own", "Declared here")] public string Own { get; set; } + } + + public class SettingsBase + { + [CliArg("fromA", "From the intended base")] public string FromA { get; set; } + } + } + + namespace B + { + public class SettingsBase + { + [CliArg("fromB", "From an unrelated type")] public string FromB { get; set; } + } + } + """); + + Assert.Equal(new[] { "own" }, members.Select(m => m.Name)); + Assert.Contains(warnings, w => w.Contains("SettingsBase") && w.Contains("more than one namespace")); + } + + [Fact] + public void Skips_the_members_the_schema_generator_skips() + { + var members = Expand(""" + namespace A + { + public class ThingInput : IStructuredCommandInput + { + [CliArg("kept", "Kept")] public string Kept { get; set; } + [Newtonsoft.Json.JsonIgnore] public string Ignored { get; set; } + public string ReadOnlyMember { get; } + public const string Constant = "no"; + public static string Shared { get; set; } + internal string Internal { get; set; } + public readonly string ReadOnlyField; + } + } + """); + + Assert.Equal(new[] { "kept" }, members.Select(m => m.Name)); + } + + [Fact] + public void Names_a_member_by_CliArg_then_JsonProperty_then_itself() + { + var members = Expand(""" + namespace A + { + public class ThingInput : IStructuredCommandInput + { + [CliArg("fromCliArg", "One")] + [Newtonsoft.Json.JsonProperty("ignoredHere")] + public string First { get; set; } + + [Newtonsoft.Json.JsonProperty(PropertyName = "fromJsonProperty")] + public string Second { get; set; } + + public string Third { get; set; } + } + } + """); + + Assert.Equal(new[] { "fromCliArg", "fromJsonProperty", "Third" }, members.Select(m => m.Name)); + } + + [Theory] + [InlineData("LayerAssignment[]", "LayerAssignment[]")] + [InlineData("List", "List")] + [InlineData("IReadOnlyList", "IReadOnlyList")] + public void Expands_a_collection_member_through_to_its_element_type(string declared, string printed) + { + // The schema generator emits an array of object schemas for any of these, so the reader has to + // reach the element type the same way rather than stopping at the collection. + var members = Expand($$""" + namespace A + { + public class ThingInput : IStructuredCommandInput + { + [CliArg("layers", "Layer assignments")] public {{declared}} Layers { get; set; } + } + + public class LayerAssignment : IStructuredCommandInput + { + [CliArg("index", "Layer index", Required = true)] public int Index { get; set; } + } + } + """); + + var layers = Assert.Single(members); + Assert.Equal(printed, layers.Type); + Assert.Equal("index", Assert.Single(layers.Members).Name); + } + + [Fact] + public void Reports_the_whole_object_when_the_marker_sits_behind_an_unresolvable_base() + { + // The marker is reachable only through the ambiguous base, so the type cannot even be classified: + // what goes missing is every field, not just the inherited ones, and the message has to say so. + var members = Expand(""" + namespace A + { + public class ThingInput : SettingsBase + { + [CliArg("own", "Declared here")] public string Own { get; set; } + } + + public class SettingsBase : IStructuredCommandInput + { + [CliArg("inherited", "From the base")] public string Inherited { get; set; } + } + } + + namespace B + { + public class SettingsBase { } + } + """); + + Assert.Empty(members); + Assert.Contains(warnings, w => w.Contains("documented without its fields")); + Assert.DoesNotContain(warnings, w => w.Contains("inherited from it")); + } + + [Fact] + public void Reports_a_structured_input_it_can_read_no_members_from() + { + // A positional record's properties exist only after compilation, so a syntax reader sees an empty + // type where the server's reflection sees two fields. + var members = Expand(""" + namespace A + { + public sealed record ThingInput(int Index, string Name) : IStructuredCommandInput; + } + """); + + Assert.Empty(members); + Assert.Contains(warnings, w => w.Contains("no readable members")); + } + + [Fact] + public void Stops_where_a_type_contains_itself() + { + var members = Expand(""" + namespace A + { + public class ThingInput : IStructuredCommandInput + { + [CliArg("child", "Nested")] public ThingInput Child { get; set; } + } + } + """); + + Assert.Empty(Assert.Single(members).Members); + } + + private IReadOnlyList Expand(string types) + { + Write("Editor/Commands/Commands.cs", """ + public static class Commands + { + [CliCommand("set_thing", "Change a thing")] + public static void Set([CliArg("settings", "Changes")] ThingInput settings = null) { } + } + """); + Write("Runtime/Common/Inputs.cs", types); + + return Assert.Single(Assert.Single(new CommandParser(warnings.Add).Parse(packageRoot)).Args).Members; + } + + private void Write(string relativePath, string contents) + { + var path = Path.Combine(packageRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, contents); + } + + public void Dispose() + { + try + { + if (Directory.Exists(packageRoot)) + Directory.Delete(packageRoot, recursive: true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // A scanner still holding a freshly written file must not fail a test that already passed. + } + } +} diff --git a/tools/command-ref-gen/CommandRefGen/CommandParser.cs b/tools/command-ref-gen/CommandRefGen/CommandParser.cs new file mode 100644 index 0000000..be457e0 --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen/CommandParser.cs @@ -0,0 +1,469 @@ +using System.Globalization; +using System.Text.Json; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace CommandRefGen; + +/// +/// Reads the [CliCommand] surface out of the package's C# sources with the Roslyn syntax API. +/// +/// The metadata this produces mirrors what a running Pipeline server reports for `unity --json command`: +/// argument name falls back to the C# parameter name, an argument is required when the attribute says so +/// (or, with no attribute, when the parameter has no C# default), and the C# parameter default wins over +/// the attribute's DefaultValue. +/// +public sealed class CommandParser(Action warn) +{ + /// Package-relative directories whose commands are documented. Everything else is ignored. + private static readonly string[] SourceRoots = { "Editor/Commands", "Runtime/Commands" }; + + // Both are built in Parse, once every source file has been read. + private ConstantEvaluator constants = null!; + private StructuredInputReader structuredInputs = null!; + + /// Parses every command under (the tarball's package/ directory). + public List Parse(string packageRoot) + { + // A command's default value may reference a const declared anywhere in the package (say a level + // name in Runtime/Console), so the whole package is parsed before any command is read — in a + // fixed order, because the indexes below resolve a repeated name to whichever declaration they + // meet first, and an arbitrary order would make the output depend on the machine it ran on. + // Ordered by the package-relative path rather than the native one: the two disagree because the + // directory separator sorts differently against letters ('/' below them, '\' above), which would + // put "Commands/Capture/..." before or after "Commands/CaptureEditorElementCommand.cs" depending + // on the operating system. + var sources = EnumerateSources(packageRoot) + .Select(file => (Path: Path.GetRelativePath(packageRoot, file).Replace('\\', '/'), File: file)) + .OrderBy(source => source.Path, StringComparer.Ordinal); + + var trees = new List<(string Path, SyntaxTree Tree)>(); + foreach (var (relativePath, file) in sources) + trees.Add((relativePath, ParseTree(file, relativePath))); + + var roots = trees.Select(entry => entry.Tree.GetRoot()).ToList(); + constants = new ConstantEvaluator(ConstantEvaluator.IndexConstants(roots, warn)); + var (types, ambiguousTypes) = IndexTypes(roots); + structuredInputs = new StructuredInputReader(types, ambiguousTypes, constants, TypeName, warn); + + var commands = new List(); + foreach (var (relativePath, tree) in trees) + { + if (IsCommandSource(relativePath)) + commands.AddRange(ParseCommands(tree, relativePath)); + else + ReportStrayCommands(tree, relativePath); + } + + foreach (var group in commands.GroupBy(c => c.Name).Where(g => g.Count() > 1)) + warn($"command '{group.Key}' is declared {group.Count()} times: {string.Join(", ", group.Select(c => $"{c.SourcePath}:{c.SourceLine}"))}"); + + return commands; + } + + /// + /// Reports a [CliCommand] found outside the directories this tool reads commands from. The + /// package keeps them all under today; if a future version moves one, it + /// would otherwise vanish from the reference while --check still reported success. + /// + private void ReportStrayCommands(SyntaxTree tree, string relativePath) + { + foreach (var method in CommandMethods(tree.GetRoot())) + { + var line = tree.GetLineSpan(method.Identifier.Span).StartLinePosition.Line + 1; + warn($"{relativePath}:{line}: [CliCommand] on method '{method.Identifier.ValueText}' lives outside {string.Join(" and ", SourceRoots)} and is missing from the reference"); + } + } + + /// + /// Indexes the package's type declarations by simple name, which is how a parameter refers to them. + /// A type split with partial keeps all its parts, since its members are spread across them. + /// Two unrelated types sharing a name in different namespaces are indistinguishable here; the package + /// has several such pairs that no command uses, so that is reported only if one is actually expanded. + /// + private static (Dictionary> Index, HashSet Ambiguous) IndexTypes(IEnumerable roots) + { + var index = new Dictionary>(StringComparer.Ordinal); + + foreach (var declaration in roots.SelectMany(root => root.DescendantNodes().OfType())) + { + if (!index.TryGetValue(declaration.Identifier.ValueText, out var parts)) + index[declaration.Identifier.ValueText] = parts = new List(); + + parts.Add(declaration); + } + + // Several parts of one partial type share a container; declarations in different containers are + // different types that this index cannot tell apart, whether or not they are partial. + var ambiguous = index + .Where(entry => entry.Value.Select(Container).Distinct(StringComparer.Ordinal).Count() > 1) + .Select(entry => entry.Key) + .ToHashSet(StringComparer.Ordinal); + + return (index, ambiguous); + } + + /// The namespaces and outer types a declaration sits in, which is what its simple name omits. + private static string Container(TypeDeclarationSyntax declaration) => + string.Join( + ".", + declaration.Ancestors() + .Reverse() + .Select(ancestor => ancestor switch + { + BaseNamespaceDeclarationSyntax ns => ns.Name.ToString(), + TypeDeclarationSyntax outer => outer.Identifier.ValueText, + _ => null, + }) + .OfType()); + + private static bool IsCommandSource(string relativePath) => + SourceRoots.Any(root => relativePath.StartsWith(root + "/", StringComparison.Ordinal)); + + private static IEnumerable EnumerateSources(string packageRoot) + { + foreach (var file in Directory.EnumerateFiles(packageRoot, "*.cs", SearchOption.AllDirectories)) + { + var segments = Path.GetRelativePath(packageRoot, file).Replace('\\', '/').Split('/'); + + // The package's own Tests assembly, at the root, declares registration fixtures (log_editor, + // test_types, ...) that are not part of the shipped command surface — but a directory merely + // named Tests inside a compiled assembly is compiled like any other, so only the root one is + // skipped. Unity compiles nothing inside a directory whose name ends with '~' (Samples~, + // Documentation~) at any depth: a type declared there is not a type of this package. + if (segments[0] == "Tests" || segments.Any(segment => segment.EndsWith('~'))) + continue; + + yield return file; + } + } + + private SyntaxTree ParseTree(string path, string relativePath) + { + var text = File.ReadAllText(path); + + // Parse once with nothing defined only to learn which preprocessor symbols the file talks about, + // then re-parse with all of them defined so that version-gated commands are present in the tree. + var probe = CSharpSyntaxTree.ParseText(text, new CSharpParseOptions(LanguageVersion.Latest), path: relativePath); + var conditionals = Directives(probe.GetRoot()).OfType().ToList(); + var symbols = conditionals + .SelectMany(d => d.Condition.DescendantNodesAndSelf().OfType()) + .Select(i => i.Identifier.ValueText) + .Distinct(StringComparer.Ordinal) + .ToList(); + + var tree = CSharpSyntaxTree.ParseText( + text, + new CSharpParseOptions(LanguageVersion.Latest, preprocessorSymbols: symbols), + path: relativePath); + + ReportHiddenCommands(tree, relativePath); + return tree; + } + + /// + /// Defining every symbol a file mentions activates the first branch of each #if chain, so a + /// command declared in an #else, an #elif, or under a negated condition stays inactive + /// and never reaches the syntax tree. Those branches survive as disabled text — report any that + /// declares a command rather than dropping it without a word. + /// + private void ReportHiddenCommands(SyntaxTree tree, string relativePath) + { + foreach (var trivia in tree.GetRoot().DescendantTrivia(descendIntoTrivia: true) + .Where(t => t.IsKind(SyntaxKind.DisabledTextTrivia) && t.ToFullString().Contains("[CliCommand", StringComparison.Ordinal))) + { + var line = tree.GetLineSpan(trivia.Span).StartLinePosition.Line + 1; + warn($"{relativePath}:{line}: a [CliCommand] sits in an inactive conditional branch and is missing from the reference"); + } + } + + /// Methods carrying a [CliCommand], read from the syntax tree so that a mention in a comment is not one. + private static IEnumerable CommandMethods(SyntaxNode root) => + root.DescendantNodes() + .OfType() + .Where(method => method.AttributeLists.SelectMany(list => list.Attributes).Any(a => IsAttribute(a, "CliCommand"))); + + private IEnumerable ParseCommands(SyntaxTree tree, string relativePath) + { + var root = tree.GetCompilationUnitRoot(); + var scopes = ConditionalScopes(root); + + foreach (var method in CommandMethods(root)) + { + var attribute = method.AttributeLists + .SelectMany(list => list.Attributes) + .First(a => IsAttribute(a, "CliCommand")); + + // CommandRegistry registers static methods only, so a non-static one never becomes a + // command however it is attributed. + if (!method.Modifiers.Any(SyntaxKind.StaticKeyword)) + { + var declaration = tree.GetLineSpan(method.Identifier.Span).StartLinePosition.Line + 1; + warn($"{relativePath}:{declaration}: [CliCommand] on non-static method '{method.Identifier.ValueText}' — the package registers only static methods, skipping"); + continue; + } + + yield return BuildCommand(method, attribute, scopes, relativePath, tree); + } + } + + private CommandInfo BuildCommand( + MethodDeclarationSyntax method, + AttributeSyntax attribute, + List<(TextSpan Span, string Condition)> scopes, + string relativePath, + SyntaxTree tree) + { + var nameArgument = ConstructorArgument(attribute, 0, "name"); + var descriptionArgument = ConstructorArgument(attribute, 1, "description"); + if (nameArgument is null || descriptionArgument is null) + throw new InvalidOperationException($"{relativePath}: [CliCommand] on {method.Identifier.ValueText} does not supply both a name and a description"); + + var name = AsString(nameArgument, relativePath); + var description = Prose.Collapse(AsString(descriptionArgument, relativePath)); + var mainThreadRequired = NamedBool(attribute, "MainThreadRequired") ?? true; + var runtimeOnly = NamedBool(attribute, "RuntimeOnly") ?? false; + + var gates = scopes + .Where(scope => scope.Span.Contains(method.Span)) + .OrderBy(scope => scope.Span.Start) + .Select(scope => scope.Condition) + .ToList(); + + var line = tree.GetLineSpan(attribute.Span).StartLinePosition.Line + 1; + var args = method.ParameterList.Parameters.Select(p => BuildArg(p, relativePath)).ToList(); + + foreach (var duplicate in args.GroupBy(a => a.Name).Where(g => g.Count() > 1)) + warn($"{relativePath}:{line}: command '{name}' declares the argument '{duplicate.Key}' {duplicate.Count()} times"); + + return new CommandInfo(name, description, mainThreadRequired, runtimeOnly, args, gates, relativePath, line); + } + + private CommandArg BuildArg(ParameterSyntax parameter, string relativePath) + { + var attribute = parameter.AttributeLists + .SelectMany(list => list.Attributes) + .FirstOrDefault(a => IsAttribute(a, "CliArg")); + + var parameterName = parameter.Identifier.ValueText; + var hasParameterDefault = parameter.Default is not null; + + string name; + string description; + bool required; + object? attributeDefault = null; + + if (attribute is null) + { + name = parameterName; + description = $"Parameter: {parameterName}"; + required = !hasParameterDefault; + } + else + { + var nameArgument = ConstructorArgument(attribute, 0, "name"); + var descriptionArgument = ConstructorArgument(attribute, 1, "description"); + if (nameArgument is null || descriptionArgument is null) + throw new InvalidOperationException($"{relativePath}: [CliArg] on parameter '{parameterName}' does not supply both a name and a description"); + + name = AsString(nameArgument, relativePath); + description = Prose.Collapse(AsString(descriptionArgument, relativePath)); + required = NamedBool(attribute, "Required") ?? false; + var defaultArgument = NamedArgument(attribute, "DefaultValue"); + if (defaultArgument is not null) + attributeDefault = constants.Evaluate(defaultArgument.Expression); + } + + // Matches CommandRegistry.DiscoverParameters: the C# default takes precedence, the attribute's + // DefaultValue is only consulted for parameters that have none. + var defaultValue = hasParameterDefault + ? constants.Evaluate(parameter.Default!.Value) + : attributeDefault; + + return new CommandArg( + name, + description, + TypeName(parameter.Type!), + required, + JsonLiteral(defaultValue), + structuredInputs.Expand(parameter.Type!, $"{relativePath}: argument '{name}'")); + } + + /// + /// Renders a parameter's declared type the way the server's command listing does — that is, + /// System.Type.Name: keyword aliases become framework names, generics keep the arity suffix, + /// and namespaces are dropped. Nullable types are the one deliberate departure, see below. + /// + private static string TypeName(TypeSyntax type) + { + switch (type) + { + case ArrayTypeSyntax array: + var ranks = string.Concat(array.RankSpecifiers.Select(r => "[" + new string(',', r.Rank - 1) + "]")); + return TypeName(array.ElementType) + ranks; + + case NullableTypeSyntax nullable: + // The live listing reports Type.Name here, which for int? is the unusable "Nullable`1". + // The underlying type is what a caller needs; optionality is already carried by the + // absence of the required marker. + return TypeName(nullable.ElementType); + + case GenericNameSyntax generic: + // Type.Name would give List`1: a literal backtick, which pairs with the ones the writer + // puts around argument names and breaks the rest of the line. The written-out form says + // more anyway. + return $"{generic.Identifier.ValueText}<{string.Join(", ", generic.TypeArgumentList.Arguments.Select(TypeName))}>"; + + case QualifiedNameSyntax qualified: + return TypeName(qualified.Right); + + case PredefinedTypeSyntax predefined: + return predefined.Keyword.ValueText switch + { + "bool" => "Boolean", + "byte" => "Byte", + "sbyte" => "SByte", + "char" => "Char", + "decimal" => "Decimal", + "double" => "Double", + "float" => "Single", + "int" => "Int32", + "uint" => "UInt32", + "long" => "Int64", + "ulong" => "UInt64", + "short" => "Int16", + "ushort" => "UInt16", + "object" => "Object", + "string" => "String", + var other => other, + }; + + default: + return type.ToString(); + } + } + + /// Formats a default value as the JSON literal the reference prints, or null when there is none. + private static string? JsonLiteral(object? value) + { + switch (value) + { + case null: + return null; + case string s: + return JsonSerializer.Serialize(s); + case bool b: + return b ? "true" : "false"; + case float f: + // JSON has one number type, so a whole float prints as 1, exactly as the live listing shows it. + return f.ToString("R", CultureInfo.InvariantCulture).Replace("E", "e"); + case double d: + return d.ToString("R", CultureInfo.InvariantCulture).Replace("E", "e"); + default: + return Convert.ToString(value, CultureInfo.InvariantCulture); + } + } + + /// The conditional-compilation regions of a file, as spans paired with their condition text. + private static List<(TextSpan Span, string Condition)> ConditionalScopes(CompilationUnitSyntax root) + { + var scopes = new List<(TextSpan, string)>(); + var open = new Stack<(string Condition, int Start)>(); + + foreach (var directive in Directives(root)) + { + switch (directive) + { + case IfDirectiveTriviaSyntax ifDirective: + open.Push((ifDirective.Condition.ToString(), ifDirective.Span.End)); + break; + + case ElifDirectiveTriviaSyntax elif: + Close(elif.SpanStart); + open.Push((elif.Condition.ToString(), elif.Span.End)); + break; + + case ElseDirectiveTriviaSyntax elseDirective: + var previous = Close(elseDirective.SpanStart); + open.Push(($"!({previous})", elseDirective.Span.End)); + break; + + case EndIfDirectiveTriviaSyntax endIf: + Close(endIf.SpanStart); + break; + } + } + + return scopes; + + string Close(int end) + { + if (open.Count == 0) + return string.Empty; + + var (condition, start) = open.Pop(); + scopes.Add((TextSpan.FromBounds(start, Math.Max(start, end)), condition)); + return condition; + } + } + + /// Every preprocessor directive in a file, in source order. Directives live in trivia, not in the node tree. + private static IEnumerable Directives(SyntaxNode root) => + root.DescendantTrivia(descendIntoTrivia: true) + .Where(trivia => trivia.HasStructure) + .Select(trivia => trivia.GetStructure()) + .OfType() + .OrderBy(directive => directive.SpanStart); + + private static bool IsAttribute(AttributeSyntax attribute, string name) + { + var text = attribute.Name switch + { + QualifiedNameSyntax qualified => qualified.Right.ToString(), + var other => other.ToString(), + }; + + return text == name || text == name + "Attribute"; + } + + /// + /// The constructor argument at , which C# lets the caller write either + /// positionally or as name:. Reading only the position would swap a command's name and + /// description the day the package writes them the other way round. + /// + private static AttributeArgumentSyntax? ConstructorArgument(AttributeSyntax attribute, int index, string parameterName) + { + var arguments = attribute.ArgumentList?.Arguments; + if (arguments is null) + return null; + + var named = arguments.Value.FirstOrDefault(a => a.NameColon?.Name.Identifier.ValueText == parameterName); + if (named is not null) + return named; + + var positional = arguments.Value.Where(a => a.NameEquals is null && a.NameColon is null).ToList(); + return index < positional.Count ? positional[index] : null; + } + + private static AttributeArgumentSyntax? NamedArgument(AttributeSyntax attribute, string name) => + attribute.ArgumentList?.Arguments.FirstOrDefault(a => a.NameEquals?.Name.Identifier.ValueText == name); + + private bool? NamedBool(AttributeSyntax attribute, string name) + { + var argument = NamedArgument(attribute, name); + if (argument is null) + return null; + + return constants.Evaluate(argument.Expression) as bool? + ?? throw new UnsupportedExpressionException($"{name} is not a boolean constant: `{argument.Expression}`"); + } + + private string AsString(AttributeArgumentSyntax argument, string relativePath) + { + var value = constants.Evaluate(argument.Expression); + return value as string + ?? throw new UnsupportedExpressionException($"{relativePath}: expected a string constant, got `{argument.Expression}`"); + } +} diff --git a/tools/command-ref-gen/CommandRefGen/StructuredInputReader.cs b/tools/command-ref-gen/CommandRefGen/StructuredInputReader.cs new file mode 100644 index 0000000..f7813ac --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen/StructuredInputReader.cs @@ -0,0 +1,333 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace CommandRefGen; + +/// +/// Expands the DTO types a command takes as a single structured argument. +/// +/// The package's convention: a command needing several related values declares a small type +/// implementing IStructuredCommandInput and takes it as one parameter, which the caller passes +/// as a JSON object. A live listing shows only the type name for such a parameter — its fields appear +/// solely in the per-command JSON schema — so reading the sources is the only way the reference can tell +/// a reader what to actually put in that object. +/// +/// The members mirror what JsonSchemaGenerator reflects over: public instance fields and +/// read/write properties, minus [JsonIgnore], named by [CliArg], then by Newtonsoft's +/// [JsonProperty], then by the member itself. Members carry no default value — the schema has +/// no place for one. +/// +public sealed class StructuredInputReader( + IReadOnlyDictionary> types, + IReadOnlySet ambiguousTypes, + ConstantEvaluator constants, + Func typeName, + Action warn) +{ + /// The marker interface a parameter type must implement to be expanded. + private const string MarkerInterface = "IStructuredCommandInput"; + + /// Ambiguous base names already reported, so that one unresolvable type is one message. + private readonly HashSet reportedAmbiguous = new(StringComparer.Ordinal); + + /// Collection types JsonSchemaGenerator treats as JSON arrays. + private static readonly string[] CollectionTypes = + { + "List", "IList", "IEnumerable", "ICollection", "IReadOnlyList", "IReadOnlyCollection", + }; + + /// + /// Members of the structured type denotes, or an empty list when it is a + /// plain value. Arrays and lists are expanded through to their element type, matching how the + /// package emits an array of object schemas. + /// + public IReadOnlyList Expand(TypeSyntax type, string context) => + Expand(type, context, new HashSet(StringComparer.Ordinal)); + + private IReadOnlyList Expand(TypeSyntax type, string context, HashSet visiting) + { + if (ElementTypeName(type) is not { } name || !types.TryGetValue(name, out var parts)) + return Array.Empty(); + + // JsonSchemaGenerator expands a parameter only when its type implements the marker interface; + // anything else schemas as a plain value, and the reference says the same. A base name this tool + // cannot resolve makes that verdict unreliable, and the cost is the whole object rather than a + // few inherited members, so it is reported here rather than deeper down. + var unresolvedBases = new HashSet(StringComparer.Ordinal); + if (!ImplementsMarker(parts, new HashSet(StringComparer.Ordinal), unresolvedBases)) + { + foreach (var unresolved in unresolvedBases.Where(reportedAmbiguous.Add).OrderBy(n => n, StringComparer.Ordinal)) + warn($"{context}: '{name}' derives from '{unresolved}', which the package declares in more than one namespace — this tool cannot tell whether the argument is a structured input, so it is documented without its fields"); + + return Array.Empty(); + } + + // Past this point the argument really is a nested object, so an unresolvable name is a hole in + // the documentation rather than a non-event: merging the members of two unrelated types would + // describe an object the server does not accept, and skipping quietly would drop the fields the + // whole expansion exists to show. + if (ambiguousTypes.Contains(name)) + { + warn($"{context}: the package declares '{name}' in more than one namespace and this tool cannot tell which one is meant — the argument is documented without its fields"); + return Array.Empty(); + } + + // A type the caller cannot instantiate is schemad as a plain string by the package, so it carries + // no fields to show. Every part has to be concrete: `abstract` on one part makes the type abstract. + if (!parts.All(IsConcrete)) + return Array.Empty(); + + // A type that contains itself would otherwise expand forever; the package emits an open object + // in that case, and the reference stops at the same point. + if (!visiting.Add(name)) + return Array.Empty(); + + try + { + var members = SchemaMembers(name, new HashSet(StringComparer.Ordinal)) + .SelectMany(member => ReadMember(member, name, visiting)) + .ToList(); + + // A structured input with nothing in it is a contradiction: either the package declared an + // empty object, or its members are written in a shape this tool does not read — a positional + // record, say, whose properties exist only after compilation. + if (members.Count == 0) + warn($"{context}: '{name}' is a structured input with no readable members — the argument is documented without its fields"); + + return members; + } + finally + { + visiting.Remove(name); + } + } + + /// + /// Members the schema generator would reflect over for : those of every + /// partial part, then those inherited from base types the package declares, because + /// GetFields/GetProperties report inherited public members too. + /// + private IEnumerable SchemaMembers(string name, HashSet seen) + { + if (!seen.Add(name) || !Resolvable(name, $"base type '{name}'", out var parts)) + yield break; + + foreach (var member in parts.SelectMany(part => part.Members)) + yield return member; + + foreach (var member in parts.SelectMany(BaseNames).SelectMany(baseName => SchemaMembers(baseName, seen))) + yield return member; + } + + /// + /// Looks a base type up, refusing an ambiguous name. Merging the members of two unrelated types that + /// happen to share a name would describe an object the server does not accept, and it is just as + /// wrong one level down a base-type chain as it is at the argument itself. The argument's own type + /// is reported separately, in , where the + /// consequence is bigger: there it is the whole object that goes undocumented. + /// + private bool Resolvable(string name, string context, out List parts) + { + if (!types.TryGetValue(name, out parts!)) + return false; + + if (!ambiguousTypes.Contains(name)) + return true; + + // Reached once from the marker probe and once while collecting members; one report is enough. + if (reportedAmbiguous.Add(name)) + warn($"{context}: the package declares '{name}' in more than one namespace and this tool cannot tell which one is meant — the members inherited from it are missing from the reference"); + + parts = null!; + return false; + } + + private IEnumerable ReadMember(MemberDeclarationSyntax member, string owner, HashSet visiting) + { + switch (member) + { + case FieldDeclarationSyntax field when IsSchemaField(field): + foreach (var variable in field.Declaration.Variables) + yield return ToArg(member, variable.Identifier.ValueText, field.Declaration.Type, owner, visiting); + break; + + case PropertyDeclarationSyntax property when IsSchemaProperty(property): + yield return ToArg(member, property.Identifier.ValueText, property.Type, owner, visiting); + break; + } + } + + private CommandArg ToArg( + MemberDeclarationSyntax member, + string memberName, + TypeSyntax memberType, + string owner, + HashSet visiting) + { + var cliArg = Attribute(member, "CliArg"); + var name = cliArg is null ? null : ConstructorString(cliArg, 0, "name"); + name ??= JsonPropertyName(member) ?? memberName; + + var description = cliArg is null ? string.Empty : Prose.Collapse(ConstructorString(cliArg, 1, "description") ?? string.Empty); + var required = cliArg is not null && NamedTrue(cliArg, "Required"); + + return new CommandArg( + name, + description, + typeName(memberType), + required, + DefaultValue: null, + Expand(memberType, $"{owner}.{memberName}", visiting)); + } + + /// + /// The name a Newtonsoft [JsonProperty] gives the member, in either of its forms — positional + /// [JsonProperty("x")] or named [JsonProperty(PropertyName = "x")]. Reflection sees the + /// same PropertyName for both, so reading only one of them would document a member under a name + /// the server does not answer to. + /// + private string? JsonPropertyName(MemberDeclarationSyntax member) + { + var attribute = Attribute(member, "JsonProperty"); + if (attribute is null) + return null; + + return ConstructorString(attribute, 0, "propertyName") ?? NamedString(attribute, "PropertyName"); + } + + private string? NamedString(AttributeSyntax attribute, string name) + { + var argument = attribute.ArgumentList?.Arguments + .FirstOrDefault(a => a.NameEquals?.Name.Identifier.ValueText == name); + + return argument is null ? null : constants.Evaluate(argument.Expression) as string; + } + + /// True when the declaration can be instantiated, i.e. is neither an interface nor abstract. + private static bool IsConcrete(TypeDeclarationSyntax declaration) => + declaration is not InterfaceDeclarationSyntax && !declaration.Modifiers.Any(SyntaxKind.AbstractKeyword); + + /// + /// True when the type implements the marker interface, directly or through a base type. The cycle + /// guard counts type names rather than declarations: the parts of one partial type are that + /// same type, so a marker written on the second part has to count as much as one on the first. + /// + private bool ImplementsMarker(IEnumerable parts, HashSet seen, ISet unresolved) => + parts.SelectMany(BaseNames).Any(baseName => ImplementsMarker(baseName, seen, unresolved)); + + private bool ImplementsMarker(string name, HashSet seen, ISet unresolved) + { + if (name == MarkerInterface) + return true; + + if (!seen.Add(name)) + return false; + + // Silent: the caller decides what an unresolvable base costs, and it costs different things + // depending on whether the marker was found by another route. + if (ambiguousTypes.Contains(name)) + { + unresolved.Add(name); + return false; + } + + return types.TryGetValue(name, out var parts) && ImplementsMarker(parts, seen, unresolved); + } + + /// The simple names of the types a declaration derives from or implements. + private static IEnumerable BaseNames(TypeDeclarationSyntax declaration) => + (declaration.BaseList?.Types ?? Enumerable.Empty()) + .Select(baseType => SimpleName(baseType.Type)) + .OfType(); + + /// Public, writable instance fields — what reflection would report to the schema generator. + private static bool IsSchemaField(FieldDeclarationSyntax field) => + field.Modifiers.Any(SyntaxKind.PublicKeyword) && + !field.Modifiers.Any(SyntaxKind.StaticKeyword) && + !field.Modifiers.Any(SyntaxKind.ConstKeyword) && + !field.Modifiers.Any(SyntaxKind.ReadOnlyKeyword) && + Attribute(field, "JsonIgnore") is null; + + /// Public instance properties with both accessors and no index parameters. + private static bool IsSchemaProperty(PropertyDeclarationSyntax property) + { + if (!property.Modifiers.Any(SyntaxKind.PublicKeyword) || property.Modifiers.Any(SyntaxKind.StaticKeyword)) + return false; + if (Attribute(property, "JsonIgnore") is not null) + return false; + + var accessors = property.AccessorList?.Accessors; + if (accessors is null) + return false; + + var readable = accessors.Value.Any(a => a.IsKind(SyntaxKind.GetAccessorDeclaration)); + var writable = accessors.Value.Any(a => a.IsKind(SyntaxKind.SetAccessorDeclaration) || a.IsKind(SyntaxKind.InitAccessorDeclaration)); + return readable && writable; + } + + /// The type whose members matter: the element type for arrays and lists, the type itself otherwise. + private static string? ElementTypeName(TypeSyntax type) + { + switch (type) + { + case ArrayTypeSyntax array: + return ElementTypeName(array.ElementType); + + case NullableTypeSyntax nullable: + return ElementTypeName(nullable.ElementType); + + case GenericNameSyntax generic when generic.TypeArgumentList.Arguments.Count == 1 && + CollectionTypes.Contains(generic.Identifier.ValueText): + return ElementTypeName(generic.TypeArgumentList.Arguments[0]); + + default: + return SimpleName(type); + } + } + + private static string? SimpleName(TypeSyntax type) => type switch + { + IdentifierNameSyntax identifier => identifier.Identifier.ValueText, + QualifiedNameSyntax qualified => SimpleName(qualified.Right), + GenericNameSyntax generic => generic.Identifier.ValueText, + _ => null, + }; + + private static AttributeSyntax? Attribute(MemberDeclarationSyntax member, string name) => + member.AttributeLists + .SelectMany(list => list.Attributes) + .FirstOrDefault(attribute => AttributeName(attribute) == name || AttributeName(attribute) == name + "Attribute"); + + private static string AttributeName(AttributeSyntax attribute) => attribute.Name switch + { + QualifiedNameSyntax qualified => qualified.Right.ToString(), + var other => other.ToString(), + }; + + /// + /// The constructor argument at , whether the caller wrote it positionally or + /// as name:. Reading only the position would swap a member's name and description. + /// + private string? ConstructorString(AttributeSyntax? attribute, int index, string parameterName) + { + var arguments = attribute?.ArgumentList?.Arguments; + if (arguments is null) + return null; + + var named = arguments.Value.FirstOrDefault(a => a.NameColon?.Name.Identifier.ValueText == parameterName); + if (named is not null) + return constants.Evaluate(named.Expression) as string; + + var positional = arguments.Value.Where(a => a.NameEquals is null && a.NameColon is null).ToList(); + return index < positional.Count ? constants.Evaluate(positional[index].Expression) as string : null; + } + + private bool NamedTrue(AttributeSyntax attribute, string name) + { + var argument = attribute.ArgumentList?.Arguments + .FirstOrDefault(a => a.NameEquals?.Name.Identifier.ValueText == name); + + return argument is not null && constants.Evaluate(argument.Expression) is true; + } +} From f172192f34fd936c936dade8fbf19012ec5b99fc Mon Sep 17 00:00:00 2001 From: Ark Tarusov Date: Sun, 2 Aug 2026 18:52:33 +0200 Subject: [PATCH 05/10] render the sectioned reference markdown from templates and sidecars --- .../CommandRefGen.Tests/CategoriesTests.cs | 172 ++++++++++++++ .../MarkdownWriterTests.cs | 96 ++++++++ .../ReferenceStampTests.cs | 69 ++++++ .../CommandRefGen/Annotations.cs | 37 +++ .../CommandRefGen/Categories.cs | 133 +++++++++++ .../CommandRefGen/CommandRefGen.csproj | 6 + .../CommandRefGen/MarkdownWriter.cs | 221 ++++++++++++++++++ .../CommandRefGen/ReferenceStamp.cs | 46 ++++ .../CommandRefGen/annotations.json | 7 + .../CommandRefGen/categories.json | 62 +++++ .../CommandRefGen/templates/header.md | 9 + .../CommandRefGen/templates/preamble.md | 7 + .../templates/runtime-only-intro.md | 1 + 13 files changed, 866 insertions(+) create mode 100644 tools/command-ref-gen/CommandRefGen.Tests/CategoriesTests.cs create mode 100644 tools/command-ref-gen/CommandRefGen.Tests/MarkdownWriterTests.cs create mode 100644 tools/command-ref-gen/CommandRefGen.Tests/ReferenceStampTests.cs create mode 100644 tools/command-ref-gen/CommandRefGen/Annotations.cs create mode 100644 tools/command-ref-gen/CommandRefGen/Categories.cs create mode 100644 tools/command-ref-gen/CommandRefGen/MarkdownWriter.cs create mode 100644 tools/command-ref-gen/CommandRefGen/ReferenceStamp.cs create mode 100644 tools/command-ref-gen/CommandRefGen/annotations.json create mode 100644 tools/command-ref-gen/CommandRefGen/categories.json create mode 100644 tools/command-ref-gen/CommandRefGen/templates/header.md create mode 100644 tools/command-ref-gen/CommandRefGen/templates/preamble.md create mode 100644 tools/command-ref-gen/CommandRefGen/templates/runtime-only-intro.md diff --git a/tools/command-ref-gen/CommandRefGen.Tests/CategoriesTests.cs b/tools/command-ref-gen/CommandRefGen.Tests/CategoriesTests.cs new file mode 100644 index 0000000..f88d4c1 --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen.Tests/CategoriesTests.cs @@ -0,0 +1,172 @@ +using Xunit; + +namespace CommandRefGen.Tests; + +public class CategoriesTests : IDisposable +{ + private readonly List notes = new(); + private readonly string directory = Path.Combine(Path.GetTempPath(), "commandrefgen-tests", Guid.NewGuid().ToString("N")); + + private Categories Make(Dictionary? rules = null, string[]? order = null) => + new(rules ?? new Dictionary(), order ?? Array.Empty(), notes.Add); + + [Fact] + public void Applies_the_longest_matching_prefix() + { + var categories = Make(new Dictionary + { + ["Editor/Commands/Scripts/"] = "Scripts & compilation", + ["Editor/Commands/Scripts/SerializedFieldCommands.cs"] = "GameObjects & components", + }); + + Assert.Equal("GameObjects & components", categories.SectionFor("Editor/Commands/Scripts/SerializedFieldCommands.cs")); + Assert.Equal("Scripts & compilation", categories.SectionFor("Editor/Commands/Scripts/CreateScriptCommand.cs")); + } + + [Fact] + public void Derives_the_section_from_the_directory_when_no_rule_matches() + { + Assert.Equal("VFX", Make().SectionFor("Editor/Commands/VFX/VfxCommands.cs")); + Assert.Contains(notes, n => n.Contains("VFX")); + } + + [Fact] + public void Derives_from_the_directory_directly_under_the_commands_root() + { + Assert.Equal("Capture", Make().SectionFor("Editor/Commands/Capture/Overlays/OverlayCommands.cs")); + } + + [Fact] + public void Derives_a_section_for_a_runtime_directory_too() + { + Assert.Equal("Input", Make().SectionFor("Runtime/Commands/Input/RuntimeInputCommands.cs")); + } + + [Fact] + public void Notes_each_derived_section_once() + { + var categories = Make(); + categories.SectionFor("Editor/Commands/VFX/VfxCommands.cs"); + categories.SectionFor("Editor/Commands/VFX/VfxGraphCommands.cs"); + + Assert.Single(notes); + } + + [Fact] + public void A_rule_beats_the_derived_name() + { + var categories = Make(new Dictionary + { + ["Editor/Commands/Observability/"] = "Console & logs", + }); + + Assert.Equal("Console & logs", categories.SectionFor("Editor/Commands/Observability/ConsoleCommands.cs")); + Assert.Empty(notes); + } + + [Fact] + public void Files_a_root_level_file_with_no_rule_under_the_fallback() + { + Assert.Equal(Categories.Fallback, Make().SectionFor("Editor/Commands/NewCommand.cs")); + Assert.Equal(Categories.Fallback, Make().SectionFor("Runtime/Commands/NewCommand.cs")); + } + + [Fact] + public void Sorts_listed_titles_first_and_unknown_ones_after_them() + { + var categories = Make(order: new[] { "Capture", "Scenes" }); + + Assert.True(categories.SortKey("Capture") < categories.SortKey("Scenes")); + Assert.True(categories.SortKey("Scenes") < categories.SortKey("VFX")); + } + + [Fact] + public void Reports_a_rule_whose_prefix_matches_no_source() + { + var categories = Make(new Dictionary + { + ["Editor/Commands/Scenes/"] = "Scenes", + ["Editor/Commands/GoneCommand.cs"] = "Gone", + }); + var warnings = new List(); + + categories.ReportUnusedRules(new[] { "Editor/Commands/Scenes/SceneCommands.cs" }, warnings.Add); + + Assert.Contains(warnings, w => w.Contains("GoneCommand.cs")); + Assert.DoesNotContain(warnings, w => w.Contains("Scenes/")); + } + + [Fact] + public void Load_reads_rules_and_order_from_the_sidecar() + { + var path = WriteSidecar(""" + { + "rules": { "Editor/Commands/Observability/": "Console & logs" }, + "order": [ "Console & logs" ] + } + """); + + var categories = Categories.Load(path, notes.Add); + + Assert.Equal("Console & logs", categories.SectionFor("Editor/Commands/Observability/ConsoleCommands.cs")); + Assert.True(categories.SortKey("Console & logs") < categories.SortKey("VFX")); + } + + [Fact] + public void Load_rejects_a_missing_file() => + Assert.Throws(() => Categories.Load(Path.Combine(directory, "categories.json"), notes.Add)); + + [Fact] + public void Load_rejects_a_duplicated_rule() + { + // JsonSerializer would keep the last value and silently drop the first — a config error that + // must surface, not vanish. + var path = WriteSidecar(""" + { + "rules": { + "Editor/Commands/Scenes/": "Scenes", + "Editor/Commands/Scenes/": "Levels" + }, + "order": [] + } + """); + + var exception = Assert.Throws(() => Categories.Load(path, notes.Add)); + Assert.Contains("Editor/Commands/Scenes/", exception.Message); + } + + [Fact] + public void Load_rejects_a_duplicated_order_entry() + { + var path = WriteSidecar(""" + { + "rules": {}, + "order": [ "Scenes", "Scenes" ] + } + """); + + var exception = Assert.Throws(() => Categories.Load(path, notes.Add)); + Assert.Contains("Scenes", exception.Message); + } + + private string WriteSidecar(string json) + { + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, "categories.json"); + File.WriteAllText(path, json); + return path; + } + + public void Dispose() + { + try + { + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // A scanner still holding a freshly written file must not fail a test that already passed. + } + } +} diff --git a/tools/command-ref-gen/CommandRefGen.Tests/MarkdownWriterTests.cs b/tools/command-ref-gen/CommandRefGen.Tests/MarkdownWriterTests.cs new file mode 100644 index 0000000..ec1b3d9 --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen.Tests/MarkdownWriterTests.cs @@ -0,0 +1,96 @@ +using Xunit; + +namespace CommandRefGen.Tests; + +/// +/// The writer's output is read both as raw markdown by an agent and as a rendered page by a person, and +/// the two disagree about what plain text is. These cases pin the places where that matters. +/// +public class MarkdownWriterTests +{ + private readonly List warnings = new(); + + [Fact] + public void Makes_an_angle_bracketed_placeholder_survive_rendering() + { + // A markdown renderer reads as an HTML tag and drops it, leaving a path with no root. + var markdown = Render("Writes under /Temp/screenshots."); + + Assert.Contains("Writes under ``/Temp/screenshots.", markdown); + } + + [Fact] + public void Leaves_a_placeholder_that_already_sits_in_a_code_span() + { + // Wrapping it again would close the surrounding span early and break the rest of the line. + var description = "Run `unity command --runtime --json` first."; + + Assert.Contains(description, Render(description)); + } + + [Fact] + public void Marks_up_a_placeholder_outside_a_code_span_while_leaving_the_span_alone() + { + var markdown = Render("Run `unity --runtime ` and write under /Temp."); + + Assert.Contains("Run `unity --runtime ` and write under ``/Temp.", markdown); + } + + [Fact] + public void Makes_a_generic_type_survive_rendering() + { + // A renderer reads as an HTML tag and drops it, leaving the collection without its + // element type. + var markdown = Render("Nothing special.", argumentType: "List"); + + Assert.Contains("`output` `List`", markdown); + } + + [Fact] + public void Leaves_a_plain_type_unquoted() + { + Assert.Contains("`output` String", Render("Nothing special.")); + } + + [Theory] + [InlineData("{{VERSON}}")] + [InlineData("{{Version}}")] + [InlineData("{{package}}")] + public void Reports_a_placeholder_the_templates_spell_wrong(string placeholder) + { + // Substitute matches its placeholders exactly, so a wrong spelling and a wrong case are equally + // unfilled and would reach the committed file just as literally. + Render("Nothing special.", preamble: $"Version {placeholder}."); + + Assert.Contains(warnings, w => w.Contains(placeholder)); + } + + [Fact] + public void Reports_a_file_whose_preamble_links_to_a_section_it_will_not_contain() + { + Render("Nothing special."); + + Assert.Contains(warnings, w => w.Contains("no target")); + } + + private string Render(string description, string preamble = "# Title", string argumentType = "String") + { + var command = new CommandInfo( + "screenshot", + description, + MainThreadRequired: true, + RuntimeOnly: false, + Args: new[] { new CommandArg("output", description, argumentType, Required: false, DefaultValue: null, Array.Empty()) }, + Gates: Array.Empty(), + SourcePath: "Editor/Commands/ScreenshotCommand.cs", + SourceLine: 1); + + var templates = new MarkdownWriter.TemplateSet("", preamble, "Runtime intro."); + var categories = new Categories( + new Dictionary { ["Editor/Commands/ScreenshotCommand.cs"] = "Capture" }, + new[] { "Capture" }, + warnings.Add); + return new MarkdownWriter(new Annotations(), categories, warnings.Add) + .Render(new[] { command }, "0.4.0-exp.1", "com.unity.pipeline", templates); + } +} diff --git a/tools/command-ref-gen/CommandRefGen.Tests/ReferenceStampTests.cs b/tools/command-ref-gen/CommandRefGen.Tests/ReferenceStampTests.cs new file mode 100644 index 0000000..b3b7001 --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen.Tests/ReferenceStampTests.cs @@ -0,0 +1,69 @@ +using Xunit; + +namespace CommandRefGen.Tests; + +/// +/// The stamp is what `--version current` reads to learn which package version the committed file +/// documents. Writing it and reading it back live in different places, so only a round trip catches a +/// format change made on one side alone. +/// +public class ReferenceStampTests : IDisposable +{ + private readonly string path = Path.Combine(Path.GetTempPath(), "commandrefgen-tests", Guid.NewGuid().ToString("N") + ".md"); + + [Fact] + public void Reads_back_the_version_it_wrote() + { + Write(ReferenceStamp.Format("com.unity.pipeline", "0.4.0-exp.1")); + + Assert.Equal("0.4.0-exp.1", ReferenceStamp.Read(path, "com.unity.pipeline")); + } + + [Fact] + public void Finds_the_stamp_after_a_crlf_checkout() + { + Write($"\r\n\r\n{ReferenceStamp.Format("com.unity.pipeline", "0.4.0-exp.1")}\r\n\r\n# Title\r\n"); + + Assert.Equal("0.4.0-exp.1", ReferenceStamp.Read(path, "com.unity.pipeline")); + } + + [Fact] + public void Refuses_a_file_that_does_not_exist() => + Assert.Throws(() => ReferenceStamp.Read(path, "com.unity.pipeline")); + + [Fact] + public void Refuses_a_file_written_before_the_stamp_existed() + { + Write("# Editor command reference\n\nNo stamp here.\n"); + + Assert.Throws(() => ReferenceStamp.Read(path, "com.unity.pipeline")); + } + + [Fact] + public void Refuses_a_file_generated_from_another_package() + { + Write(ReferenceStamp.Format("com.unity.other", "0.4.0-exp.1")); + + var failure = Assert.Throws(() => ReferenceStamp.Read(path, "com.unity.pipeline")); + Assert.Contains("com.unity.other", failure.Message); + } + + private void Write(string contents) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, contents); + } + + public void Dispose() + { + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // A scanner still holding a freshly written file must not fail a test that already passed. + } + } +} diff --git a/tools/command-ref-gen/CommandRefGen/Annotations.cs b/tools/command-ref-gen/CommandRefGen/Annotations.cs new file mode 100644 index 0000000..eeb342a --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen/Annotations.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CommandRefGen; + +/// +/// Field knowledge that is true of a command but is not stated in the package sources — behaviour +/// observed while driving a real editor. It lives here, next to the generator, so that the reference +/// file itself stays fully generated and nothing has to be re-applied by hand after a regeneration. +/// +public sealed class Annotations +{ + /// Command name → extra markdown appended to the generated description. + [JsonPropertyName("commands")] + public Dictionary Commands { get; init; } = new(StringComparer.Ordinal); + + /// Reads the sidecar, or returns an empty set when the file is absent. + public static Annotations Load(string path, Action warn) + { + if (!File.Exists(path)) + { + warn($"{path}: annotations file not found — descriptions will carry no field notes"); + return new Annotations(); + } + + return JsonSerializer.Deserialize(File.ReadAllText(path)) + ?? throw new InvalidOperationException($"{path}: annotations file is empty"); + } + + /// Reports annotations that no longer match a command, so stale notes cannot rot unnoticed. + public void ReportUnused(IEnumerable commandNames, Action warn) + { + var known = new HashSet(commandNames, StringComparer.Ordinal); + foreach (var name in Commands.Keys.Where(name => !known.Contains(name)).OrderBy(n => n, StringComparer.Ordinal)) + warn($"annotation for '{name}' matches no command in this package version"); + } +} diff --git a/tools/command-ref-gen/CommandRefGen/Categories.cs b/tools/command-ref-gen/CommandRefGen/Categories.cs new file mode 100644 index 0000000..7dae5d9 --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen/Categories.cs @@ -0,0 +1,133 @@ +using System.Text.Json; + +namespace CommandRefGen; + +/// +/// Maps a command's source location to the reference section it is documented under. Only commands the +/// editor lists reach this; the rest go to whatever their source path. +/// +/// The rules live in the categories.json sidecar next to the executable: a map of package-relative path +/// prefixes to section titles, longest match wins, so a file rule overrides the directory rule around +/// it. A source path matching no rule falls back to the name of its directory under the commands root +/// (Editor/Commands/VFX/... → "VFX"): a directory a future package version adds gets a usable +/// section without waiting for a rule edit. The derivation is reported through +/// so the title can be replaced with a deliberate one; it is not a warning, because a run that derives +/// a section is still correct and must keep passing in --strict. A root-level file matching no rule has +/// no directory to take a name from and lands in , which the writer warns about. +/// +public sealed class Categories(IReadOnlyDictionary rules, IReadOnlyList order, Action note) +{ + /// Title of the section that receives commands with no matching rule and no directory to name one. + public const string Fallback = "Other"; + + /// Title of the trailing section for commands the editor hides from its listing. + public const string RuntimeOnly = "RuntimeOnly commands (hidden from the listing)"; + + private readonly HashSet notedSections = new(StringComparer.Ordinal); + + /// Reads the sidecar. The file shapes the whole output, so its absence is an error, not a default. + public static Categories Load(string path, Action note) + { + if (!File.Exists(path)) + throw new FileNotFoundException($"missing categories sidecar: {path}", path); + + using var document = JsonDocument.Parse(File.ReadAllText(path)); + var root = document.RootElement; + + // JsonSerializer would keep the last of two identical keys and silently drop the first; read + // the document by hand so a duplicated rule is an error instead. + if (!root.TryGetProperty("rules", out var rulesElement)) + throw new InvalidOperationException($"{path}: no 'rules' object"); + + var rules = new Dictionary(StringComparer.Ordinal); + foreach (var rule in rulesElement.EnumerateObject()) + { + var section = rule.Value.ValueKind == JsonValueKind.String ? rule.Value.GetString() : null; + if (string.IsNullOrEmpty(section)) + throw new InvalidOperationException($"{path}: rule \"{rule.Name}\" does not name a section"); + + if (!rules.TryAdd(rule.Name, section)) + throw new InvalidOperationException($"{path}: rule \"{rule.Name}\" is declared twice"); + } + + if (!root.TryGetProperty("order", out var orderElement)) + throw new InvalidOperationException($"{path}: no 'order' array"); + + var order = new List(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var entry in orderElement.EnumerateArray()) + { + var title = entry.ValueKind == JsonValueKind.String ? entry.GetString() : null; + if (string.IsNullOrEmpty(title)) + throw new InvalidOperationException($"{path}: 'order' contains an entry that is not a section title"); + + if (!seen.Add(title)) + throw new InvalidOperationException($"{path}: 'order' lists \"{title}\" twice"); + + order.Add(title); + } + + return new Categories(rules, order, note); + } + + /// + /// Section for a package-relative source path: the longest matching rule, else the directory name + /// under the commands root, else . + /// + public string SectionFor(string sourcePath) + { + string? best = null; + var bestLength = -1; + foreach (var (prefix, section) in rules) + { + if (sourcePath.StartsWith(prefix, StringComparison.Ordinal) && prefix.Length > bestLength) + { + best = section; + bestLength = prefix.Length; + } + } + + if (best is not null) + return best; + + // Command sources always sit under /Commands/ (the parser reads nowhere else), so a + // fourth path segment means the file has a directory of its own to take a section name from. + var segments = sourcePath.Split('/'); + if (segments.Length >= 4 && segments[1] == "Commands") + { + var derived = segments[2]; + if (notedSections.Add(derived)) + note($"no rule covers {segments[0]}/Commands/{derived}/ — section \"{derived}\" is named after the directory; add a rule to rename or regroup it"); + + return derived; + } + + return Fallback; + } + + /// Sort key for a section title; titles absent from the configured order come after every listed one. + public int SortKey(string section) + { + for (var i = 0; i < order.Count; i++) + { + if (string.Equals(order[i], section, StringComparison.Ordinal)) + return i; + } + + return order.Count; + } + + /// + /// Reports rules whose prefix matches none of — after a package + /// update renames or removes a file, this is what says the sidecar needs the matching edit. + /// + public void ReportUnusedRules(IEnumerable sourcePaths, Action warn) + { + var paths = sourcePaths.ToList(); + foreach (var (prefix, section) in rules.OrderBy(r => r.Key, StringComparer.Ordinal)) + { + if (!paths.Any(path => path.StartsWith(prefix, StringComparison.Ordinal))) + warn($"categories.json rule \"{prefix}\" → \"{section}\" matches no command source in this package version"); + } + } +} diff --git a/tools/command-ref-gen/CommandRefGen/CommandRefGen.csproj b/tools/command-ref-gen/CommandRefGen/CommandRefGen.csproj index 16088d2..03fc2f9 100644 --- a/tools/command-ref-gen/CommandRefGen/CommandRefGen.csproj +++ b/tools/command-ref-gen/CommandRefGen/CommandRefGen.csproj @@ -14,4 +14,10 @@ + + + + + + diff --git a/tools/command-ref-gen/CommandRefGen/MarkdownWriter.cs b/tools/command-ref-gen/CommandRefGen/MarkdownWriter.cs new file mode 100644 index 0000000..e1754e9 --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen/MarkdownWriter.cs @@ -0,0 +1,221 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace CommandRefGen; + +/// +/// Renders the whole reference file. Every line of the output comes from here: the header, the preamble +/// templates, the contents list, the categorised sections and the RuntimeOnly section. Nothing in the +/// output file is meant to be edited by hand. +/// +public sealed partial class MarkdownWriter(Annotations annotations, Categories categories, Action warn) +{ + [GeneratedRegex(@"^UNITY_(?\d+)_(?\d+)(_(?\d+))?_OR_NEWER$")] + private static partial Regex UnityVersionSymbol(); + + [GeneratedRegex(@"[^a-z0-9 -]")] + private static partial Regex AnchorNoise(); + + // Any spelling, because Substitute matches its placeholders exactly: {{Version}} and {{VERSION_2}} + // are as unfilled as {{VERSON}} and would reach the file just as literally. + [GeneratedRegex(@"\{\{[A-Za-z0-9_]+\}\}")] + private static partial Regex UnresolvedPlaceholder(); + + // , and friends are placeholders in the package's own prose, but a markdown + // renderer reads them as HTML tags and drops them, leaving a sentence with a hole in it. Code spans + // are matched too, only to be handed back untouched — a placeholder already inside one is safe, and + // wrapping it again would split the span. + [GeneratedRegex(@"(?`[^`]*`)|<(?[A-Za-z][A-Za-z0-9 _-]*)>")] + private static partial Regex AngleBracketPlaceholder(); + + /// Builds the file contents for at package . + public string Render(IReadOnlyList commands, string version, string packageName, TemplateSet templates) + { + var listed = commands.Where(c => !c.RuntimeOnly).ToList(); + var runtimeOnly = commands.Where(c => c.RuntimeOnly).ToList(); + + var sections = listed + .GroupBy(c => categories.SectionFor(c.SourcePath)) + .OrderBy(g => categories.SortKey(g.Key)) + .ThenBy(g => g.Key, StringComparer.Ordinal) + .ToList(); + + foreach (var command in sections.Where(s => s.Key == Categories.Fallback).SelectMany(s => s)) + warn($"no section rule matches {command.SourcePath} (command '{command.Name}') — filed under \"{Categories.Fallback}\""); + + var titles = sections.Select(s => s.Key).ToList(); + if (runtimeOnly.Count > 0) + titles.Add(Categories.RuntimeOnly); + else + warn($"no command sets RuntimeOnly — the preamble's link to \"{Categories.RuntimeOnly}\" has no target in this file"); + + var builder = new StringBuilder(); + builder.Append(Substitute(templates.Header, version, packageName, listed.Count, runtimeOnly.Count)); + builder.AppendLine(ReferenceStamp.Format(packageName, version)); + builder.AppendLine(); + builder.Append(Substitute(templates.Preamble, version, packageName, listed.Count, runtimeOnly.Count)); + + builder.AppendLine("## Contents"); + builder.AppendLine(); + builder.AppendLine(string.Join(" · ", titles.Select(t => $"[{t}](#{Anchor(t)})"))); + builder.AppendLine(); + + foreach (var section in sections) + { + builder.AppendLine($"## {section.Key}"); + builder.AppendLine(); + foreach (var command in section.OrderBy(c => c.Name, StringComparer.Ordinal)) + AppendCommand(builder, command); + } + + if (runtimeOnly.Count > 0) + { + builder.AppendLine($"## {Categories.RuntimeOnly}"); + builder.AppendLine(); + builder.Append(Substitute(templates.RuntimeOnlyIntro, version, packageName, listed.Count, runtimeOnly.Count)); + foreach (var command in runtimeOnly.OrderBy(c => c.Name, StringComparer.Ordinal)) + AppendCommand(builder, command); + } + + // AppendLine follows the host platform; the reference is committed with LF endings everywhere. + var markdown = builder.ToString().Replace("\r\n", "\n").TrimEnd() + "\n"; + + foreach (var unresolved in UnresolvedPlaceholder().Matches(markdown).Select(m => m.Value).Distinct(StringComparer.Ordinal)) + warn($"the templates use {unresolved}, which this generator does not fill in — it reaches the file as written"); + + return markdown; + } + + private void AppendCommand(StringBuilder builder, CommandInfo command) + { + builder.AppendLine($"### {command.Name}"); + + var description = command.Description; + ReportIfOverlong(description, command.Name, command); + + if (annotations.Commands.TryGetValue(command.Name, out var note) && note.Length > 0) + description = Join(description, note); + + var flags = new List(); + if (!command.MainThreadRequired) + flags.Add("works while main thread is busy"); + flags.AddRange(command.Gates.Select(DescribeGate)); + + description = Renderable(description); + builder.AppendLine(flags.Count > 0 ? $"{description} *({string.Join(", ", flags)})*" : description); + + if (command.Args.Count == 0) + builder.AppendLine("- *(no arguments)*"); + else + AppendArgs(builder, command.Args, command, string.Empty, indent: 0); + + builder.AppendLine(); + } + + /// + /// Writes one bullet per argument, indenting the fields of a structured argument underneath it so a + /// reader can see the shape of the JSON object the argument expects. + /// + private void AppendArgs(StringBuilder builder, IReadOnlyList args, CommandInfo command, string path, int indent) + { + var margin = new string(' ', indent * 2); + + foreach (var arg in args) + { + ReportIfOverlong(arg.Description, $"{command.Name} {path}{arg.Name}", command); + + var required = arg.Required ? "\\*" : string.Empty; + var defaultValue = arg.DefaultValue is null ? string.Empty : $" (default {arg.DefaultValue})"; + var text = arg.Description.Length > 0 ? $" — {Renderable(arg.Description)}" : string.Empty; + + // A generic type carries angle brackets, which a renderer reads as an HTML tag and drops — + // "List" without its element type. Code makes them literal. + var type = arg.Type.Contains('<') ? $"`{arg.Type}`" : arg.Type; + builder.AppendLine($"{margin}- `{arg.Name}`{required} {type}{defaultValue}{text}"); + + if (arg.Members.Count > 0) + AppendArgs(builder, arg.Members, command, $"{path}{arg.Name}.", indent + 1); + } + } + + /// + /// Descriptions are emitted in full whatever their length; past the threshold the generator only says + /// so, because a wall of text in the sources is a problem to fix upstream, not text to cut here. + /// + private void ReportIfOverlong(string text, string subject, CommandInfo command) + { + if (text.Length > Prose.LongDescriptionThreshold) + warn($"{subject} has a {text.Length}-character description ({command.SourcePath}:{command.SourceLine}) — emitted in full"); + } + + /// Appends a field note to a source description, supplying the sentence break the source omits. + private static string Join(string description, string note) + { + if (description.Length == 0) + return note; + + var separator = description[^1] is '.' or '!' or '?' or ':' ? " " : ". "; + return description + separator + note; + } + + /// Turns a preprocessor condition into the availability note printed next to the command. + private string DescribeGate(string condition) + { + var symbol = condition.Trim(); + + var unity = UnityVersionSymbol().Match(symbol); + if (unity.Success) + { + var version = unity.Groups["patch"].Success + ? $"{unity.Groups["major"].Value}.{unity.Groups["minor"].Value}.{unity.Groups["patch"].Value}" + : $"{unity.Groups["major"].Value}.{unity.Groups["minor"].Value}"; + return $"Unity {version}+ only"; + } + + // Every gate the package uses today is a Unity version check. Anything else is printed as it + // stands, with a warning, so that a new kind of gate is noticed and given a readable form here + // rather than reaching the reference as jargon. + warn($"preprocessor condition `{symbol}` has no readable form — printed verbatim"); + return $"compiled under {symbol}"; + } + + /// Keeps an angle-bracketed placeholder visible in a rendered page by making it code. + private static string Renderable(string text) => + AngleBracketPlaceholder().Replace( + text, + match => match.Groups["code"].Success ? match.Value : $"`<{match.Groups["placeholder"].Value}>`"); + + /// GitHub's heading anchor: lowercase, punctuation dropped, spaces to hyphens. + private static string Anchor(string title) => + AnchorNoise().Replace(title.ToLowerInvariant(), string.Empty).Replace(' ', '-'); + + private static string Substitute(string template, string version, string packageName, int listedCount, int runtimeOnlyCount) => + template + .Replace("{{PACKAGE}}", packageName) + .Replace("{{VERSION}}", version) + .Replace("{{LISTED_COUNT}}", listedCount.ToString()) + .Replace("{{RUNTIME_ONLY_COUNT}}", runtimeOnlyCount.ToString()) + .Replace("{{TOTAL_COUNT}}", (listedCount + runtimeOnlyCount).ToString()) + .Replace("{{RUNTIME_ONLY_SECTION}}", Categories.RuntimeOnly) + .Replace("{{RUNTIME_ONLY_ANCHOR}}", Anchor(Categories.RuntimeOnly)); + + /// The hand-written prose blocks the generator splices into the output. + public sealed record TemplateSet(string Header, string Preamble, string RuntimeOnlyIntro) + { + /// Loads the templates from . + public static TemplateSet Load(string directory) => new( + Read(directory, "header.md"), + Read(directory, "preamble.md"), + Read(directory, "runtime-only-intro.md")); + + private static string Read(string directory, string name) + { + var path = Path.Combine(directory, name); + if (!File.Exists(path)) + throw new FileNotFoundException($"missing template: {path}", path); + + // Normalise so the output has the same line endings on every platform. + return File.ReadAllText(path).Replace("\r\n", "\n").TrimEnd() + "\n\n"; + } + } +} diff --git a/tools/command-ref-gen/CommandRefGen/ReferenceStamp.cs b/tools/command-ref-gen/CommandRefGen/ReferenceStamp.cs new file mode 100644 index 0000000..68878d9 --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen/ReferenceStamp.cs @@ -0,0 +1,46 @@ +using System.Text.RegularExpressions; + +namespace CommandRefGen; + +/// +/// The machine-readable line the generator writes into the reference file recording which package +/// version produced it. It is emitted by the generator itself rather than by a template, so that +/// rewording the templates cannot break reading it back. +/// +/// Reading it back is what --version current does: regenerate against the version the file +/// already documents. That keeps an automated --check honest about generator changes without +/// turning red the moment a new package version is published. +/// +public static partial class ReferenceStamp +{ + [GeneratedRegex(@"^$", RegexOptions.Multiline)] + private static partial Regex StampLine(); + + /// Formats the stamp for at . + public static string Format(string packageName, string version) => + $""; + + /// Reads the version recorded in . + /// The file is missing, unstamped, or documents another package. + public static string Read(string path, string expectedPackage) + { + if (!File.Exists(path)) + throw new InvalidOperationException($"--version current needs an existing reference file; {path} does not exist"); + + var match = StampLine().Match(File.ReadAllText(path).Replace("\r\n", "\n")); + if (!match.Success) + { + throw new InvalidOperationException( + $"{path} carries no 'generated-from' line — it predates this generator, so pass an explicit --version once"); + } + + var package = match.Groups["package"].Value; + if (package != expectedPackage) + { + throw new InvalidOperationException( + $"{path} was generated from {package}, not {expectedPackage} — pass --package {package} or an explicit --version"); + } + + return match.Groups["version"].Value; + } +} diff --git a/tools/command-ref-gen/CommandRefGen/annotations.json b/tools/command-ref-gen/CommandRefGen/annotations.json new file mode 100644 index 0000000..bd08def --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen/annotations.json @@ -0,0 +1,7 @@ +{ + "//": "Behaviour observed while driving a real editor that the package sources do not state. Appended verbatim to the generated description of the named command. Keep each note short and factual; anything the sources already say belongs in the sources, not here.", + "commands": { + "simulate_pointer": "**Feeds a virtual device, not the OS cursor — UI raycasts work, but game code polling `Mouse.current` sees the virtual mouse; verify the click landed via its response, not via assumption.**", + "quit": "Against the editor this is the play-mode/app quit path — to shut the editor itself down, prefer `eval EditorApplication.Exit(0)` (see [lifecycle-recovery.md](lifecycle-recovery.md))." + } +} diff --git a/tools/command-ref-gen/CommandRefGen/categories.json b/tools/command-ref-gen/CommandRefGen/categories.json new file mode 100644 index 0000000..a5b9a54 --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen/categories.json @@ -0,0 +1,62 @@ +{ + "rules": { + "Editor/Commands/PlayModeCommands.cs": "Editor & play mode", + "Editor/Commands/EditorStatusCommand.cs": "Editor & play mode", + "Editor/Commands/FocusEditorCommand.cs": "Editor & play mode", + "Editor/Commands/AutoTickCommand.cs": "Editor & play mode", + "Editor/Commands/MenuItemCommand.cs": "Editor & play mode", + + "Editor/Commands/Capture/": "Capture", + "Editor/Commands/CaptureEditorElementCommand.cs": "Capture", + "Editor/Commands/ScreenshotCommand.cs": "Capture", + + "Editor/Commands/Observability/": "Console, logs & performance", + "Runtime/Commands/ConsoleCommand.cs": "Console, logs & performance", + + "Editor/Commands/Navigation/": "Search & selection", + "Editor/Commands/Scenes/": "Scenes", + "Editor/Commands/GameObjects/": "GameObjects & components", + "Editor/Commands/Scripts/SerializedFieldCommands.cs": "GameObjects & components", + "Editor/Commands/Prefabs/": "Prefabs", + "Editor/Commands/Assets/": "Assets & files", + "Editor/Commands/Authoring/": "Authoring", + + "Editor/Commands/Scripts/": "Scripts & compilation", + "Editor/Commands/RecompileCommand.cs": "Scripts & compilation", + "Runtime/Commands/CodeEvalCommand.cs": "Scripts & compilation", + "Runtime/Commands/HotReloadCommands.cs": "Scripts & compilation", + + "Editor/Commands/TestCommands.cs": "Tests", + "Editor/Commands/Build/": "Build", + "Editor/Commands/PackageManager/": "Packages (UPM)", + "Editor/Commands/Materials/": "Materials & shaders", + "Editor/Commands/Animation/": "Animation & Timeline", + + "Editor/Commands/Baking/LightingBakeCommands.cs": "Lighting", + "Editor/Commands/Baking/NavMeshBakeCommands.cs": "NavMesh", + "Editor/Commands/Baking/OcclusionBakeCommands.cs": "Occlusion culling", + + "Editor/Commands/ProjectSettings/": "Project settings" + }, + "order": [ + "Editor & play mode", + "Capture", + "Console, logs & performance", + "Search & selection", + "Scenes", + "GameObjects & components", + "Prefabs", + "Assets & files", + "Authoring", + "Scripts & compilation", + "Tests", + "Build", + "Packages (UPM)", + "Materials & shaders", + "Animation & Timeline", + "Lighting", + "NavMesh", + "Occlusion culling", + "Project settings" + ] +} diff --git a/tools/command-ref-gen/CommandRefGen/templates/header.md b/tools/command-ref-gen/CommandRefGen/templates/header.md new file mode 100644 index 0000000..7743c0c --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen/templates/header.md @@ -0,0 +1,9 @@ + diff --git a/tools/command-ref-gen/CommandRefGen/templates/preamble.md b/tools/command-ref-gen/CommandRefGen/templates/preamble.md new file mode 100644 index 0000000..c48d6b7 --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen/templates/preamble.md @@ -0,0 +1,7 @@ +# Editor command reference + +Every `[CliCommand]` declared by the `{{PACKAGE}}` package at version `{{VERSION}}` — {{TOTAL_COUNT}} commands, {{LISTED_COUNT}} of them advertised by an editor server and {{RUNTIME_ONLY_COUNT}} hidden from its listing. The commands a given editor actually answers to are dynamic (package version, optional packages, project-defined `[CliCommand]` methods); commands defined by a project rather than by the package are outside this file, so on a name or argument mismatch re-check against the live output of `unity --json command`. + +**This file is more complete than `unity command`.** Commands whose `[CliCommand]` attribute sets `RuntimeOnly = true` are filtered out of the editor listing — they are designed for Unity **Player** connections (`unity command --runtime `) — yet the editor server still executes them when called by name, in edit mode and play mode alike. The CLI never reveals their schemas; the [{{RUNTIME_ONLY_SECTION}}](#{{RUNTIME_ONLY_ANCHOR}}) section below is the only schema source for them. Absence from the listing is not proof a command does not exist — a genuinely unknown name fails with exit code 6. + +Argument conventions: pass every argument as `--name value`, spelled exactly as this file lists it — most are snake_case, but a good few are camelCase (`assemblyDir`, `frameRate`, `exitCode`), and the server matches the name verbatim. `*` marks an argument the server refuses to run without — it can still carry a default, which is what the server would have used had the argument been optional. Destructive commands need `--confirm true`; most mutating commands accept `--dry_run true`; async commands pair with a `*_status` poll command. A note such as *(Unity 6000.7+ only)* means the command is compiled out on older editors and fails there with exit code 6. diff --git a/tools/command-ref-gen/CommandRefGen/templates/runtime-only-intro.md b/tools/command-ref-gen/CommandRefGen/templates/runtime-only-intro.md new file mode 100644 index 0000000..788f319 --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen/templates/runtime-only-intro.md @@ -0,0 +1 @@ +Declared with `RuntimeOnly = true` in the `{{PACKAGE}}` `{{VERSION}}` sources — `unity command` neither lists them nor shows their schemas. They execute against the editor server despite the flag, and they work against Player connections (`--runtime ` / `--runtime-path `). A version note on an entry still applies: a command compiled out on the running editor does not exist there and fails with exit code 6. From e4a569d2f316491a3d3f978bbcb4a7a770961920 Mon Sep 17 00:00:00 2001 From: Ark Tarusov Date: Sun, 2 Aug 2026 20:19:48 +0200 Subject: [PATCH 06/10] summarise what changed between two generated references --- .../CommandRefGen.Tests/ReferenceDiffTests.cs | 87 ++++++++ .../CommandRefGen/ReferenceDiff.cs | 191 ++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 tools/command-ref-gen/CommandRefGen.Tests/ReferenceDiffTests.cs create mode 100644 tools/command-ref-gen/CommandRefGen/ReferenceDiff.cs diff --git a/tools/command-ref-gen/CommandRefGen.Tests/ReferenceDiffTests.cs b/tools/command-ref-gen/CommandRefGen.Tests/ReferenceDiffTests.cs new file mode 100644 index 0000000..f0bdf5e --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen.Tests/ReferenceDiffTests.cs @@ -0,0 +1,87 @@ +using Xunit; + +namespace CommandRefGen.Tests; + +/// +/// The change summary is what makes a regeneration reviewable without reading the whole file diff, so a +/// change it fails to report is a change nobody looks at. +/// +public class ReferenceDiffTests +{ + // Normalised, because a raw string literal keeps the line endings of the file it is written in and + // a checkout may hand them over as CRLF. + private static readonly string Baseline = Lf(""" + ## Scenes + + ### open_scene + Open a scene. + - `path`\* String — Scene path. + - `additive` Boolean (default false) — Load additively. + + ### save_scene + Save the open scene. + - *(no arguments)* + """); + + [Fact] + public void Reports_nothing_when_the_file_is_unchanged() => + Assert.Equal(string.Empty, Summarise(Baseline, Baseline)); + + [Fact] + public void Reports_an_added_command() => + Assert.Contains("+ close_scene", Summarise(Baseline, Baseline + "\n\n### close_scene\nClose it.\n- *(no arguments)*\n")); + + [Fact] + public void Reports_a_removed_command() => + Assert.Contains("- save_scene", Summarise(Baseline, Baseline.Replace("### save_scene\nSave the open scene.\n- *(no arguments)*", ""))); + + [Fact] + public void Reports_a_command_that_moved_to_another_section() + { + var summary = Summarise(Baseline, Baseline.Replace("## Scenes", "## Assets & files")); + + Assert.Contains("section: \"Scenes\" -> \"Assets & files\"", summary); + } + + [Fact] + public void Reports_a_changed_default() => + Assert.Contains( + "default false -> true", + Summarise(Baseline, Baseline.Replace("(default false)", "(default true)"))); + + [Fact] + public void Reports_an_argument_that_became_required() => + Assert.Contains( + "required False -> True", + Summarise(Baseline, Baseline.Replace("`additive` Boolean", "`additive`\\* Boolean"))); + + [Fact] + public void Reports_a_field_of_a_structured_argument_by_its_path() + { + var before = "### set_tags_layers\nChange tags.\n- `settings` TagsLayersInput — Changes.\n - `addTags` String[] — Tags to add.\n"; + var after = before + " - `removeTags` String[] — Tags to remove.\n"; + + Assert.Contains("+ arg settings.removeTags", Summarise(before, after)); + } + + [Fact] + public void Reports_a_repeated_argument_instead_of_failing() + { + var warnings = new List(); + var duplicated = "### log\nWrite a message.\n- `message`\\* String — Text.\n- `message`\\* String — Text.\n"; + + var summary = ReferenceDiff.Summarise(duplicated, duplicated, warnings.Add); + + Assert.Equal(string.Empty, summary); + Assert.Contains(warnings, w => w.Contains("message")); + } + + [Fact] + public void Counts_every_command_as_added_when_there_is_no_previous_file() => + Assert.Contains("2 added, 0 removed", ReferenceDiff.Summarise(null, Baseline, _ => { })); + + private static string Summarise(string before, string after) => + ReferenceDiff.Summarise(Lf(before), Lf(after), message => Assert.Fail($"unexpected warning: {message}")); + + private static string Lf(string text) => text.Replace("\r\n", "\n"); +} diff --git a/tools/command-ref-gen/CommandRefGen/ReferenceDiff.cs b/tools/command-ref-gen/CommandRefGen/ReferenceDiff.cs new file mode 100644 index 0000000..dbfe71a --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen/ReferenceDiff.cs @@ -0,0 +1,191 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace CommandRefGen; + +/// +/// Compares a previously written reference file against a freshly generated one and reports what moved, +/// so a regeneration is reviewable without reading the whole diff: added and removed commands, changed +/// descriptions, and per-argument changes. +/// +public static partial class ReferenceDiff +{ + [GeneratedRegex(@"^### (?\S+)\s*$")] + private static partial Regex CommandHeading(); + + [GeneratedRegex(@"^## (?.+?)\s*$")] + private static partial Regex SectionHeading(); + + // The default value may itself contain a closing bracket, so the group only ends where the + // description separator or the end of the line follows. Leading spaces mark a field of the + // structured argument above. + [GeneratedRegex(@"^(?<indent> *)- `(?<name>[^`]+)`(?<required>\\\*)? (?<type>\S+)(?<default> \(default (?<value>.*?)\)(?= — |$))?(?: — (?<description>.*))?$")] + private static partial Regex ArgumentLine(); + + private sealed record ParsedArg(string Name, string Type, bool Required, string? DefaultValue, string Description); + + private sealed record ParsedCommand(string Name, string Section, string Description, List<ParsedArg> Args); + + /// <summary>Renders a human-readable change summary; empty string when nothing changed.</summary> + public static string Summarise(string? previousMarkdown, string currentMarkdown, Action<string> warn) + { + var before = previousMarkdown is null + ? new Dictionary<string, ParsedCommand>(StringComparer.Ordinal) + : Parse(previousMarkdown, warn); + var after = Parse(currentMarkdown, warn); + + var added = after.Keys.Except(before.Keys, StringComparer.Ordinal).OrderBy(n => n, StringComparer.Ordinal).ToList(); + var removed = before.Keys.Except(after.Keys, StringComparer.Ordinal).OrderBy(n => n, StringComparer.Ordinal).ToList(); + var common = after.Keys.Intersect(before.Keys, StringComparer.Ordinal).OrderBy(n => n, StringComparer.Ordinal).ToList(); + + var report = new StringBuilder(); + var changedCommands = 0; + + foreach (var name in added) + report.Append($"+ {name} ({after[name].Args.Count} args)\n"); + foreach (var name in removed) + report.Append($"- {name}\n"); + + foreach (var name in common) + { + var lines = CompareCommand(before[name], after[name], warn); + if (lines.Count == 0) + continue; + + changedCommands++; + report.Append($"~ {name}\n"); + foreach (var line in lines) + report.Append($" {line}\n"); + } + + if (report.Length == 0) + return string.Empty; + + report.Append($"\n{added.Count} added, {removed.Count} removed, {changedCommands} changed" + + $" (was {before.Count} commands, now {after.Count})\n"); + return report.ToString(); + } + + private static List<string> CompareCommand(ParsedCommand before, ParsedCommand after, Action<string> warn) + { + var lines = new List<string>(); + + if (!string.Equals(before.Section, after.Section, StringComparison.Ordinal)) + lines.Add($"section: \"{before.Section}\" -> \"{after.Section}\""); + + if (!string.Equals(before.Description, after.Description, StringComparison.Ordinal)) + lines.Add($"description: {Abbreviate(before.Description)} -> {Abbreviate(after.Description)}"); + + var beforeArgs = ByName(before, warn); + var afterArgs = ByName(after, warn); + + foreach (var name in afterArgs.Keys.Except(beforeArgs.Keys, StringComparer.Ordinal).OrderBy(n => n, StringComparer.Ordinal)) + lines.Add($"+ arg {name} ({afterArgs[name].Type})"); + foreach (var name in beforeArgs.Keys.Except(afterArgs.Keys, StringComparer.Ordinal).OrderBy(n => n, StringComparer.Ordinal)) + lines.Add($"- arg {name}"); + + foreach (var name in afterArgs.Keys.Intersect(beforeArgs.Keys, StringComparer.Ordinal).OrderBy(n => n, StringComparer.Ordinal)) + { + var oldArg = beforeArgs[name]; + var newArg = afterArgs[name]; + var changes = new List<string>(); + + if (oldArg.Type != newArg.Type) + changes.Add($"type {oldArg.Type} -> {newArg.Type}"); + if (oldArg.Required != newArg.Required) + changes.Add($"required {oldArg.Required} -> {newArg.Required}"); + if (oldArg.DefaultValue != newArg.DefaultValue) + changes.Add($"default {oldArg.DefaultValue ?? "(none)"} -> {newArg.DefaultValue ?? "(none)"}"); + if (!string.Equals(oldArg.Description, newArg.Description, StringComparison.Ordinal)) + changes.Add($"description {Abbreviate(oldArg.Description)} -> {Abbreviate(newArg.Description)}"); + + if (changes.Count > 0) + lines.Add($"~ arg {name}: {string.Join("; ", changes)}"); + } + + var beforeOrder = string.Join(",", before.Args.Select(a => a.Name)); + var afterOrder = string.Join(",", after.Args.Select(a => a.Name)); + if (beforeOrder != afterOrder && beforeArgs.Keys.ToHashSet(StringComparer.Ordinal).SetEquals(afterArgs.Keys)) + lines.Add($"argument order: {beforeOrder} -> {afterOrder}"); + + return lines; + } + + /// <summary> + /// Indexes a command's arguments by name. A repeated name means the reference lists the same + /// argument twice; that is worth reporting, but it must not stop the summary. + /// </summary> + private static Dictionary<string, ParsedArg> ByName(ParsedCommand command, Action<string> warn) + { + var byName = new Dictionary<string, ParsedArg>(StringComparer.Ordinal); + + foreach (var arg in command.Args.Where(arg => !byName.TryAdd(arg.Name, arg))) + warn($"'{command.Name}' lists the argument '{arg.Name}' more than once — comparing the first occurrence"); + + return byName; + } + + /// <summary> + /// Reads back the generator's own output format: <c>## section</c>, <c>### name</c>, a description + /// line, argument bullets. + /// </summary> + private static Dictionary<string, ParsedCommand> Parse(string markdown, Action<string> warn) + { + var commands = new Dictionary<string, ParsedCommand>(StringComparer.Ordinal); + var lines = markdown.Replace("\r\n", "\n").Split('\n'); + var section = string.Empty; + + for (var i = 0; i < lines.Length; i++) + { + var sectionHeading = SectionHeading().Match(lines[i]); + if (sectionHeading.Success) + { + section = sectionHeading.Groups["title"].Value; + continue; + } + + var heading = CommandHeading().Match(lines[i]); + if (!heading.Success) + continue; + + var name = heading.Groups["name"].Value; + var description = i + 1 < lines.Length ? lines[i + 1].Trim() : string.Empty; + var args = new List<ParsedArg>(); + + // Fields of a structured argument are indented under it; they are compared as dotted paths + // (settings.addTags) so a change inside a nested object is reported like any other. + var path = new List<string>(); + for (var j = i + 2; j < lines.Length && lines[j].TrimStart().StartsWith("- ", StringComparison.Ordinal); j++) + { + if (lines[j] == "- *(no arguments)*") + break; + + var argument = ArgumentLine().Match(lines[j]); + if (!argument.Success) + { + args.Add(new ParsedArg(lines[j].Trim(), "?", false, null, string.Empty)); + continue; + } + + var depth = argument.Groups["indent"].Value.Length / 2; + path.RemoveRange(Math.Min(depth, path.Count), path.Count - Math.Min(depth, path.Count)); + path.Add(argument.Groups["name"].Value); + + args.Add(new ParsedArg( + string.Join('.', path), + argument.Groups["type"].Value, + argument.Groups["required"].Success, + argument.Groups["default"].Success ? argument.Groups["value"].Value : null, + argument.Groups["description"].Value)); + } + + if (!commands.TryAdd(name, new ParsedCommand(name, section, description, args))) + warn($"the reference lists '{name}' more than once — comparing the first entry"); + } + + return commands; + } + + private static string Abbreviate(string value) => + value.Length <= 60 ? $"\"{value}\"" : $"\"{value[..57]}...\""; +} From b280856539d4ffe3ce1456d32ff563bf00fbd2f8 Mon Sep 17 00:00:00 2001 From: Ark Tarusov <ark.tarusov@devark.pro> Date: Mon, 3 Aug 2026 19:34:21 +0200 Subject: [PATCH 07/10] wire the generator cli: options, exit codes, the whole run --- .../CommandRefGen.Tests/ExitCodeTests.cs | 28 +++ .../CommandRefGen/CommandLine.cs | 91 +++++++ .../CommandRefGen/CommandRefGen.csproj | 1 + .../command-ref-gen/CommandRefGen/ExitCode.cs | 26 ++ .../command-ref-gen/CommandRefGen/Program.cs | 229 ++++++++++++++++++ tools/command-ref-gen/CommandRefGen/README.md | 102 ++++++++ 6 files changed, 477 insertions(+) create mode 100644 tools/command-ref-gen/CommandRefGen.Tests/ExitCodeTests.cs create mode 100644 tools/command-ref-gen/CommandRefGen/CommandLine.cs create mode 100644 tools/command-ref-gen/CommandRefGen/ExitCode.cs create mode 100644 tools/command-ref-gen/CommandRefGen/Program.cs create mode 100644 tools/command-ref-gen/CommandRefGen/README.md diff --git a/tools/command-ref-gen/CommandRefGen.Tests/ExitCodeTests.cs b/tools/command-ref-gen/CommandRefGen.Tests/ExitCodeTests.cs new file mode 100644 index 0000000..4b40d9f --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen.Tests/ExitCodeTests.cs @@ -0,0 +1,28 @@ +using Xunit; + +namespace CommandRefGen.Tests; + +/// <summary> +/// The exit code is the whole contract an automated caller sees, and `--strict` exists so that a warning +/// cannot hide behind a zero. +/// </summary> +public class ExitCodeTests +{ + [Fact] + public void Leaves_a_clean_run_alone() => + Assert.Equal(ExitCode.Success, ExitCode.Settle(ExitCode.Success, strict: true, warnings: 0)); + + [Fact] + public void Leaves_warnings_alone_without_strict() => + Assert.Equal(ExitCode.Success, ExitCode.Settle(ExitCode.Success, strict: false, warnings: 3)); + + [Fact] + public void Turns_a_successful_run_that_warned_into_a_failure() => + Assert.Equal(ExitCode.Warned, ExitCode.Settle(ExitCode.Success, strict: true, warnings: 1)); + + [Theory] + [InlineData(ExitCode.Failure)] + [InlineData(ExitCode.OutOfDate)] + public void Keeps_a_code_that_already_says_something_more_specific(int code) => + Assert.Equal(code, ExitCode.Settle(code, strict: true, warnings: 1)); +} diff --git a/tools/command-ref-gen/CommandRefGen/CommandLine.cs b/tools/command-ref-gen/CommandRefGen/CommandLine.cs new file mode 100644 index 0000000..d78227c --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen/CommandLine.cs @@ -0,0 +1,91 @@ +namespace CommandRefGen; + +/// <summary>Parsed command line for the generator.</summary> +internal sealed record Options( + string Version, + string? Output, + string? Package, + string? Registry, + bool Check, + bool KeepSources, + bool Strict); + +internal static class CommandLine +{ + /// <summary>Returns null when the caller asked for usage; throws when the command line is unusable.</summary> + public static Options? Parse(string[] args) + { + string? version = null, output = null, package = null, registry = null; + var check = false; + var keepSources = false; + var strict = false; + + for (var i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--version": + version = Next(args, ref i); + break; + case "--output": + output = Next(args, ref i); + break; + case "--package": + package = Next(args, ref i); + break; + case "--registry": + registry = Next(args, ref i); + break; + case "--check": + check = true; + break; + case "--keep-sources": + keepSources = true; + break; + case "--strict": + strict = true; + break; + case "--help": + case "-h": + return null; + default: + throw new ArgumentException($"unknown argument '{args[i]}'"); + } + } + + // An empty command line is a caller that meant to pass arguments and passed none — reporting + // success there would let a script believe the reference was checked when nothing ran. + if (version is null) + throw new ArgumentException("--version is required: a published version, 'latest', or 'current'"); + + return new Options(version, output, package, registry, check, keepSources, strict); + } + + public static void PrintUsage() + { + Console.WriteLine(""" + Regenerates the editor command reference from the com.unity.pipeline package sources. + + dotnet run --project tools/command-ref-gen/CommandRefGen -- --version latest + + --version <v|latest|current> + package version to document. 'latest' picks the highest published + version; 'current' reuses the version the output file records + --output <path> file to write (default: skills/unity-pipeline/references/editor-commands.md) + --package <name> UPM package name (default: com.unity.pipeline) + --registry <url> registry base URL (default: https://packages.unity.com) + --check report the diff and exit 2 if the file is out of date, writing nothing + --keep-sources leave the unpacked package in the temp directory + --strict turn a successful run that warned into exit 3; 1 and 2 keep their + meaning, since they say something more specific + """); + } + + private static string Next(string[] args, ref int index) + { + if (index + 1 >= args.Length) + throw new ArgumentException($"{args[index]} needs a value"); + + return args[++index]; + } +} diff --git a/tools/command-ref-gen/CommandRefGen/CommandRefGen.csproj b/tools/command-ref-gen/CommandRefGen/CommandRefGen.csproj index 03fc2f9..034b100 100644 --- a/tools/command-ref-gen/CommandRefGen/CommandRefGen.csproj +++ b/tools/command-ref-gen/CommandRefGen/CommandRefGen.csproj @@ -1,6 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> + <OutputType>Exe</OutputType> <TargetFramework>net8.0</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> diff --git a/tools/command-ref-gen/CommandRefGen/ExitCode.cs b/tools/command-ref-gen/CommandRefGen/ExitCode.cs new file mode 100644 index 0000000..36a323c --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen/ExitCode.cs @@ -0,0 +1,26 @@ +namespace CommandRefGen; + +/// <summary>How a finished run is reported to whoever called it.</summary> +public static class ExitCode +{ + /// <summary>The run did what it was asked to do.</summary> + public const int Success = 0; + + /// <summary>The run could not do it: bad arguments, an unreachable registry, unreadable sources.</summary> + public const int Failure = 1; + + /// <summary><c>--check</c> found the reference out of date.</summary> + public const int OutOfDate = 2; + + /// <summary><c>--strict</c> and the run warned.</summary> + public const int Warned = 3; + + /// <summary> + /// Applies <c>--strict</c> to a finished run. Every warning means the reference may be incomplete — + /// a command filed nowhere, a type that could not be resolved — which a caller polling the exit code + /// alone would not see. Only a run that would otherwise report success is overridden: a failure or an + /// out-of-date file already says something more specific than "something warned". + /// </summary> + public static int Settle(int code, bool strict, int warnings) => + strict && warnings > 0 && code == Success ? Warned : code; +} diff --git a/tools/command-ref-gen/CommandRefGen/Program.cs b/tools/command-ref-gen/CommandRefGen/Program.cs new file mode 100644 index 0000000..80d4d8b --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen/Program.cs @@ -0,0 +1,229 @@ +using System.Text.Json; +using CommandRefGen; + +const string DefaultPackage = "com.unity.pipeline"; +const string DefaultRegistry = "https://packages.unity.com"; +const string DefaultOutputRelativePath = "skills/unity-pipeline/references/editor-commands.md"; + +Options options; +try +{ + var parsed = CommandLine.Parse(args); + if (parsed is null) + { + CommandLine.PrintUsage(); + // Parse returns null only for an explicit --help; anything unusable throws. + return 0; + } + + options = parsed; +} +catch (ArgumentException exception) +{ + Console.Error.WriteLine($"error: {exception.Message}"); + CommandLine.PrintUsage(); + return ExitCode.Failure; +} + +var warnings = 0; +void Warn(string message) +{ + warnings++; + Console.Error.WriteLine($"warning: {message}"); +} + +// HttpClient.Timeout only bounds the wait for response headers; reading the body and unpacking the +// archive run afterwards, so they get a deadline of their own instead of hanging indefinitely. +var registryDeadline = TimeSpan.FromMinutes(10); +using var deadline = new CancellationTokenSource(registryDeadline); + +var exitCode = await Generate(); + +if (warnings > 0) + Console.Error.WriteLine($"{warnings} warning(s)"); + +return ExitCode.Settle(exitCode, options.Strict, warnings); + +// The whole job, so that the exit code is settled only after the temp directory has been cleared: a +// warning raised while cleaning up counts like any other. +async Task<int> Generate() +{ + string? workingDirectory = null; + try + { + var packageName = PathSafe("--package", options.Package ?? DefaultPackage); + var registryUrl = options.Registry ?? DefaultRegistry; + var outputPath = Path.GetFullPath(options.Output ?? Path.Combine(RepoRoot(), DefaultOutputRelativePath.Replace('/', Path.DirectorySeparatorChar))); + + using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; + var registry = new PackageRegistry(http, registryUrl); + var catalogue = await registry.FetchVersionsAsync(packageName, deadline.Token); + + var wantsLatest = options.Version.Equals("latest", StringComparison.OrdinalIgnoreCase); + var version = options.Version.ToLowerInvariant() switch + { + "latest" => PackageRegistry.HighestVersion(catalogue.Tarballs.Keys, Warn), + "current" => ReferenceStamp.Read(outputPath, packageName), + _ => options.Version, + }; + + // With --version latest the version string comes from the registry document, i.e. from remote + // data, and it ends up in a path that is deleted recursively — keep it inside the temp directory. + version = PathSafe("--version", version); + + if (!catalogue.Tarballs.TryGetValue(version, out var tarballUrl)) + { + Console.Error.WriteLine( + $"error: {packageName} has no version {version}; published: {string.Join(", ", catalogue.Tarballs.Keys)}"); + return ExitCode.Failure; + } + + if (wantsLatest && catalogue.LatestTag is { Length: > 0 } tag && tag != version) + Warn($"registry dist-tag 'latest' is {tag} but the highest published version is {version} — using {version}"); + + Console.WriteLine($"{packageName}@{version}"); + Console.WriteLine($"source: {tarballUrl}"); + + // Per process, so that two runs of the same version — a release watch and a manual one — cannot + // delete and unpack the same directory underneath each other. + workingDirectory = Path.Combine(Path.GetTempPath(), "commandrefgen", $"{packageName}@{version}-{Environment.ProcessId}"); + if (Directory.Exists(workingDirectory)) + Directory.Delete(workingDirectory, recursive: true); + + var packageRoot = await registry.DownloadAndExtractAsync(tarballUrl, workingDirectory, deadline.Token); + + var commands = new CommandParser(Warn).Parse(packageRoot); + if (commands.Count == 0) + { + Console.Error.WriteLine($"error: no [CliCommand] methods found under {packageRoot} — the package layout has changed"); + return ExitCode.Failure; + } + + var toolDirectory = AppContext.BaseDirectory; + var annotations = Annotations.Load(Path.Combine(toolDirectory, "annotations.json"), Warn); + annotations.ReportUnused(commands.Select(c => c.Name), Warn); + + // Notes go to stdout, not into Warn: a derived section title is designed behaviour, and turning + // it into a warning would make --strict fail the day the package adds a directory — exactly the + // human-free run the derivation exists for. + var categories = Categories.Load(Path.Combine(toolDirectory, "categories.json"), n => Console.WriteLine($"note: {n}")); + categories.ReportUnusedRules(commands.Where(c => !c.RuntimeOnly).Select(c => c.SourcePath), Warn); + + var templates = MarkdownWriter.TemplateSet.Load(Path.Combine(toolDirectory, "templates")); + var markdown = new MarkdownWriter(annotations, categories, Warn).Render(commands, version, packageName, templates); + + var previous = File.Exists(outputPath) ? File.ReadAllText(outputPath).Replace("\r\n", "\n") : null; + var listed = commands.Count(c => !c.RuntimeOnly); + + Console.WriteLine($"parsed: {commands.Count} commands ({listed} listed, {commands.Count - listed} RuntimeOnly)"); + Console.WriteLine($"output: {outputPath}"); + Console.WriteLine(); + + var summary = ReferenceDiff.Summarise(previous, markdown, Warn); + Console.WriteLine(summary.Length > 0 ? summary : "no command or argument changes against the current file"); + + if (options.Check) + { + if (previous == markdown) + { + Console.WriteLine("check: the reference is up to date"); + return ExitCode.Success; + } + + Console.Error.WriteLine("check: the reference is out of date — rerun without --check to update it"); + return ExitCode.OutOfDate; + } + + var outputDirectory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(outputDirectory)) + Directory.CreateDirectory(outputDirectory); + + File.WriteAllText(outputPath, markdown); + Console.WriteLine($"written: {outputPath}"); + return ExitCode.Success; + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + Console.Error.WriteLine( + $"error: {options.Registry ?? DefaultRegistry} did not answer within {registryDeadline.TotalMinutes:0} minutes — check the network and the registry"); + return ExitCode.Failure; + } + catch (Exception exception) when (exception + is UnsupportedExpressionException + or InvalidOperationException + or ArgumentException + or HttpRequestException + or JsonException + // A --registry value that is not a URL at all. + or FormatException + or IOException + // A body that is not a gzip stream at all — a truncated download, + // or a proxy page served with 200 in place of the tarball. + or InvalidDataException + or UnauthorizedAccessException + or OperationCanceledException) + { + Console.Error.WriteLine($"error: {exception.Message}"); + return ExitCode.Failure; + } + finally + { + if (workingDirectory is not null) + { + if (options.KeepSources) + Console.WriteLine($"sources kept in {workingDirectory}"); + else + RemoveWorkingDirectory(workingDirectory); + } + } +} + +// Clearing the temp directory must not replace the result of the run it cleans up after: a file still +// held open by a scanner or an indexer is worth a warning, not a crash on the way out. +void RemoveWorkingDirectory(string directory) +{ + try + { + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + Warn($"could not remove the temporary directory {directory}: {exception.Message}"); + } +} + +// Rejects a value that would escape the temp directory it is about to be pasted into. +static string PathSafe(string argument, string value) +{ + if (value.Length == 0) + throw new ArgumentException($"{argument} is empty"); + + if (value.Contains("..", StringComparison.Ordinal) || + value.Contains('/') || value.Contains('\\') || + value.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + throw new ArgumentException($"{argument} value '{value}' is not a usable path component"); + } + + return value; +} + +// Walks up from the executable until the checkout that holds the reference file is found. +static string RepoRoot() +{ + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (Directory.Exists(Path.Combine(directory.FullName, ".git")) || + File.Exists(Path.Combine(directory.FullName, ".git"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException( + "cannot locate the repository root from the executable path — pass --output explicitly"); +} diff --git a/tools/command-ref-gen/CommandRefGen/README.md b/tools/command-ref-gen/CommandRefGen/README.md new file mode 100644 index 0000000..859f4cf --- /dev/null +++ b/tools/command-ref-gen/CommandRefGen/README.md @@ -0,0 +1,102 @@ +# CommandRefGen + +Regenerates [`skills/unity-pipeline/references/editor-commands.md`](../../../skills/unity-pipeline/references/editor-commands.md) from the published `com.unity.pipeline` package sources. No Unity Editor, no project, no captured `unity command` dump — only the .NET 8 SDK and network access to the public UPM registry. + +```bash +dotnet run --project tools/command-ref-gen/CommandRefGen -- --version latest +``` + +| Option | Meaning | +| --- | --- | +| `--version <v\|latest\|current>` | Package version to document. `latest` picks the highest published version; `current` reuses the version the output file already records. Required. | +| `--output <path>` | File to write. Defaults to the reference file in this repository. | +| `--package <name>` | UPM package name. Default `com.unity.pipeline`. A scoped name (`@scope/pkg`) is rejected: the name becomes a directory name under the temp directory, so it may not contain a path separator. | +| `--registry <url>` | Registry base URL. Default `https://packages.unity.com`. | +| `--check` | Print the diff and exit 2 if the file is out of date; write nothing. | +| `--keep-sources` | Leave the unpacked package in the temp directory for inspection. | +| `--strict` | Turn a successful run that warned into exit 3. | + +Exit codes: `0` success, `1` failure, `2` `--check` found the file out of date, `3` `--strict` and an otherwise successful run warned. A run that fails or finds the file out of date keeps its own code even under `--strict`, because 1 and 2 say something more specific than "something warned"; a caller that needs to know about warnings regardless reads the `N warning(s)` line on stderr. + +## Pinning a check to the documented version + +The generator stamps the file it writes with a machine-readable line, emitted by the generator itself rather than by a template so that rewording the templates cannot break reading it back: + +``` +<!-- generated-from: com.unity.pipeline@0.4.0-exp.1 --> +``` + +`--version current` reads that line back. Combined with `--check` it answers "does this generator still produce the committed file?" — a question about the generator, not about the registry: + +```bash +dotnet run --project tools/command-ref-gen/CommandRefGen -- --version current --check +``` + +Use `--version latest --check` instead to answer the other question: "has the package moved on?" That one legitimately fails whenever a new version is published, so the two belong to different triggers: the first to whatever gates a change to this tool, the second to a release watch. Neither runs automatically from this repository — nothing here schedules them. + +## What it does + +1. Reads `<registry>/<package>` and resolves the version to a `dist.tarball` URL. `latest` picks the highest one by semantic version — UPM declares semver, and the ordering is `NuGet.Versioning`'s rather than this tool's own. +2. Downloads and unpacks the tarball into a temp directory. +3. Parses the package's `.cs` files with Roslyn and reads the `[CliCommand]` / `[CliArg]` attributes from the syntax tree — attributes span several lines and defaults may reference constants declared in another file, so regular expressions are not enough. Two kinds of directory are left out: the root `Tests/` assembly, which holds registration fixtures (`log_editor`, `test_types`, `test_structured`) rather than shipped commands, and anything under a directory whose name ends with `~` (`Samples~`, `Documentation~`), which Unity does not compile at all. A directory merely *named* `Tests` inside a compiled assembly is read like any other. Commands are taken from `Editor/Commands/` and `Runtime/Commands/`; the remaining files supply constants and DTO types. A `[CliCommand]` found anywhere else is reported as a warning instead of being dropped. +4. Expands the DTO types a command takes as a single structured argument, so the reference shows the fields of that JSON object instead of an opaque type name. +5. Writes the reference file and prints a summary of what changed: commands added and removed, changed descriptions, and per-argument type/default/description changes. + +## Fidelity to a live editor + +The generated metadata mirrors what a running Pipeline server answers for `unity --json command`, because it reproduces the same rules the package's own `CommandRegistry` applies: + +- an argument's name falls back to the C# parameter name when `[CliArg]` omits it; +- an argument is required when `[CliArg(Required = true)]` says so, or — with no attribute — when the parameter has no C# default; +- the C# parameter default wins; the attribute's `DefaultValue` is only used for parameters that have none; +- the printed type is the framework name (`String`, `Boolean`, `Single[]`), not the C# keyword. + +What the metadata says is the same; how it reads is this tool's own, in two places where the server's raw text tells a reader nothing: + +- a floating-point default is printed in its shortest round-trippable form, so `float.MinValue` reads `-3.4028235e+38` where a listing captured from an editor showed `-3.40282347e+38` — the same number, written with the digit count that editor's JSON serializer happened to use; +- a nullable parameter is printed as its underlying type (`Int32`), where the listing reports ``Nullable`1``. That an argument is optional is already carried by the absence of the required marker; +- a generic type is written out (`List<String>`) rather than as the arity form `` List`1 ``, whose literal backtick would collide with the ones around argument names and break the line. + +Three things the live listing cannot give and this tool can: commands declared `RuntimeOnly = true` (hidden from an editor's listing but executable), commands compiled out on the Unity version that produced a dump — those get an availability note such as *(Unity 6000.7+ only)* derived from the `#if` they sit under — and the fields of a structured argument. + +## Structured arguments + +A command that needs several related values takes a single parameter whose type implements `IStructuredCommandInput`, passed as a JSON object. A live listing prints only the type name for it (`TagsLayersInput`), leaving the fields visible solely inside the per-command JSON schema. The generator expands them as nested bullets: + +```markdown +- `settings` TagsLayersInput — Tag/layer changes to make. + - `addTags` String[] — Tag names to add. + - `setLayers` LayerAssignment[] — User layer assignments (index 8-31). + - `index`\* Int32 — Layer index (8-31 for user layers). +``` + +The members are the ones `JsonSchemaGenerator` reflects over: public instance fields and read/write properties, minus `[JsonIgnore]`, named by `[CliArg]`, then by Newtonsoft's `[JsonProperty]`, then by the member itself. Members carry no default — the schema has no place for one. Arrays and lists are expanded through to their element type; a self-referential type stops where the package's own schema generator stops. + +## Editing the output + +The output file is generated end to end and must not be hand-edited. The two places to change instead: + +- `templates/` — the header comment, the preamble paragraphs and the RuntimeOnly section intro. Placeholders: `{{PACKAGE}}`, `{{VERSION}}`, `{{LISTED_COUNT}}`, `{{RUNTIME_ONLY_COUNT}}`, `{{TOTAL_COUNT}}`, and — for linking to the hidden-command section without hardcoding its title — `{{RUNTIME_ONLY_SECTION}}` and `{{RUNTIME_ONLY_ANCHOR}}`. A placeholder the generator does not fill in is reported rather than left in the file. +- `annotations.json` — per-command field notes appended to the description generated from the sources. Use it only for behaviour the sources do not state; an annotation whose command disappears is reported as a warning. +- `categories.json` — the section a command is documented under, as path-prefix rules (longest match wins, so a file rule overrides the directory rule around it) plus the order the sections appear in. + +A source path matching no rule takes its directory name under the commands root as the section title (`Editor/Commands/VFX/…` → "VFX"), so a directory a new package version adds gets a usable section without waiting for a rule; the run prints a note suggesting a deliberate title. Only a root-level file with no rule is filed under "Other" and reported as a warning, and a rule matching no source in the current package version is reported as stale. + +## Building and testing + +`tools/command-ref-gen/CommandRefGen.sln` ties the tool and its tests together — open that in an IDE, or from the command line: + +```bash +dotnet build tools/command-ref-gen/CommandRefGen.sln +dotnet test tools/command-ref-gen/CommandRefGen.sln +``` + +It is the only .NET code in the repository: the plugin itself is the `skills/` tree, and `tools/` holds what maintains it. + +The tests cover the parts that decide what lands in the file and can be exercised without Unity, a registry, or a network: attribute reading, constant folding, version ordering, the change summary, and the unpacking of an untrusted archive. The parser tests state the rules in cases the real package does not distinguish — where a `[CliArg]` default and a C# default disagree, for instance — so that a change to those rules fails here rather than silently in a future package version. Running the generator against the real package with `--version current --check` is the integration test. + +## Warnings + +Warnings go to stderr. By default they do not change the exit code; `--strict` turns any of them into exit 3, because most of them mean the output may be incomplete and a caller polling the exit code alone would not see that. Among them: a command declared outside the directories commands are read from, a command left in an inactive `#if` branch, a command on a non-static method, a root-level source file no section rule matches, a section rule matching no source in the package version, a type name that resolves to more than one declaration, a duplicate command or argument name, a stale annotation, a template placeholder the generator does not fill in, a version the registry publishes that is not a semantic version, and a description long enough (over 1000 characters) to suggest something went wrong upstream. Descriptions are never truncated. One warning is about the run rather than the output: a temporary directory that could not be removed afterwards. + +A construct the tool cannot evaluate — an attribute argument that is not a compile-time constant it understands — is a hard error with the file and line, not a guess. From a7687e23472aaaca9777a19e8ca12deef77f295c Mon Sep 17 00:00:00 2001 From: Ark Tarusov <ark.tarusov@devark.pro> Date: Mon, 3 Aug 2026 23:11:54 +0200 Subject: [PATCH 08/10] drop the js generator the roslyn tool supersedes --- tools/gen-commands-md.js | 89 ---------------------------------------- 1 file changed, 89 deletions(-) delete mode 100644 tools/gen-commands-md.js diff --git a/tools/gen-commands-md.js b/tools/gen-commands-md.js deleted file mode 100644 index d5e478d..0000000 --- a/tools/gen-commands-md.js +++ /dev/null @@ -1,89 +0,0 @@ -// Generates the categorized part of skills/unity-pipeline/references/editor-commands.md -// from a `unity --json command` dump. Usage: -// unity --json command --project-path <project> > cmds.json -// node tools/gen-commands-md.js cmds.json out.md -// The "RuntimeOnly commands" section is not generated — those commands are absent -// from the dump; their schemas come from the com.unity.pipeline package source -// (Runtime/Commands/*.cs) and are appended manually. Re-attach that section after -// regenerating, and update the version line in the preamble. -const fs = require('fs'); - -const dump = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); -const cmds = dump.data.commands.slice().sort((a, b) => a.name.localeCompare(b.name)); - -const CATEGORIES = [ - ['Editor & play mode', ['editor_play','editor_pause','editor_stop','editor_status','editor_focus','set_autotick','save_all','menu','get_selection','set_selection']], - ['Capture', ['capture_scene_view','capture_game_view','screenshot']], - ['Console & logs', ['get_console_logs','clear_console','console','log']], - ['Scenes', ['create_scene','open_scene','save_scene','set_active_scene','list_open_scenes','get_scene_hierarchy','add_scene_to_build','remove_scene_from_build']], - ['GameObjects & components', ['create_gameobject','create_gameobjects','find_gameobjects','delete_gameobject','rename_gameobject','set_parent','set_active','set_transform','set_tag','set_layer','add_component','remove_component','attach_script','get_component_properties','set_component_properties','get_serialized_fields','set_serialized_field']], - ['Prefabs', ['create_prefab','create_prefab_variant','instantiate_prefab','unpack_prefab','apply_prefab_overrides','revert_prefab_overrides','save_prefab_contents']], - ['Assets & files', ['create_asset','import_asset','delete_asset','rename_asset','move_asset','copy_asset','find_assets','search','create_folder','read_text_file','write_text_file','get_import_settings','set_import_settings','get_authoring_root','set_authoring_root']], - ['Scripts & compilation', ['create_script','recompile','recompile_status','eval','eval_file','reload_file','reload_file_override','hotreload_status','cleanup_hotreload']], - ['Tests', ['run_tests','test_status','list_tests','cancel_tests']], - ['Build', ['build','build_status','list_build_targets','switch_build_target','switch_build_target_status','list_build_profiles','get_build_settings','set_build_settings','get_player_settings','set_player_settings']], - ['Packages (UPM)', ['package_add','package_remove','package_list','package_search','package_resolve','package_status']], - ['Materials & shaders', ['get_material_properties','set_material_properties','list_shaders','get_shader_properties']], - ['Animation & Timeline', ['create_animation_clip','get_animation_clip','set_animation_curve','remove_animation_curve','create_animator_controller','get_animator_controller','add_animator_parameter','add_animator_state','add_animator_transition','add_animator_layer','create_timeline','get_timeline','add_timeline_track','add_timeline_clip']], - ['Lighting bake', ['bake_lighting','lighting_bake_status','cancel_lighting_bake','clear_baked_lighting','get_lighting_settings','set_lighting_settings']], - ['NavMesh bake', ['bake_navmesh','navmesh_bake_status','cancel_navmesh_bake','clear_navmesh','bake_navmesh_surfaces','get_navmesh_settings','set_navmesh_settings']], - ['Occlusion bake', ['bake_occlusion_culling','occlusion_bake_status','cancel_occlusion_bake','clear_occlusion_culling']], - ['Project settings', ['get_quality_settings','set_quality_settings','get_time_settings','set_time_settings','get_physics_settings','set_physics_settings','get_audio_settings','set_audio_settings','get_graphics_settings','set_graphics_settings','get_input_settings','set_input_settings','get_tags_layers','set_tags_layers','get_performance_stats']], -]; - -const catOf = new Map(); -for (const [cat, names] of CATEGORIES) for (const n of names) catOf.set(n, cat); - -const byCat = new Map(CATEGORIES.map(([c]) => [c, []])); -const uncategorized = []; -for (const c of cmds) { - const cat = catOf.get(c.name); - if (cat) byCat.get(cat).push(c); else uncategorized.push(c); -} -if (uncategorized.length) byCat.set('Other', uncategorized); - -function fmtDefault(v) { - if (v === null || v === undefined) return ''; - const s = JSON.stringify(v); - return ` (default ${s})`; -} -function normDesc(s) { - if (!s) return ''; - return s.replace(/\s+/g, ' ').trim(); -} - -let out = []; -out.push('# Editor command reference'); -out.push(''); -out.push('Generated from `unity --json command` against Unity Pipeline package `0.4.0-exp.1` on Unity `6000.4`. The live list is dynamic (package version, project code, optional packages, project-defined `[CliCommand]` methods) — on a name/argument mismatch, re-check against the live output of `unity --json command`; this file covers the common core.'); -out.push(''); -out.push('**The listing is not exhaustive**: some registered built-in commands (the play-mode input group: `simulate_pointer`, `simulate_key`, `runtime_status`, `set_timescale`, `set_target_framerate`, `quit`) do not appear in the 140-command listing but execute fine when called. Absence from the listing is not proof a command does not exist — a genuinely unknown name fails with exit code 6.'); -out.push(''); -out.push('Argument conventions: pass every argument as `--name value` (snake_case). `*` marks a required argument. Destructive commands need `--confirm true`; most mutating commands accept `--dry_run true`; async commands pair with a `*_status` poll command.'); -out.push(''); - -for (const [cat, list] of byCat) { - if (!list.length) continue; - out.push(`## ${cat}`); - out.push(''); - for (const c of list) { - const flags = []; - if (c.mainThreadRequired === false) flags.push('works while main thread is busy'); - const desc = normDesc(c.description); - out.push(`### ${c.name}`); - out.push(desc + (flags.length ? ` *(${flags.join(', ')})*` : '')); - if (c.parameters && c.parameters.length) { - for (const p of c.parameters) { - const req = p.required ? '\\*' : ''; - const d = normDesc(p.description); - out.push(`- \`${p.name}\`${req} ${p.type}${fmtDefault(p.defaultValue)}${d ? ' — ' + d : ''}`); - } - } else { - out.push('- *(no arguments)*'); - } - out.push(''); - } -} - -fs.writeFileSync(process.argv[3], out.join('\n')); -console.log('written', process.argv[3], out.length, 'lines'); From e73b1cbe49781033499fb7a2eea711ffbef3383b Mon Sep 17 00:00:00 2001 From: Ark Tarusov <ark.tarusov@devark.pro> Date: Tue, 4 Aug 2026 20:45:29 +0200 Subject: [PATCH 09/10] regenerate the editor command reference from the com.unity.pipeline sources --- .../references/editor-commands.md | 255 +++++++++++------- 1 file changed, 151 insertions(+), 104 deletions(-) diff --git a/skills/unity-pipeline/references/editor-commands.md b/skills/unity-pipeline/references/editor-commands.md index b1036f4..28a3bd5 100644 --- a/skills/unity-pipeline/references/editor-commands.md +++ b/skills/unity-pipeline/references/editor-commands.md @@ -1,25 +1,26 @@ <!-- -The command entries below are GENERATED — do not hand-edit them. Regenerate with: - unity --json command --project-path <project> > cmds.json - node tools/gen-commands-md.js cmds.json out.md -Hand-maintained parts the generator does NOT produce (its own emitted preamble is -older than the one in this file): this comment, the preamble paragraphs, the -Contents section, and the "RuntimeOnly commands" section at the bottom. -Regeneration replaces the whole file — re-apply the hand-maintained parts after, -and update the version numbers in the preamble. +GENERATED FILE — do not hand-edit any part of it, including this header and the prose below. +The whole file is produced from the com.unity.pipeline package sources by tools/command-ref-gen/CommandRefGen: + + dotnet run --project tools/command-ref-gen/CommandRefGen -- --version latest + +Change the wording in tools/command-ref-gen/CommandRefGen/templates/, add per-command field notes to +tools/command-ref-gen/CommandRefGen/annotations.json, then regenerate. No Unity installation is needed. --> +<!-- generated-from: com.unity.pipeline@0.4.0-exp.1 --> + # Editor command reference -Generated from `unity --json command` against Unity Pipeline package `0.4.0-exp.1` on Unity `6000.4`. The live list is dynamic (package version, project code, optional packages, project-defined `[CliCommand]` methods) — on a name/argument mismatch, re-check against the live output of `unity --json command`; this file covers the common core. +Every `[CliCommand]` declared by the `com.unity.pipeline` package at version `0.4.0-exp.1` — 151 commands, 141 of them advertised by an editor server and 10 hidden from its listing. The commands a given editor actually answers to are dynamic (package version, optional packages, project-defined `[CliCommand]` methods); commands defined by a project rather than by the package are outside this file, so on a name or argument mismatch re-check against the live output of `unity --json command`. -**The listing is not exhaustive — this file is more complete than `unity command`.** Commands whose `[CliCommand]` attribute sets `RuntimeOnly = true` are filtered out of the editor listing — they are designed for Unity **Player** connections (`unity command --runtime <player>`) — yet the editor server still executes them when called by name, in edit mode and play mode alike. The CLI never reveals their schemas; the [RuntimeOnly commands](#runtimeonly-commands-hidden-from-the-listing) section below, extracted from the package source, is the only schema source for them. Absence from the listing is not proof a command does not exist — a genuinely unknown name fails with exit code 6. +**This file is more complete than `unity command`.** Commands whose `[CliCommand]` attribute sets `RuntimeOnly = true` are filtered out of the editor listing — they are designed for Unity **Player** connections (`unity command --runtime <player>`) — yet the editor server still executes them when called by name, in edit mode and play mode alike. The CLI never reveals their schemas; the [RuntimeOnly commands (hidden from the listing)](#runtimeonly-commands-hidden-from-the-listing) section below is the only schema source for them. Absence from the listing is not proof a command does not exist — a genuinely unknown name fails with exit code 6. -Argument conventions: pass every argument as `--name value` (snake_case). `*` marks a required argument. Destructive commands need `--confirm true`; most mutating commands accept `--dry_run true`; async commands pair with a `*_status` poll command. +Argument conventions: pass every argument as `--name value`, spelled exactly as this file lists it — most are snake_case, but a good few are camelCase (`assemblyDir`, `frameRate`, `exitCode`), and the server matches the name verbatim. `*` marks an argument the server refuses to run without — it can still carry a default, which is what the server would have used had the argument been optional. Destructive commands need `--confirm true`; most mutating commands accept `--dry_run true`; async commands pair with a `*_status` poll command. A note such as *(Unity 6000.7+ only)* means the command is compiled out on older editors and fails there with exit code 6. ## Contents -[Editor & play mode](#editor--play-mode) · [Capture](#capture) · [Console & logs](#console--logs) · [Scenes](#scenes) · [GameObjects & components](#gameobjects--components) · [Prefabs](#prefabs) · [Assets & files](#assets--files) · [Scripts & compilation](#scripts--compilation) · [Tests](#tests) · [Build](#build) · [Packages (UPM)](#packages-upm) · [Materials & shaders](#materials--shaders) · [Animation & Timeline](#animation--timeline) · [Lighting bake](#lighting-bake) · [NavMesh bake](#navmesh-bake) · [Occlusion bake](#occlusion-bake) · [Project settings](#project-settings) · [RuntimeOnly commands](#runtimeonly-commands-hidden-from-the-listing) +[Editor & play mode](#editor--play-mode) · [Capture](#capture) · [Console, logs & performance](#console-logs--performance) · [Search & selection](#search--selection) · [Scenes](#scenes) · [GameObjects & components](#gameobjects--components) · [Prefabs](#prefabs) · [Assets & files](#assets--files) · [Authoring](#authoring) · [Scripts & compilation](#scripts--compilation) · [Tests](#tests) · [Build](#build) · [Packages (UPM)](#packages-upm) · [Materials & shaders](#materials--shaders) · [Animation & Timeline](#animation--timeline) · [Lighting](#lighting) · [NavMesh](#navmesh) · [Occlusion culling](#occlusion-culling) · [Project settings](#project-settings) · [RuntimeOnly commands (hidden from the listing)](#runtimeonly-commands-hidden-from-the-listing) ## Editor & play mode @@ -43,30 +44,23 @@ Get detailed Unity Editor status and state information Exit Unity Editor play mode - *(no arguments)* -### get_selection -Read the current Editor selection as structured object identities. -- *(no arguments)* - ### menu Execute an Editor menu item by path, or list available items when no path is given - `path` String (default "") — Menu item path to execute, e.g. "Assets/Reimport All". Omit to list available menu items. -### save_all -Save all open scenes that have unsaved changes. -- *(no arguments)* - ### set_autotick Keep the editor ticking while unfocused by forcing EditorApplication.SignalTick at a throttled rate - `enable` Boolean (default true) — Enable (true) or disable (false) auto-tick mode - `interval_ms` Int32 (default 16) — Minimum milliseconds between forced ticks. 0 = every update (max rate, pegs a CPU core). Default 16 (~60Hz). -### set_selection -Set the Editor selection to the given assets/scene objects. -- `instance_ids` ObjectId[] — Scene/loaded object instance IDs to select. -- `paths` String[] — Asset paths to select (e.g. Assets/Foo.prefab). - ## Capture +### capture_editor_element +Capture a UI Toolkit VisualElement (by selector) from an EditorWindow to a PNG; returns path + base64. *(Unity 6000.7+ only)* +- `window`\* String (default "") — EditorWindow type name (e.g. InspectorWindow) or window title to capture from. +- `selector`\* String (default "") — Element selector: '#name', '.class', a type name (e.g. Button), descendant (space) / child ('>') chains, optional pseudo-states (:checked,:hover,:focus,:active,:enabled,:disabled,:not(...)). +- `output` String (default "") — Output PNG path (absolute, or relative to the project root). Defaults to a timestamped file under `<project>`/Temp/pipeline-screenshots/. + ### capture_game_view Render a camera to a PNG. Returns it inline as base64, unless save_path is set (path-only result; pass include_inline_image=true to get both). - `width` Int32 (default 1280) — Output width in px (default 1280; capped 4096). @@ -87,11 +81,11 @@ Render the active Scene View to a PNG. Returns it inline as base64, unless save_ ### screenshot Capture the Scene or Game view as a PNG and return its file path - `view` String (default "game") — Which view to capture: 'game' (default) or 'scene' -- `output` String (default "") — Output PNG path (absolute, or relative to the project root). Defaults to a timestamped file under <project>/Temp/pipeline-screenshots/. +- `output` String (default "") — Output PNG path (absolute, or relative to the project root). Defaults to a timestamped file under `<project>`/Temp/pipeline-screenshots/. - `width` Int32 (default 0) — Output width in pixels. 0 (default) uses the view camera's current width. - `height` Int32 (default 0) — Output height in pixels. 0 (default) uses the view camera's current height. -## Console & logs +## Console, logs & performance ### clear_console Clear the captured log buffer and the Unity Editor console. @@ -108,6 +102,26 @@ Read recently captured Editor console logs (structured). - `severity` String (default "all") — Filter: all | log | warning | error. 'all' = every entry; 'log' = Log only; 'warning' = Warning only; 'error' = Error/Exception/Assert only. - `limit` Int32 (default 100) — Max entries to return (most-recent first), capped at 1000. +### get_performance_stats +Read render, memory, and frame-timing stats (structured, read-only). +- *(no arguments)* + +## Search & selection + +### get_selection +Read the current Editor selection as structured object identities. +- *(no arguments)* + +### search +Run a Unity Search query and return structured results. +- `query`\* String — Unity Search query string, e.g. 't:Material', 'p: my asset', 'h: Main Camera'. +- `limit` Int32 (default 50) — Max results to return (capped 200). + +### set_selection +Set the Editor selection to the given assets/scene objects. +- `instance_ids` ObjectId[] — Scene/loaded object instance IDs to select. +- `paths` String[] — Asset paths to select (e.g. Assets/Foo.prefab). + ## Scenes ### add_scene_to_build @@ -138,6 +152,10 @@ Open an existing scene from the given path. Remove a scene from the Build Settings scene list (idempotent). - `path`\* String — Scene path to remove (authoring-root relative; Assets/ prefix and .unity optional). +### save_all +Save all open scenes that have unsaved changes. +- *(no arguments)* + ### save_scene Save an open scene. Saves the active scene when no path is given. - `path` String — Path of the open scene to save (authoring-root relative; Assets/ prefix and .unity optional). Omit to save the active scene. @@ -153,12 +171,6 @@ Add a component (by type name) to a GameObject. - `target`\* ObjectRef — Handle of the GameObject. - `type`\* String — Component type name (e.g. 'Rigidbody' or 'UnityEngine.Camera'). -### attach_script -Add a MonoBehaviour to a GameObject by its (compiled) type name OR by its script asset path. Provide exactly one of 'type' or 'script'. If the type isn't compiled yet, returns a recoverable error: recompile, poll recompile_status, then retry. -- `target`\* ObjectRef — Reference to the GameObject to add the component to (globalId/path/guid/instanceId/hierarchyPath). -- `type` String — Component type name to add, e.g. PlayerController or Game.Player.PlayerController. Must already be compiled. Mutually exclusive with 'script'. -- `script` String — Script asset path, e.g. 'Assets/Pool/Scripts/CueShooter.cs'. The backing class is resolved via MonoScript.GetClass(), so the class name may differ from the filename. Mutually exclusive with 'type'. - ### create_gameobject Create an empty GameObject or a built-in primitive (cube/sphere/capsule/cylinder/plane/quad) in the active scene. - `name` String — Name for the new GameObject. Defaults to 'GameObject' (or the primitive name). @@ -323,10 +335,6 @@ Find assets by type and/or name and/or label, returning their path, GUID and typ - `search_in` String — Folder to scope the search to, relative to the authoring root (default: the authoring root). - `limit` Int32 (default 200) — Maximum number of results to return (default 200). -### get_authoring_root -Get the base folder (under Assets/) that bare authoring paths resolve against. -- *(no arguments)* - ### get_import_settings Read an asset's import settings, structured by importer type (texture/model/audio), including the default-platform fields and (for textures/audio) one platform override block. - `asset`\* ObjectRef — Reference to the asset whose importer to read (path / guid / globalId). @@ -356,15 +364,6 @@ Rename an asset in place (keeps it in the same folder, keeps its GUID). - `new_name`\* String — New file name WITHOUT a folder path. The extension is preserved if omitted. - `dry_run` Boolean (default false) — If true, validate the rename without performing it. -### search -Run a Unity Search query and return structured results. -- `query`\* String — Unity Search query string, e.g. 't:Material', 'p: my asset', 'h: Main Camera'. -- `limit` Int32 (default 50) — Max results to return (capped 200). - -### set_authoring_root -Set the base folder (under Assets/) that bare authoring paths resolve against and are confined to. Use 'Assets' for full project access. -- `root`\* String — Project-relative folder under Assets/, e.g. Assets/AgentWork. Use 'Assets' to allow the whole project. - ### set_import_settings Set import settings on an asset's AssetImporter (default platform top-level properties, or a texture/audio per-platform override) and re-import it. - `asset`\* ObjectRef — Reference to the asset whose importer to edit (path / guid / globalId). @@ -379,8 +378,24 @@ Write UTF-8 text to a file under the authoring root, then import it. Overwriting - `confirm` Boolean (default false) — Required (true) only when overwriting an existing file at the path. - `dry_run` Boolean (default false) — If true, validate inputs and report what would be written without writing anything. +## Authoring + +### get_authoring_root +Get the base folder (under Assets/) that bare authoring paths resolve against. +- *(no arguments)* + +### set_authoring_root +Set the base folder (under Assets/) that bare authoring paths resolve against and are confined to. Use 'Assets' for full project access. +- `root`\* String — Project-relative folder under Assets/, e.g. Assets/AgentWork. Use 'Assets' to allow the whole project. + ## Scripts & compilation +### attach_script +Add a MonoBehaviour to a GameObject by its (compiled) type name OR by its script asset path. Provide exactly one of 'type' or 'script'. If the type isn't compiled yet, returns a recoverable error: recompile, poll recompile_status, then retry. +- `target`\* ObjectRef — Reference to the GameObject to add the component to (globalId/path/guid/instanceId/hierarchyPath). +- `type` String — Component type name to add, e.g. PlayerController or Game.Player.PlayerController. Must already be compiled. Mutually exclusive with 'script'. +- `script` String — Script asset path, e.g. 'Assets/Pool/Scripts/CueShooter.cs'. The backing class is resolved via MonoScript.GetClass(), so the class name may differ from the filename. Mutually exclusive with 'type'. + ### create_script Create a new C# script (default base class MonoBehaviour) from a template under the authoring root. NOTE: the type does not exist until a recompile completes — to attach it, call recompile, poll recompile_status, then attach_script. - `name`\* String — Class/file name without extension, e.g. PlayerController. Must be a valid C# identifier. @@ -463,10 +478,6 @@ Status of the current/most recent build: idle | queued | building | completed, w Read the current build configuration from EditorUserBuildSettings / EditorBuildSettings. - *(no arguments)* -### get_player_settings -Read PlayerSettings (company/product/version, scripting backend, API level). -- *(no arguments)* - ### list_build_profiles List Build Profile assets in the project (Unity 6 only). Returns feature_unavailable on earlier versions. - *(no arguments)* @@ -478,15 +489,15 @@ List the known BuildTarget values with their group and whether build support is ### set_build_settings Set mutable EditorUserBuildSettings fields. Does NOT manage scenes (use add_scene_to_build / remove_scene_from_build) or switch target (use switch_build_target). Use dry_run to preview. - `settings` SetBuildSettingsInput — Fields to change; omitted fields are left unchanged. + - `developmentBuild` Boolean — Build a Development Player (enables the debugger/profiler). + - `allowDebugging` Boolean — Allow script debugging (only effective with developmentBuild=true). + - `connectWithProfiler` Boolean — Auto-connect the Profiler (only effective with developmentBuild=true). + - `buildScriptsOnly` Boolean — Build only the scripts (skip data) for faster iteration. + - `symlinkSources` Boolean — Symlink runtime/plugin sources instead of copying (where supported). + - `il2CppCodeGeneration` String — IL2CPP code generation for the active target: OptimizeSpeed | OptimizeSize. - `confirm` Boolean (default false) — Apply the changes. Without it the call is refused. - `dry_run` Boolean (default false) — Preview the change without applying it. -### set_player_settings -Change PlayerSettings. Requires confirm=true; use dry_run to preview. Not undoable via Ctrl+Z. Scripting backend / API level changes trigger a domain reload. -- `settings` PlayerSettingsInput — Fields to change; omitted fields are left unchanged. -- `confirm` Boolean (default false) — Apply the change. Without it the call is refused. -- `dry_run` Boolean (default false) — Preview the change without applying it. - ### switch_build_target Switch the active build target (destructive, long-running: triggers a full reimport + domain reload). Requires confirm=true. Returns immediately; poll switch_build_target_status. *(works while main thread is busy)* - `target`\* String (default "") — BuildTarget name to switch to (must be installed; see list_build_targets). @@ -553,7 +564,7 @@ Set shader properties on a material (Float/Range/Int=number; Color=[r,g,b,a] or - `material`\* ObjectRef — Reference to the .mat asset (or a loaded material) to edit (path / guid / globalId / instanceId). - `shader` String — Reassign the material's shader by name (e.g. "Standard", "Universal Render Pipeline/Lit", or a Shader Graph shader name). Applied before properties so new property names resolve against the new shader. - `properties` JObject — JSON object of shader property name -> value. Names must include the leading underscore (e.g. _BaseColor). Float/Range/Int=number; Color=[r,g,b,a] or hex string; Vector=[x,y,z,w]; Texture=an object reference {guid/path} or null. -- `renderQueue` Nullable`1 — Explicit render queue, or -1 to inherit from the shader. Omit to leave unchanged. +- `renderQueue` Int32 — Explicit render queue, or -1 to inherit from the shader. Omit to leave unchanged. - `enableKeywords` String[] — Shader keywords to enable (e.g. _NORMALMAP, _EMISSION). - `disableKeywords` String[] — Shader keywords to disable. - `confirm` Boolean (default false) — Reserved for parity; editing an existing material is non-destructive and undoable, so it is not required. @@ -669,7 +680,7 @@ Add or replace a single float curve binding on an AnimationClip (via AnimationUt - `keys`\* JArray — Keyframes: [{ time, value, inTangent?, outTangent?, weightedMode?: "None"|"In"|"Out"|"Both" }]. Omitted tangents default to 0 (flat); this is NOT Unity's Auto tangent mode. - `dry_run` Boolean (default false) — If true, validate type/property/keys without writing the curve. -## Lighting bake +## Lighting ### bake_lighting Trigger an async lightmap bake of the open scene(s) via Lightmapping.BakeAsync(). Returns immediately; poll lighting_bake_status until completed. @@ -699,7 +710,7 @@ Apply a subset of lighting settings to the active LightingSettings. Returns { ap - `settings`\* JObject — JSON object with a subset of lighting fields to set (same names/enums as get_lighting_settings). - `dry_run` Boolean (default false) — If true, validate the keys and report applied/unknown without changing anything. -## NavMesh bake +## NavMesh ### bake_navmesh Trigger an async legacy NavMesh bake of the open scene(s) via UnityEditor.AI.NavMeshBuilder. Returns immediately; poll navmesh_bake_status until completed. @@ -732,13 +743,13 @@ Apply a subset of legacy NavMesh bake settings to the default agent. Returns { a - `settings`\* JObject — JSON object with a subset of NavMesh fields to set (same names as get_navmesh_settings). - `dry_run` Boolean (default false) — If true, validate the keys and report applied/unknown without changing anything. -## Occlusion bake +## Occlusion culling ### bake_occlusion_culling Trigger an async occlusion-culling bake of the open scene(s) via StaticOcclusionCulling.GenerateInBackground(). Returns immediately; poll occlusion_bake_status until completed. -- `smallest_occluder` Single (default -3.40282347e+38) — Smallest object that will occlude others (meters). Defaults to Unity's current value. -- `smallest_hole` Single (default -3.40282347e+38) — Smallest gap geometry can have that the view can see through (meters). Defaults to Unity's current value. -- `backface_threshold` Single (default -3.40282347e+38) — Backface threshold (1-100); lower trims more backfaces. Defaults to Unity's current value. +- `smallest_occluder` Single (default -3.4028235e+38) — Smallest object that will occlude others (meters). Defaults to Unity's current value. +- `smallest_hole` Single (default -3.4028235e+38) — Smallest gap geometry can have that the view can see through (meters). Defaults to Unity's current value. +- `backface_threshold` Single (default -3.4028235e+38) — Backface threshold (1-100); lower trims more backfaces. Defaults to Unity's current value. - `confirm` Boolean (default false) — Accepted for parity (a bake overwrites existing occlusion data); not required. - `dry_run` Boolean (default false) — If true, validate there is an open scene and report the parameters that would be used without baking. @@ -769,14 +780,14 @@ Read GraphicsSettings (default render pipeline). Read the legacy Input Manager axes (names and count). - *(no arguments)* -### get_performance_stats -Read render, memory, and frame-timing stats (structured, read-only). -- *(no arguments)* - ### get_physics_settings Read Physics settings (gravity, solver iterations, bounce threshold). - *(no arguments)* +### get_player_settings +Read PlayerSettings (company/product/version, scripting backend, API level). +- *(no arguments)* + ### get_quality_settings Read QualitySettings (current level, level names, vSync, anti-aliasing). - *(no arguments)* @@ -792,92 +803,128 @@ Read Time settings (fixedDeltaTime, maximumDeltaTime, timeScale). ### set_audio_settings Change project Audio settings. Requires confirm=true; use dry_run to preview. Not undoable via Ctrl+Z. - `settings` AudioSettingsInput — Fields to change; omitted fields are left unchanged. + - `volume` Single — Global audio volume (0..1). + - `rolloffScale` Single — Global rolloff scale. + - `dopplerFactor` Single — Global doppler factor. - `confirm` Boolean (default false) — Apply the change. Without it the call is refused. - `dry_run` Boolean (default false) — Preview the change without applying it. ### set_graphics_settings Set the default render pipeline asset. Requires confirm=true; use dry_run to preview. Not undoable via Ctrl+Z. - `settings` GraphicsSettingsInput — Fields to change; omitted fields are left unchanged. + - `renderPipelineAsset` ObjectRef — Reference (path / guid / globalId) to a RenderPipelineAsset to set as the default. Pass an empty reference ({}) to select the built-in pipeline; omit to leave unchanged. - `confirm` Boolean (default false) — Apply the change. Without it the call is refused. - `dry_run` Boolean (default false) — Preview the change without applying it. ### set_input_settings Tune a legacy Input Manager axis (sensitivity/gravity/dead) by name. Requires confirm=true; use dry_run to preview. Not undoable via Ctrl+Z. - `settings` InputAxisInput — Axis change. 'axis' selects the axis by name; omitted numeric fields are left unchanged. + - `axis`\* String — Name of the axis to modify (e.g. 'Horizontal'). + - `sensitivity` Single — New sensitivity. + - `gravity` Single — New gravity. + - `dead` Single — New dead-zone size. - `confirm` Boolean (default false) — Apply the change. Without it the call is refused. - `dry_run` Boolean (default false) — Preview the change without applying it. ### set_physics_settings Change Physics settings. Requires confirm=true; use dry_run to preview. Not undoable via Ctrl+Z. - `settings` PhysicsSettingsInput — Fields to change; omitted fields are left unchanged. + - `gravityX` Single — Gravity X component. + - `gravityY` Single — Gravity Y component (e.g. -9.81). + - `gravityZ` Single — Gravity Z component. + - `defaultSolverIterations` Int32 — Default solver iteration count. + - `bounceThreshold` Single — Bounce threshold velocity. +- `confirm` Boolean (default false) — Apply the change. Without it the call is refused. +- `dry_run` Boolean (default false) — Preview the change without applying it. + +### set_player_settings +Change PlayerSettings. Requires confirm=true; use dry_run to preview. Not undoable via Ctrl+Z. Scripting backend / API level changes trigger a domain reload. +- `settings` PlayerSettingsInput — Fields to change; omitted fields are left unchanged. + - `companyName` String — Company name. + - `productName` String — Product name. + - `bundleVersion` String — Bundle/application version string. + - `scriptingBackend` ScriptingImplementation — Scripting backend (e.g. Mono2x, IL2CPP). Triggers a domain reload. + - `apiCompatibilityLevel` ApiCompatibilityLevel — API compatibility level (e.g. NET_Standard_2_0, NET_Unity_4_8). Triggers a domain reload. - `confirm` Boolean (default false) — Apply the change. Without it the call is refused. - `dry_run` Boolean (default false) — Preview the change without applying it. ### set_quality_settings Change QualitySettings. Requires confirm=true; use dry_run to preview. Not undoable via Ctrl+Z. - `settings` QualitySettingsInput — Fields to change; omitted fields are left unchanged. + - `level` Int32 — Quality level index (see levelNames from get_quality_settings). + - `vSyncCount` Int32 — VSync count (0 = off, 1, 2). + - `antiAliasing` Int32 — MSAA sample count (0, 2, 4, 8). - `confirm` Boolean (default false) — Apply the change. Without it the call is refused. - `dry_run` Boolean (default false) — Preview the change without applying it. ### set_tags_layers Add/remove tags and assign user layer names (index 8-31). Requires confirm=true; use dry_run to preview. Not undoable via Ctrl+Z. - `settings` TagsLayersInput — Tag/layer changes to make. + - `addTags` String[] — Tag names to add. + - `removeTags` String[] — Tag names to remove. + - `setLayers` LayerAssignment[] — User layer assignments (index 8-31). + - `index`\* Int32 — Layer index (8-31 for user layers). + - `name`\* String — Layer name (empty string clears the slot). - `confirm` Boolean (default false) — Apply the change. Without it the call is refused. - `dry_run` Boolean (default false) — Preview the change without applying it. ### set_time_settings Change Time settings. Requires confirm=true; use dry_run to preview. Not undoable via Ctrl+Z. - `settings` TimeSettingsInput — Fields to change; omitted fields are left unchanged. + - `fixedDeltaTime` Single — Fixed timestep in seconds (e.g. 0.02). + - `maximumDeltaTime` Single — Maximum allowed timestep in seconds. + - `timeScale` Single — Time scale (1 = real-time). - `confirm` Boolean (default false) — Apply the change. Without it the call is refused. - `dry_run` Boolean (default false) — Preview the change without applying it. -## RuntimeOnly commands (hidden from the listing) - -Extracted from the `com.unity.pipeline` `0.4.0-exp.1` package source (`Runtime/Commands/`) — `unity command` does not list them and cannot show their schemas. They execute against the editor server despite the `RuntimeOnly` flag (exception: `capture_runtime_element`, see its entry); they also work against Player connections (`--runtime <process>` / `--runtime-path <port file>`). All require the main thread. -### simulate_pointer -Simulate a mouse/pointer event at screen coordinates (Input System). **Feeds a virtual device, not the OS cursor — UI raycasts work, but game code polling `Mouse.current` sees the virtual mouse; verify the click landed via its response, not via assumption.** -- `x`\* Single — Screen X in pixels (origin bottom-left) -- `y`\* Single — Screen Y in pixels (origin bottom-left) -- `action` String (default "click") — move | down | up | click (down+up) -- `button` String (default "left") — left | right | middle - -### simulate_key -Simulate a keyboard key event (Input System). Drives the running app. -- `key`\* String — Input System Key name, e.g. Space, W, Enter, LeftArrow -- `action` String (default "press") — down | up | press (down+up) +## RuntimeOnly commands (hidden from the listing) -### runtime_status -Get comprehensive runtime application status. -- *(no arguments)* +Declared with `RuntimeOnly = true` in the `com.unity.pipeline` `0.4.0-exp.1` sources — `unity command` neither lists them nor shows their schemas. They execute against the editor server despite the flag, and they work against Player connections (`--runtime <process>` / `--runtime-path <port file>`). A version note on an entry still applies: a command compiled out on the running editor does not exist there and fails with exit code 6. ### capture_runtime_element -Capture a UI Toolkit VisualElement (by selector) from a live runtime panel (UIDocument or PanelRenderer) to a PNG; returns path + base64. **Unity 6000.7+ only** — the command is compiled out (`#if UNITY_6000_7_OR_NEWER`) on older versions and does not exist there: calling it fails with exit code 6. -- `panel` String (default "") — Target panel name: PanelSettings asset name or host GameObject name. Optional when exactly one panel exists. -- `selector`\* String — Element selector: '#name', '.class', a type name (e.g. Button), descendant (space) / child ('>') chains, optional pseudo-states (:checked, :hover, :focus, :active, :enabled, :disabled, :not(...)). +Capture a UI Toolkit VisualElement (by selector) from a live runtime panel (UIDocument or PanelRenderer) to a PNG; returns path + base64. *(Unity 6000.7+ only)* +- `panel` String (default "") — Name of the target panel: matches the PanelSettings asset name or the host GameObject name (UIDocument or PanelRenderer). Optional when exactly one panel exists. +- `selector`\* String (default "") — Element selector: '#name', '.class', a type name (e.g. Button), descendant (space) / child ('>') chains, optional pseudo-states (:checked,:hover,:focus,:active,:enabled,:disabled,:not(...)). - `output` String (default "") — Output PNG path (absolute, or relative to Application.persistentDataPath). Defaults to a timestamped file under Application.persistentDataPath. -### set_timescale -Set the time scale for the application. -- `scale`\* Single — Time scale multiplier (0.0 to pause, 1.0 for normal speed) - -### set_target_framerate -Set the target frame rate for the application. -- `frameRate`\* Int32 — Target frame rate (-1 for platform default, 0 for unlimited) +### cleanup_hotreload +Remove old hot reload DLL versions and clear registry +- `assemblyDir`\* String — Directory containing assemblies to cleanup +- `force_domain_reload` Boolean (default true) — Force Unity domain reload after cleanup -### quit -Gracefully quit the Unity application. Against the editor this is the play-mode/app quit path — for shutting down the editor itself, prefer `eval EditorApplication.Exit(0)` (see [lifecycle-recovery.md](lifecycle-recovery.md)). -- `exitCode` Int32 (default 0) — Exit code for the application +### hotreload_status +Show current hot reload registry status and statistics +- *(no arguments)* ### log -Write a message to Unity console. +Write a message to Unity console - `message`\* String — Message to log to console - `level` String (default "info") — Log level: info, warning, error -### hotreload_status -Show current hot reload registry status and statistics. +### quit +Gracefully quit the Unity application. Against the editor this is the play-mode/app quit path — to shut the editor itself down, prefer `eval EditorApplication.Exit(0)` (see [lifecycle-recovery.md](lifecycle-recovery.md)). +- `exitCode` Int32 (default 0) — Exit code for the application + +### runtime_status +Get comprehensive runtime application status - *(no arguments)* -### cleanup_hotreload -Remove old hot reload DLL versions and clear registry. -- `assemblyDir`\* String — Directory containing assemblies to cleanup -- `force_domain_reload` Boolean (default true) — Force Unity domain reload after cleanup +### set_target_framerate +Set the target frame rate for the application +- `frameRate`\* Int32 — Target frame rate (-1 for platform default, 0 for unlimited) + +### set_timescale +Set the time scale for the application +- `scale`\* Single — Time scale multiplier (0.0 to pause, 1.0 for normal speed) + +### simulate_key +Simulate a keyboard key event (Input System). Drives the running app. +- `key`\* String — Input System Key name, e.g. Space, W, Enter, LeftArrow +- `action` String (default "press") — down | up | press (down+up). Default: press + +### simulate_pointer +Simulate a mouse/pointer event at screen coordinates (Input System). **Feeds a virtual device, not the OS cursor — UI raycasts work, but game code polling `Mouse.current` sees the virtual mouse; verify the click landed via its response, not via assumption.** +- `x`\* Single — Screen X in pixels (origin bottom-left) +- `y`\* Single — Screen Y in pixels (origin bottom-left) +- `action` String (default "click") — move | down | up | click (down+up). Default: click +- `button` String (default "left") — left | right | middle. Default: left From 0b86ae8253fd8a2ab0edb334ee34d4d61a65b710 Mon Sep 17 00:00:00 2001 From: Ark Tarusov <ark.tarusov@devark.pro> Date: Wed, 5 Aug 2026 14:30:26 +0200 Subject: [PATCH 10/10] leave symbols a file only negates undefined so their guarded commands stay parsed --- .../CommandRefGen.Tests/CommandParserTests.cs | 54 ++++++++++++++++- .../CommandRefGen/CommandParser.cs | 59 +++++++++++++++---- 2 files changed, 100 insertions(+), 13 deletions(-) diff --git a/tools/command-ref-gen/CommandRefGen.Tests/CommandParserTests.cs b/tools/command-ref-gen/CommandRefGen.Tests/CommandParserTests.cs index 228a321..6f9a213 100644 --- a/tools/command-ref-gen/CommandRefGen.Tests/CommandParserTests.cs +++ b/tools/command-ref-gen/CommandRefGen.Tests/CommandParserTests.cs @@ -175,11 +175,61 @@ public static void Capture() { } Assert.Equal(new[] { "UNITY_6000_7_OR_NEWER" }, command.Gates); } + [Fact] + public void Keeps_a_command_guarded_by_a_negated_condition() + { + // A symbol a file only ever negates (#if !UNITY_SERVER, #if !UNITY_6000_7_OR_NEWER) must stay + // undefined, or the guarded command would be parsed out of the reference. + var command = Assert.Single(Parse(""" + #if !UNITY_SERVER + [CliCommand("interactive", "Needs a display")] + public static void Interactive() { } + #endif + """)); + + Assert.Equal("interactive", command.Name); + Assert.Equal(new[] { "!UNITY_SERVER" }, command.Gates); + Assert.Empty(warnings); + } + + [Fact] + public void Keeps_a_command_under_a_mixed_condition_with_a_negated_term() + { + var command = Assert.Single(Parse(""" + #if UNITY_EDITOR && !UNITY_SERVER + [CliCommand("editor_only", "Editor without server")] + public static void EditorOnly() { } + #endif + """)); + + Assert.Equal("editor_only", command.Name); + Assert.Empty(warnings); + } + + [Fact] + public void Reports_a_command_under_a_symbol_the_file_tests_both_ways() + { + // One parse cannot satisfy #if X and #if !X at once; the losing branch must not vanish quietly. + var commands = Parse(""" + #if UNITY_6000_7_OR_NEWER + [CliCommand("modern", "New way")] + public static void Modern() { } + #endif + #if !UNITY_6000_7_OR_NEWER + [CliCommand("legacy", "Old way")] + public static void Legacy() { } + #endif + """); + + Assert.Equal("modern", Assert.Single(commands).Name); + Assert.Contains(warnings, w => w.Contains("inactive conditional branch")); + } + [Fact] public void Reports_a_command_left_in_an_inactive_branch() { - // Every symbol a file mentions is defined, so the #else body never reaches the tree; dropping it - // without a word would take a command out of the reference invisibly. + // One parse cannot activate both arms of an #if/#else, so the #else body never reaches the tree; + // dropping it without a word would take a command out of the reference invisibly. var commands = Parse(""" #if UNITY_6000_7_OR_NEWER [CliCommand("modern", "New way")] diff --git a/tools/command-ref-gen/CommandRefGen/CommandParser.cs b/tools/command-ref-gen/CommandRefGen/CommandParser.cs index be457e0..ae8b663 100644 --- a/tools/command-ref-gen/CommandRefGen/CommandParser.cs +++ b/tools/command-ref-gen/CommandRefGen/CommandParser.cs @@ -144,15 +144,21 @@ private SyntaxTree ParseTree(string path, string relativePath) { var text = File.ReadAllText(path); - // Parse once with nothing defined only to learn which preprocessor symbols the file talks about, - // then re-parse with all of them defined so that version-gated commands are present in the tree. + // Parse once with nothing defined only to learn which preprocessor symbols the file talks about + // and with which polarity, then re-parse with the right ones defined so that guarded commands are + // present in the tree. A symbol the file only tests positively (#if UNITY_6000_7_OR_NEWER) is + // defined; one it only negates (#if !UNITY_SERVER) is left undefined, so that branch is active + // too. A symbol tested both ways cannot be satisfied by one parse — it is defined, and the losing + // branch is caught by ReportHiddenCommands below. var probe = CSharpSyntaxTree.ParseText(text, new CSharpParseOptions(LanguageVersion.Latest), path: relativePath); var conditionals = Directives(probe.GetRoot()).OfType<ConditionalDirectiveTriviaSyntax>().ToList(); - var symbols = conditionals - .SelectMany(d => d.Condition.DescendantNodesAndSelf().OfType<IdentifierNameSyntax>()) - .Select(i => i.Identifier.ValueText) - .Distinct(StringComparer.Ordinal) - .ToList(); + + var positive = new HashSet<string>(StringComparer.Ordinal); + var negative = new HashSet<string>(StringComparer.Ordinal); + foreach (var conditional in conditionals) + CollectPolarities(conditional.Condition, negated: false, positive, negative); + + var symbols = positive.ToList(); var tree = CSharpSyntaxTree.ParseText( text, @@ -164,10 +170,11 @@ private SyntaxTree ParseTree(string path, string relativePath) } /// <summary> - /// Defining every symbol a file mentions activates the first branch of each <c>#if</c> chain, so a - /// command declared in an <c>#else</c>, an <c>#elif</c>, or under a negated condition stays inactive - /// and never reaches the syntax tree. Those branches survive as disabled text — report any that - /// declares a command rather than dropping it without a word. + /// The symbol choice in <see cref="ParseTree"/> satisfies each condition where it can, but one parse + /// cannot activate both arms of an <c>#if</c>/<c>#else</c>, nor both branches of a symbol the file + /// tests positively in one place and negated in another. The losing branch never reaches the syntax + /// tree; it survives as disabled text — report any that declares a command rather than dropping it + /// without a word. /// </summary> private void ReportHiddenCommands(SyntaxTree tree, string relativePath) { @@ -409,6 +416,36 @@ string Close(int end) } } + /// <summary> + /// Records with which polarity a condition tests each symbol: <c>UNITY_EDITOR && !UNITY_SERVER</c> + /// tests the first positively and the second negatively, and <c>!(A || B)</c> negates both. An + /// equality comparison (<c>X == false</c>) is rare enough in package guards that its operands are + /// simply counted as positive; a command lost to that goes through the hidden-command report, not + /// through silence. + /// </summary> + private static void CollectPolarities(ExpressionSyntax condition, bool negated, ISet<string> positive, ISet<string> negative) + { + switch (condition) + { + case IdentifierNameSyntax identifier: + (negated ? negative : positive).Add(identifier.Identifier.ValueText); + break; + + case PrefixUnaryExpressionSyntax unary when unary.IsKind(SyntaxKind.LogicalNotExpression): + CollectPolarities(unary.Operand, !negated, positive, negative); + break; + + case ParenthesizedExpressionSyntax parenthesized: + CollectPolarities(parenthesized.Expression, negated, positive, negative); + break; + + case BinaryExpressionSyntax binary: + CollectPolarities(binary.Left, negated, positive, negative); + CollectPolarities(binary.Right, negated, positive, negative); + break; + } + } + /// <summary>Every preprocessor directive in a file, in source order. Directives live in trivia, not in the node tree.</summary> private static IEnumerable<DirectiveTriviaSyntax> Directives(SyntaxNode root) => root.DescendantTrivia(descendIntoTrivia: true)