diff --git a/.github/workflows/command-ref.yml b/.github/workflows/command-ref.yml new file mode 100644 index 0000000..778c854 --- /dev/null +++ b/.github/workflows/command-ref.yml @@ -0,0 +1,71 @@ +name: command reference + +# Regenerates skills/unity-pipeline/references/editor-commands.md from the com.unity.pipeline +# package sources on the public UPM registry. A bare runner with the .NET SDK is the whole +# requirement — no Unity Editor, no license, no `unity --json command` dump. +# +# Triggered by hand, or by the release-watch workflow, which polls the same versions endpoint. + +on: + workflow_dispatch: + inputs: + version: + description: 'com.unity.pipeline version to document, or "latest"' + type: string + default: latest + workflow_call: + inputs: + version: + description: 'com.unity.pipeline version to document, or "latest"' + type: string + default: latest + +permissions: + contents: read + +jobs: + generate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + # Parsing regressions surface here, against the checked-in fixture package, before the + # generator is let near the real reference file. + - name: Verify the generator against its fixture package + run: tools/CommandRefGen/tests/run.sh + + - name: Regenerate the command reference + env: + VERSION: ${{ inputs.version }} + run: | + set -o pipefail + dotnet run --project tools/CommandRefGen -- --version "$VERSION" 2>&1 | tee gen.log + + - name: Diff summary + if: always() + run: | + { + echo '## Command reference' + echo + echo '```' + sed -n '/^diff against /,/commands)$/p' gen.log + echo '```' + echo + echo '### Warnings' + echo + echo '```' + grep '^warning:' gen.log || echo 'none' + echo '```' + } >>"$GITHUB_STEP_SUMMARY" + git diff --stat -- skills/ + + - uses: actions/upload-artifact@v4 + with: + name: editor-commands + path: | + skills/unity-pipeline/references/editor-commands.md + gen.log diff --git a/skills/unity-pipeline/references/editor-commands.md b/skills/unity-pipeline/references/editor-commands.md index b1036f4..e82b141 100644 --- a/skills/unity-pipeline/references/editor-commands.md +++ b/skills/unity-pipeline/references/editor-commands.md @@ -1,12 +1,14 @@ # Editor command reference diff --git a/tools/CommandRefGen/.gitignore b/tools/CommandRefGen/.gitignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/tools/CommandRefGen/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/tools/CommandRefGen/CommandRefGen.csproj b/tools/CommandRefGen/CommandRefGen.csproj new file mode 100644 index 0000000..7b09335 --- /dev/null +++ b/tools/CommandRefGen/CommandRefGen.csproj @@ -0,0 +1,25 @@ + + + + Exe + net8.0 + enable + enable + CommandRefGen + CommandRefGen + true + true + + false + + + + + + + + + + + + diff --git a/tools/CommandRefGen/README.md b/tools/CommandRefGen/README.md new file mode 100644 index 0000000..093bd72 --- /dev/null +++ b/tools/CommandRefGen/README.md @@ -0,0 +1,97 @@ +# CommandRefGen + +Generates [`skills/unity-pipeline/references/editor-commands.md`](../../skills/unity-pipeline/references/editor-commands.md) +from the `com.unity.pipeline` package sources. No Unity Editor, no license, no +`unity --json command` dump — a machine with the .NET 8 SDK is the whole requirement. + +The package is public on the UPM registry with no auth, and its C# sources carry every +`[CliCommand]`/`[CliArg]` declaration, including the `RuntimeOnly` commands the live listing +filters out and the version-gated ones that only exist on newer Unity. The source is therefore +a strict superset of the live listing, and the only source that needs no running editor. + +## Usage + +```bash +# document the newest version in the registry, updating the reference file in place +dotnet run --project tools/CommandRefGen -- --version latest + +# pin a version +dotnet run --project tools/CommandRefGen -- --version 0.4.0-exp.1 + +# CI drift check: exits 3 when the reference file is out of date +dotnet run --project tools/CommandRefGen -- --version latest --check +``` + +A version bump is a rerun with a different `--version`. There is no code to edit. + +Every run prints a diff summary against the file already on disk — added, removed and changed +commands and arguments — plus warnings, to stderr. `--stdout` puts the document on stdout so +stderr stays a clean log. + +Offline, when the registry is unreachable (filtered egress, an air-gapped runner): + +```bash +dotnet run --project tools/CommandRefGen -- --tarball ./com.unity.pipeline-0.4.0-exp.1.tgz +dotnet run --project tools/CommandRefGen -- --source-dir ./unpacked/package +``` + +Both read the version from the package's `package.json`. `--help` lists every option. + +## What it owns + +The generator owns the output file end to end: the do-not-edit banner, the preamble, the +Contents list, every category section and the RuntimeOnly section. Nothing in it is +hand-maintained, which is the drift trap the old two-step pipeline (a dump plus a hand-spliced +RuntimeOnly section) existed to create. + +Field notes that are true but not derivable from the source — behaviour observed in the field, +links to the other skill files — live in [`annotations.json`](annotations.json) and are merged +into the matching command's entry. That keeps hand-maintained prose in a file the generator +reads, never in the file it writes. A note whose command no longer exists is reported as unused. + +## Rules it applies + +- **Roslyn, not regexes.** Attributes span lines, descriptions are built by concatenation or + written as verbatim strings, and defaults live in two places. +- **Defaults.** The attribute's `DefaultValue` wins; otherwise the C# parameter default applies. + Well-known constants are folded, including the package's `float.MinValue` "leave Unity's own + value alone" sentinel, which renders as `-3.40282347e+38` exactly as the live listing does. +- **Required.** An explicit `Required` (or `IsRequired`) on `[CliArg]` decides it. Without one, + an argument with no default of any kind is required. The first run against a new package + version reports any argument whose required-ness moved, in the diff summary. +- **Descriptions are never shortened.** Newlines and whitespace runs collapse to single spaces + so a multi-line description cannot break the markdown list, and that is the only edit. An + emergency ceiling (`--max-description`, 1000 chars) exists so a runaway string cannot wreck + the file; hitting it prints a warning naming the command — it is never a silent cut. +- **Version gates.** A command compiled under `#if UNITY_6000_7_OR_NEWER` is marked with its + Unity floor instead of being presented as universally available. Non-version gates + (`#if ENABLE_INPUT_SYSTEM`) are reported as conditional. Each file is parsed twice — once with + every `#if` symbol defined, once with none — so `#else` and `#if !SYMBOL` branches are reached + too; a command declared in both branches is reported as ungated, because it exists either way. +- **`Tests/` is excluded.** The package's test assembly registers throwaway commands + (`log_editor`, `test_types`, `test_structured`) to prove registration works. A + `Commands/Tests/` directory is a different thing — that is where `run_tests` and friends live, + and it is kept. +- **Categories come from the source tree**, from the directory under `Commands/`. Display names + and section order live in `Categories.cs`; an unmapped directory still gets a section, with its + name humanized and a warning naming it, so the mapping is extended on purpose rather than by + accident. +- **Project-defined commands are out of scope.** The reference documents the package surface. + +## Tests + +```bash +tools/CommandRefGen/tests/run.sh # compare against the golden file +tools/CommandRefGen/tests/run.sh --update # accept the current output as the new golden +``` + +`tests/fixture-package/` is a small source tree that exercises each rule above — multi-line +attributes, both default sources, an explicit `Required` on an argument that has a default, +the `float.MinValue` sentinel, a verbatim multi-line description, a `#if` version gate, a +compound gate, a command declared in both branches of an `#if`, `RuntimeOnly`, +`MainThreadRequired = false`, a parameter with no `[CliArg]`, an unmapped category directory, +and a `Tests/` assembly whose commands must not appear. The script also asserts the warnings +the fixture is built to provoke, and both `--check` outcomes. + +CI runs it before the generator touches the real reference file +([`.github/workflows/command-ref.yml`](../../.github/workflows/command-ref.yml)). diff --git a/tools/CommandRefGen/annotations.json b/tools/CommandRefGen/annotations.json new file mode 100644 index 0000000..6608f40 --- /dev/null +++ b/tools/CommandRefGen/annotations.json @@ -0,0 +1,21 @@ +{ + // Field notes merged into the generated reference by tools/CommandRefGen. + // + // Everything a command's entry says is read out of the package source, except what is in + // here: behaviour observed in the field, and links to the other skill files. Keeping these + // notes out of the generated document is what lets the generator own that file end to end. + // + // Each key is a command name; the value is markdown appended after the command's + // description (and after any version-gate note the generator derives from `#if`). + // The generator warns about a key that matches no command in the package version it read. + "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.**", + + "capture_runtime_element": + "Unlike the other RuntimeOnly commands it does not execute against the editor server; on an editor older than its Unity floor it fails with exit code 6, indistinguishably from an unknown command name.", + + "quit": + "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))." + } +} diff --git a/tools/CommandRefGen/src/Annotations.cs b/tools/CommandRefGen/src/Annotations.cs new file mode 100644 index 0000000..684190d --- /dev/null +++ b/tools/CommandRefGen/src/Annotations.cs @@ -0,0 +1,65 @@ +using System.Text.Json; + +namespace CommandRefGen; + +/// +/// Field notes that are true but not derivable from the package source — observed behaviour, +/// cross-references to other skill files. They live outside the generated document so the +/// generator can own that file end to end; the generator merges them into a command's entry. +/// +internal sealed class Annotations +{ + private readonly Dictionary _notes; + + private Annotations(Dictionary notes) => _notes = notes; + + public static Annotations Empty { get; } = new([]); + + /// The note appended after a command's description, or "" when there is none. + public string NoteFor(string command) => _notes.GetValueOrDefault(command, ""); + + /// Names that no longer match a command — stale notes to clean up. + public IEnumerable Orphans(IEnumerable commandNames) + { + var known = commandNames.ToHashSet(StringComparer.Ordinal); + return _notes.Keys.Where(k => !known.Contains(k)).OrderBy(k => k, StringComparer.Ordinal); + } + + public static Annotations Load(string path) + { + if (!File.Exists(path)) + { + Log.Info($"annotations: {path} not found, continuing without field notes"); + return Empty; + } + + using var doc = JsonDocument.Parse(File.ReadAllText(path), new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }); + + var notes = new Dictionary(StringComparer.Ordinal); + if (doc.RootElement.TryGetProperty("commands", out var commands) + && commands.ValueKind == JsonValueKind.Object) + { + foreach (var entry in commands.EnumerateObject()) + { + var note = entry.Value.ValueKind switch + { + JsonValueKind.String => entry.Value.GetString(), + JsonValueKind.Object when entry.Value.TryGetProperty("note", out var n) => n.GetString(), + _ => null, + }; + + if (string.IsNullOrWhiteSpace(note)) + Log.Warn($"annotations: '{entry.Name}' has no note text; ignoring it"); + else + notes[entry.Name] = note.Trim(); + } + } + + Log.Info($"annotations: {notes.Count} field note(s) from {path}"); + return new Annotations(notes); + } +} diff --git a/tools/CommandRefGen/src/Categories.cs b/tools/CommandRefGen/src/Categories.cs new file mode 100644 index 0000000..2cf05d1 --- /dev/null +++ b/tools/CommandRefGen/src/Categories.cs @@ -0,0 +1,168 @@ +using System.Text; + +namespace CommandRefGen; + +/// +/// Turns a source directory under `Commands/` into a section title, and fixes the order +/// sections appear in. Only presentation lives here — which commands exist, and what they +/// do, always comes from the package source. +/// +internal static class Categories +{ + public const string RuntimeOnlyTitle = "RuntimeOnly commands (hidden from the listing)"; + private const string Fallback = "Other"; + + /// Section order. Titles not listed here follow, sorted alphabetically. + private static readonly string[] Order = + [ + "Editor & play mode", + "Capture", + "Console & logs", + "Scenes", + "GameObjects & components", + "Prefabs", + "Assets & files", + "Scripts & compilation", + "Tests", + "Build", + "Packages (UPM)", + "Materials & shaders", + "Animation & Timeline", + "Lighting bake", + "NavMesh bake", + "Occlusion bake", + "Project settings", + ]; + + /// + /// Directory name (normalized: lowercase, alphanumerics only) to section title. + /// A directory that is not here still gets a section — its name is humanized and a + /// warning names it, so the mapping can be extended deliberately. + /// + private static readonly Dictionary Titles = new(StringComparer.Ordinal) + { + ["editor"] = "Editor & play mode", + ["playmode"] = "Editor & play mode", + ["editorplaymode"] = "Editor & play mode", + ["capture"] = "Capture", + ["captures"] = "Capture", + ["screenshots"] = "Capture", + ["console"] = "Console & logs", + ["logs"] = "Console & logs", + ["consolelogs"] = "Console & logs", + ["scene"] = "Scenes", + ["scenes"] = "Scenes", + ["gameobject"] = "GameObjects & components", + ["gameobjects"] = "GameObjects & components", + ["components"] = "GameObjects & components", + ["gameobjectscomponents"] = "GameObjects & components", + ["hierarchy"] = "GameObjects & components", + ["prefab"] = "Prefabs", + ["prefabs"] = "Prefabs", + ["asset"] = "Assets & files", + ["assets"] = "Assets & files", + ["files"] = "Assets & files", + ["assetsfiles"] = "Assets & files", + ["script"] = "Scripts & compilation", + ["scripts"] = "Scripts & compilation", + ["scripting"] = "Scripts & compilation", + ["compilation"] = "Scripts & compilation", + ["scriptscompilation"] = "Scripts & compilation", + ["test"] = "Tests", + ["tests"] = "Tests", + ["testing"] = "Tests", + ["build"] = "Build", + ["builds"] = "Build", + ["package"] = "Packages (UPM)", + ["packages"] = "Packages (UPM)", + ["upm"] = "Packages (UPM)", + ["material"] = "Materials & shaders", + ["materials"] = "Materials & shaders", + ["shaders"] = "Materials & shaders", + ["materialsshaders"] = "Materials & shaders", + ["animation"] = "Animation & Timeline", + ["animations"] = "Animation & Timeline", + ["timeline"] = "Animation & Timeline", + ["animationtimeline"] = "Animation & Timeline", + ["lighting"] = "Lighting bake", + ["lightingbake"] = "Lighting bake", + ["navmesh"] = "NavMesh bake", + ["navmeshbake"] = "NavMesh bake", + ["navigation"] = "NavMesh bake", + ["occlusion"] = "Occlusion bake", + ["occlusionbake"] = "Occlusion bake", + ["occlusionculling"] = "Occlusion bake", + ["settings"] = "Project settings", + ["projectsettings"] = "Project settings", + }; + + /// Section title for a category directory, warning once per unmapped directory. + public static string TitleFor(string? directory) + { + if (string.IsNullOrEmpty(directory)) return Fallback; + + var key = Normalize(directory); + if (Titles.TryGetValue(key, out var title)) return title; + + var humanized = Humanize(directory); + if (Unmapped.Add(directory)) + Log.Warn($"category directory '{directory}' has no mapped section title; using " + + $"'{humanized}' and placing it after the known sections " + + "(add it to Categories.Titles to control the name and position)"); + return humanized; + } + + private static readonly HashSet Unmapped = new(StringComparer.Ordinal); + + /// Sort key: known sections in , then the rest alphabetically. + public static (int Rank, string Title) SortKey(string title) + { + var index = Array.IndexOf(Order, title); + return (index >= 0 ? index : Order.Length, title); + } + + /// A GitHub-flavoured heading anchor, for the Contents list. + public static string Anchor(string heading) + { + var sb = new StringBuilder(heading.Length); + foreach (var ch in heading.ToLowerInvariant()) + { + if (char.IsLetterOrDigit(ch) || ch is '-' or '_') sb.Append(ch); + else if (ch == ' ') sb.Append('-'); + } + return sb.ToString(); + } + + private static string Normalize(string directory) + { + var sb = new StringBuilder(directory.Length); + foreach (var ch in directory.ToLowerInvariant()) + if (char.IsLetterOrDigit(ch)) sb.Append(ch); + return sb.ToString(); + } + + /// `AnimationTimeline` / `animation_timeline` -> `Animation timeline`. + private static string Humanize(string directory) + { + var sb = new StringBuilder(directory.Length + 4); + for (var i = 0; i < directory.Length; i++) + { + var ch = directory[i]; + if (ch is '_' or '-' or '.') + { + sb.Append(' '); + continue; + } + + var boundary = i > 0 + && char.IsUpper(ch) + && (char.IsLower(directory[i - 1]) + || (i + 1 < directory.Length && char.IsLower(directory[i + 1]))); + if (boundary) sb.Append(' '); + sb.Append(sb.Length == 0 ? char.ToUpperInvariant(ch) : char.ToLowerInvariant(ch)); + } + + var text = sb.ToString().Trim(); + return text.Length == 0 ? Fallback : text; + } +} diff --git a/tools/CommandRefGen/src/DiffReporter.cs b/tools/CommandRefGen/src/DiffReporter.cs new file mode 100644 index 0000000..718586a --- /dev/null +++ b/tools/CommandRefGen/src/DiffReporter.cs @@ -0,0 +1,195 @@ +using System.Text; + +namespace CommandRefGen; + +/// +/// Compares the generated document against the one already on disk and prints what moved: +/// added / removed / changed commands and arguments. The generator owns the file format, so +/// re-reading its own output is enough — no dump and no editor are needed for the comparison. +/// +internal static class DiffReporter +{ + private sealed record ParsedArg(string Name, string Type, string? Default, bool Required, string Description) + { + public string Signature => $"{Type}|{Default ?? "-"}|{(Required ? "req" : "opt")}"; + } + + private sealed record ParsedCommand(string Name, string Section, string Description, List Args); + + public static string Report(string? previousMarkdown, string generatedMarkdown, string path) + { + var generated = Parse(generatedMarkdown); + + if (previousMarkdown is null) + return $"diff: {path} does not exist yet — all {generated.Count} commands are new."; + + var previous = Parse(previousMarkdown); + var sb = new StringBuilder(); + sb.Append("diff against ").Append(path).Append(":\n"); + + var added = generated.Keys.Except(previous.Keys, StringComparer.Ordinal) + .OrderBy(k => k, StringComparer.Ordinal).ToList(); + var removed = previous.Keys.Except(generated.Keys, StringComparer.Ordinal) + .OrderBy(k => k, StringComparer.Ordinal).ToList(); + var common = generated.Keys.Intersect(previous.Keys, StringComparer.Ordinal) + .OrderBy(k => k, StringComparer.Ordinal).ToList(); + + var changed = new List(); + var unchanged = 0; + + foreach (var name in common) + { + var lines = CommandChanges(previous[name], generated[name]); + if (lines.Count == 0) + { + unchanged++; + continue; + } + + changed.Add(name); + sb.Append(" ~ ").Append(name).Append('\n'); + foreach (var line in lines) sb.Append(" ").Append(line).Append('\n'); + } + + foreach (var name in added) + sb.Append(" + ").Append(name).Append(" [").Append(generated[name].Section) + .Append("], ").Append(generated[name].Args.Count).Append(" argument(s)\n"); + + foreach (var name in removed) + sb.Append(" - ").Append(name).Append(" [was in ").Append(previous[name].Section).Append("]\n"); + + sb.Append($" {added.Count} added, {removed.Count} removed, {changed.Count} changed, {unchanged} unchanged") + .Append($" ({previous.Count} -> {generated.Count} commands)"); + + return sb.ToString(); + } + + private static List CommandChanges(ParsedCommand before, ParsedCommand after) + { + var lines = new List(); + + if (!string.Equals(before.Description, after.Description, StringComparison.Ordinal)) + lines.Add("description changed"); + + if (!string.Equals(before.Section, after.Section, StringComparison.Ordinal)) + lines.Add($"section {before.Section} -> {after.Section}"); + + var beforeArgs = before.Args.ToDictionary(a => a.Name, StringComparer.Ordinal); + var afterArgs = after.Args.ToDictionary(a => a.Name, StringComparer.Ordinal); + + foreach (var name in afterArgs.Keys.Except(beforeArgs.Keys, StringComparer.Ordinal) + .OrderBy(k => k, StringComparer.Ordinal)) + lines.Add($"+ argument `{name}` {Describe(afterArgs[name])}"); + + foreach (var name in beforeArgs.Keys.Except(afterArgs.Keys, StringComparer.Ordinal) + .OrderBy(k => k, StringComparer.Ordinal)) + lines.Add($"- argument `{name}` {Describe(beforeArgs[name])}"); + + foreach (var name in afterArgs.Keys.Intersect(beforeArgs.Keys, StringComparer.Ordinal) + .OrderBy(k => k, StringComparer.Ordinal)) + { + var a = beforeArgs[name]; + var b = afterArgs[name]; + var deltas = new List(); + + if (a.Type != b.Type) deltas.Add($"type {a.Type} -> {b.Type}"); + if (a.Default != b.Default) deltas.Add($"default {a.Default ?? "(none)"} -> {b.Default ?? "(none)"}"); + if (a.Required != b.Required) deltas.Add(b.Required ? "now required" : "no longer required"); + if (!string.Equals(a.Description, b.Description, StringComparison.Ordinal)) deltas.Add("description changed"); + + if (deltas.Count > 0) lines.Add($"~ argument `{name}`: {string.Join(", ", deltas)}"); + } + + return lines; + } + + private static string Describe(ParsedArg arg) + { + var text = arg.Type; + if (arg.Default is not null) text += $" (default {arg.Default})"; + return arg.Required ? text + ", required" : text; + } + + /// Reads a reference document in this generator's own format. + private static Dictionary Parse(string markdown) + { + var commands = new Dictionary(StringComparer.Ordinal); + var section = ""; + ParsedCommand? current = null; + var wantDescription = false; + + foreach (var raw in markdown.Split('\n')) + { + var line = raw.TrimEnd('\r'); + + if (line.StartsWith("## ", StringComparison.Ordinal)) + { + section = line[3..].Trim(); + current = null; + wantDescription = false; + continue; + } + + if (line.StartsWith("### ", StringComparison.Ordinal)) + { + var name = line[4..].Trim(); + current = new ParsedCommand(name, section, "", []); + commands[name] = current; + wantDescription = true; + continue; + } + + if (current is null) continue; + + if (wantDescription) + { + wantDescription = false; + if (line.Length > 0 && !line.StartsWith("- ", StringComparison.Ordinal)) + { + commands[current.Name] = current = current with { Description = line.Trim() }; + continue; + } + } + + if (line.StartsWith("- ", StringComparison.Ordinal) && ParseArg(line) is { } arg) + current.Args.Add(arg); + } + + return commands; + } + + private static ParsedArg? ParseArg(string line) + { + var body = line[2..]; + if (!body.StartsWith('`')) return null; // "*(no arguments)*" + + var close = body.IndexOf('`', 1); + if (close < 0) return null; + + var name = body[1..close]; + var rest = body[(close + 1)..]; + + var required = rest.StartsWith("\\*", StringComparison.Ordinal); + if (required) rest = rest[2..]; + rest = rest.TrimStart(); + + var description = ""; + var dash = rest.IndexOf(" — ", StringComparison.Ordinal); + if (dash >= 0) + { + description = rest[(dash + 3)..].Trim(); + rest = rest[..dash]; + } + + string? @default = null; + const string marker = " (default "; + var defaultAt = rest.IndexOf(marker, StringComparison.Ordinal); + if (defaultAt >= 0 && rest.EndsWith(')')) + { + @default = rest[(defaultAt + marker.Length)..^1]; + rest = rest[..defaultAt]; + } + + return new ParsedArg(name, rest.Trim(), @default, required, description); + } +} diff --git a/tools/CommandRefGen/src/Literals.cs b/tools/CommandRefGen/src/Literals.cs new file mode 100644 index 0000000..d490669 --- /dev/null +++ b/tools/CommandRefGen/src/Literals.cs @@ -0,0 +1,209 @@ +using System.Globalization; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace CommandRefGen; + +/// +/// Folds the constant expressions that appear in `[CliCommand]`/`[CliArg]` attribute +/// arguments and parameter defaults, and renders types and defaults the way the CLI +/// listing does (CLR type names, JSON-ish literals). +/// +internal static class Literals +{ + /// A recognized constant. Value may be null for a `null` literal. + public static bool TryEvaluate(ExpressionSyntax? expr, out object? value) + { + value = null; + if (expr is null) return false; + + switch (expr) + { + case ParenthesizedExpressionSyntax p: + return TryEvaluate(p.Expression, out value); + + case CastExpressionSyntax c: + // `(float)0.5` — the cast target is reflected by the declared parameter type. + return TryEvaluate(c.Expression, out value); + + case LiteralExpressionSyntax lit: + if (lit.IsKind(SyntaxKind.NullLiteralExpression)) return true; // null, recognized + if (lit.IsKind(SyntaxKind.DefaultLiteralExpression)) return false; // needs the type + value = lit.Token.Value; + return value is not null; + + case PrefixUnaryExpressionSyntax u when u.IsKind(SyntaxKind.UnaryMinusExpression): + if (!TryEvaluate(u.Operand, out var neg)) return false; + value = neg switch + { + int i => -i, + long l => -l, + float f => -f, + double d => -d, + decimal m => -m, + _ => null, + }; + return value is not null; + + case PrefixUnaryExpressionSyntax u when u.IsKind(SyntaxKind.UnaryPlusExpression): + return TryEvaluate(u.Operand, out value); + + case PrefixUnaryExpressionSyntax u when u.IsKind(SyntaxKind.LogicalNotExpression): + if (!TryEvaluate(u.Operand, out var not) || not is not bool b) return false; + value = !b; + return true; + + case BinaryExpressionSyntax bin when bin.IsKind(SyntaxKind.AddExpression): + // Multi-line descriptions are usually written as concatenated literals. + if (!TryEvaluate(bin.Left, out var left) || !TryEvaluate(bin.Right, out var right)) return false; + if (left is string ls && right is string rs) { value = ls + rs; return true; } + return false; + + case MemberAccessExpressionSyntax ma: + return TryWellKnownConstant(ma, out value); + + default: + return false; + } + } + + /// `float.MinValue`, `string.Empty`, `int.MaxValue` and friends. + private static bool TryWellKnownConstant(MemberAccessExpressionSyntax ma, out object? value) + { + value = null; + var type = SimpleName(ma.Expression); + if (type is null) return false; + + value = (Canonical(type), ma.Name.Identifier.ValueText) switch + { + ("String", "Empty") => "", + ("Single", "MinValue") => float.MinValue, + ("Single", "MaxValue") => float.MaxValue, + ("Single", "Epsilon") => float.Epsilon, + ("Single", "NaN") => float.NaN, + ("Single", "PositiveInfinity") => float.PositiveInfinity, + ("Single", "NegativeInfinity") => float.NegativeInfinity, + ("Double", "MinValue") => double.MinValue, + ("Double", "MaxValue") => double.MaxValue, + ("Double", "Epsilon") => double.Epsilon, + ("Int32", "MinValue") => int.MinValue, + ("Int32", "MaxValue") => int.MaxValue, + ("Int64", "MinValue") => long.MinValue, + ("Int64", "MaxValue") => long.MaxValue, + ("Int16", "MinValue") => short.MinValue, + ("Int16", "MaxValue") => short.MaxValue, + ("Byte", "MinValue") => byte.MinValue, + ("Byte", "MaxValue") => byte.MaxValue, + _ => null, + }; + + return value is not null; + } + + /// The zero value of a type, for a `default` / `default(T)` parameter default. + public static object? DefaultOf(TypeSyntax type) + { + if (type is NullableTypeSyntax or ArrayTypeSyntax) return null; + + return Canonical(SimpleName(type) ?? "") switch + { + "Boolean" => false, + "Int32" => 0, + "Int64" => 0L, + "Int16" => (short)0, + "Byte" => (byte)0, + "Single" => 0f, + "Double" => 0d, + "Decimal" => 0m, + "Char" => '\0', + _ => null, + }; + } + + /// Renders a default the way the CLI listing shows it; null means "no default". + public static string? FormatDefault(object? value) => value switch + { + null => null, + string s => Quote(s), + char c => Quote(c.ToString()), + bool b => b ? "true" : "false", + // G9 round-trips a float and matches the listing's rendering of the + // "leave Unity's value alone" sentinel float.MinValue as -3.40282347e+38. + float f => f.ToString("G9", CultureInfo.InvariantCulture).Replace("E", "e"), + double d => d.ToString("R", CultureInfo.InvariantCulture).Replace("E", "e"), + decimal m => m.ToString(CultureInfo.InvariantCulture), + IFormattable n => n.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString(), + }; + + private static string Quote(string s) + { + var sb = new StringBuilder(s.Length + 2).Append('"'); + foreach (var ch in s) + { + switch (ch) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (char.IsControl(ch)) sb.Append("\\u").Append(((int)ch).ToString("x4")); + else sb.Append(ch); + break; + } + } + return sb.Append('"').ToString(); + } + + /// Renders a parameter type as the CLI listing does: `String`, `Int32`, `ObjectId[]`. + public static string FormatType(TypeSyntax type) => type switch + { + // The listing reports the underlying type; nullability is not part of the CLI surface. + NullableTypeSyntax n => FormatType(n.ElementType), + ArrayTypeSyntax a => FormatType(a.ElementType) + string.Concat(a.RankSpecifiers.Select(_ => "[]")), + GenericNameSyntax g when Canonical(g.Identifier.ValueText) is "List" or "IList" or "IEnumerable" or "IReadOnlyList" + => FormatType(g.TypeArgumentList.Arguments[0]) + "[]", + GenericNameSyntax g + => $"{g.Identifier.ValueText}<{string.Join(", ", g.TypeArgumentList.Arguments.Select(FormatType))}>", + _ => Canonical(SimpleName(type) ?? type.ToString()), + }; + + /// The trailing identifier of a possibly qualified or aliased name. + public static string? SimpleName(SyntaxNode? node) => node switch + { + null => null, + IdentifierNameSyntax id => id.Identifier.ValueText, + GenericNameSyntax g => g.Identifier.ValueText, + QualifiedNameSyntax q => SimpleName(q.Right), + AliasQualifiedNameSyntax a => SimpleName(a.Name), + MemberAccessExpressionSyntax m => m.Name.Identifier.ValueText, + PredefinedTypeSyntax p => p.Keyword.ValueText, + _ => null, + }; + + /// C# keyword to CLR type name; anything else is passed through. + private static string Canonical(string name) => name switch + { + "string" => "String", + "bool" => "Boolean", + "int" => "Int32", + "uint" => "UInt32", + "long" => "Int64", + "ulong" => "UInt64", + "short" => "Int16", + "ushort" => "UInt16", + "byte" => "Byte", + "sbyte" => "SByte", + "float" => "Single", + "double" => "Double", + "decimal" => "Decimal", + "char" => "Char", + "object" => "Object", + "void" => "Void", + _ => name, + }; +} diff --git a/tools/CommandRefGen/src/Log.cs b/tools/CommandRefGen/src/Log.cs new file mode 100644 index 0000000..c0b0108 --- /dev/null +++ b/tools/CommandRefGen/src/Log.cs @@ -0,0 +1,29 @@ +namespace CommandRefGen; + +/// +/// Diagnostics go to stderr so `--stdout` stays a clean document. +/// +internal static class Log +{ + private static readonly HashSet Warnings = new(StringComparer.Ordinal); + + public static int WarningCount => Warnings.Count; + + public static void Info(string message) => Console.Error.WriteLine(message); + + /// + /// Warns once per distinct message: a file is parsed twice (once per preprocessor pass), + /// so the same finding is reached twice. + /// + public static void Warn(string message) + { + if (!Warnings.Add(message)) return; + Console.Error.WriteLine($"warning: {message}"); + } + + public static void Summary() + { + if (Warnings.Count > 0) + Console.Error.WriteLine($"{Warnings.Count} warning(s)"); + } +} diff --git a/tools/CommandRefGen/src/MarkdownWriter.cs b/tools/CommandRefGen/src/MarkdownWriter.cs new file mode 100644 index 0000000..691c9ee --- /dev/null +++ b/tools/CommandRefGen/src/MarkdownWriter.cs @@ -0,0 +1,154 @@ +using System.Text; + +namespace CommandRefGen; + +/// +/// Renders the whole reference document. Every line of the output file comes from here: +/// the do-not-edit banner, the preamble, the Contents list, each category section and the +/// RuntimeOnly section. Nothing in the output is hand-maintained. +/// +internal static class MarkdownWriter +{ + public static string Render(IReadOnlyList commands, string version, Annotations annotations) + { + var listed = commands.Where(c => !c.RuntimeOnly).ToList(); + var runtimeOnly = commands.Where(c => c.RuntimeOnly) + .OrderBy(c => c.Name, StringComparer.Ordinal) + .ToList(); + + var sections = listed + .GroupBy(c => Categories.TitleFor(c.CategoryDir)) + .OrderBy(g => Categories.SortKey(g.Key)) + .ToList(); + + var sb = new StringBuilder(); + + Banner(sb); + Preamble(sb, version, commands.Count); + Contents(sb, sections.Select(s => s.Key), runtimeOnly.Count > 0); + + foreach (var section in sections) + { + sb.Append("## ").Append(section.Key).Append("\n\n"); + foreach (var command in section.OrderBy(c => c.Name, StringComparer.Ordinal)) + Entry(sb, command, annotations); + } + + if (runtimeOnly.Count > 0) + { + RuntimeOnlyPreamble(sb, version, runtimeOnly); + foreach (var command in runtimeOnly) + Entry(sb, command, annotations); + } + + return sb.ToString().TrimEnd() + "\n"; + } + + private static void Banner(StringBuilder sb) => sb.Append(""" + + + """).Append('\n'); + + private static void Preamble(StringBuilder sb, string version, int count) + { + sb.Append("# Editor command reference\n\n"); + + sb.Append("Generated by `tools/CommandRefGen` from the `com.unity.pipeline` package source, version `") + .Append(version) + .Append("` — ").Append(count) + .Append(" commands, read from the `[CliCommand]`/`[CliArg]` attributes of the package's C# sources ") + .Append("rather than from a live editor. The file therefore covers every command the package ") + .Append("declares, on every Unity version it supports.\n\n"); + + sb.Append("**This file is more complete than `unity command`.** The live listing is dynamic ") + .Append("(package version, project code, optional packages, project-defined `[CliCommand]` methods), ") + .Append("and it filters out commands whose attribute sets `RuntimeOnly = true` — those are designed ") + .Append("for Unity **Player** connections (`unity command --runtime `) — yet the editor ") + .Append("server still executes them when called by name, in edit mode and play mode alike. The CLI ") + .Append("never reveals their schemas; the [") + .Append(Categories.RuntimeOnlyTitle.Split(" (")[0]) + .Append("](#").Append(Categories.Anchor(Categories.RuntimeOnlyTitle)) + .Append(") section below is the only schema source for them. Absence from the listing is not proof ") + .Append("a command does not exist — a genuinely unknown name fails with exit code 6. The reverse also ") + .Append("holds: a project's own `[CliCommand]` methods are listed by the CLI but are not documented ") + .Append("here, because this reference covers the package surface only.\n\n"); + + sb.Append("Argument conventions: pass every argument as `--name value` (snake_case). `*` marks a ") + .Append("required argument. Destructive commands need `--confirm true`; most mutating commands accept ") + .Append("`--dry_run true`; async commands pair with a `*_status` poll command. A command the package ") + .Append("compiles only on newer Unity versions says so in its entry — it does not exist on older ") + .Append("ones, and calling it there fails with exit code 6.\n\n"); + } + + private static void Contents(StringBuilder sb, IEnumerable sections, bool hasRuntimeOnly) + { + var links = sections.Select(s => $"[{s}](#{Categories.Anchor(s)})").ToList(); + if (hasRuntimeOnly) + links.Add($"[{Categories.RuntimeOnlyTitle.Split(" (")[0]}](#{Categories.Anchor(Categories.RuntimeOnlyTitle)})"); + + sb.Append("## Contents\n\n").Append(string.Join(" · ", links)).Append("\n\n"); + } + + private static void RuntimeOnlyPreamble(StringBuilder sb, string version, IReadOnlyList commands) + { + sb.Append("## ").Append(Categories.RuntimeOnlyTitle).Append("\n\n"); + + sb.Append("The `[CliCommand]` attribute of every command below sets `RuntimeOnly = true`, so ") + .Append("`unity command` neither lists them nor shows their schemas — these entries, read out of the ") + .Append("`com.unity.pipeline` `").Append(version).Append("` package source, are the only schema source ") + .Append("for them. They execute against the editor server despite the flag, and against Player ") + .Append("connections (`--runtime ` / `--runtime-path `)."); + + if (commands.All(c => c.MainThreadRequired)) + sb.Append(" All of them require the main thread."); + + sb.Append("\n\n"); + } + + private static void Entry(StringBuilder sb, CommandInfo command, Annotations annotations) + { + sb.Append("### ").Append(command.Name).Append('\n'); + + var parts = new List(); + if (command.Description.Length > 0) parts.Add(command.Description); + + if (command.MinUnityVersion is { } minimum) + parts.Add($"**Unity {minimum}+ only** — the command is compiled under " + + $"`#if {SourceParser.UnitySymbolFor(minimum)}` and does not exist on older versions."); + + foreach (var condition in command.Conditions) + parts.Add($"**Conditional** — the command is compiled only where `{condition}` holds."); + + var note = annotations.NoteFor(command.Name); + if (note.Length > 0) parts.Add(note); + + if (!command.MainThreadRequired) parts.Add("*(works while main thread is busy)*"); + + sb.Append(parts.Count > 0 ? string.Join(' ', parts) : "*(no description)*").Append('\n'); + + if (command.Args.Count == 0) + { + sb.Append("- *(no arguments)*\n\n"); + return; + } + + foreach (var arg in command.Args) + { + sb.Append("- `").Append(arg.Name).Append('`'); + if (arg.Required) sb.Append("\\*"); + sb.Append(' ').Append(arg.Type); + if (arg.Default is { } value) sb.Append(" (default ").Append(value).Append(')'); + if (arg.Description.Length > 0) sb.Append(" — ").Append(arg.Description); + sb.Append('\n'); + } + + sb.Append('\n'); + } +} diff --git a/tools/CommandRefGen/src/Model.cs b/tools/CommandRefGen/src/Model.cs new file mode 100644 index 0000000..d41ea07 --- /dev/null +++ b/tools/CommandRefGen/src/Model.cs @@ -0,0 +1,49 @@ +namespace CommandRefGen; + +/// One `[CliArg]`-annotated parameter of a command. +internal sealed class CliArgInfo +{ + public required string Name { get; init; } + + /// CLR-style type name as the CLI listing shows it (`String`, `Int32`, `ObjectId[]`). + public required string Type { get; init; } + + /// Rendered default, or null when the argument has none. + public string? Default { get; init; } + + public bool Required { get; init; } + + public string Description { get; init; } = ""; + + /// Compared by the diff reporter; description text is compared separately. + public string Signature => $"{Type}|{Default ?? "-"}|{(Required ? "req" : "opt")}"; +} + +/// One `[CliCommand]`-annotated method. +internal sealed record CommandInfo +{ + public required string Name { get; init; } + public string Description { get; init; } = ""; + + /// False when the attribute sets `MainThreadRequired = false`. + public bool MainThreadRequired { get; init; } = true; + + /// True when the attribute sets `RuntimeOnly = true` — these are hidden from the editor listing. + public bool RuntimeOnly { get; init; } + + /// Category directory under `Commands/`, or null when the source sits outside one. + public string? CategoryDir { get; init; } + + /// Minimum Unity version from an enclosing `#if UNITY_x_y_OR_NEWER`, e.g. "6000.7". + public string? MinUnityVersion { get; init; } + + /// Enclosing preprocessor conditions that are not Unity version gates. + public IReadOnlyList Conditions { get; init; } = []; + + /// Package-relative source path, for warnings. + public required string SourcePath { get; init; } + + public int SourceLine { get; init; } + + public List Args { get; init; } = []; +} diff --git a/tools/CommandRefGen/src/Options.cs b/tools/CommandRefGen/src/Options.cs new file mode 100644 index 0000000..679a457 --- /dev/null +++ b/tools/CommandRefGen/src/Options.cs @@ -0,0 +1,116 @@ +namespace CommandRefGen; + +/// Thrown for bad command lines; the message is printed without a stack trace. +internal sealed class UsageException(string message) : Exception(message); + +internal sealed class Options +{ + public const string DefaultRegistry = "https://packages.unity.com"; + public const string DefaultPackage = "com.unity.pipeline"; + public const string DefaultOutput = "skills/unity-pipeline/references/editor-commands.md"; + public const string DefaultAnnotations = "tools/CommandRefGen/annotations.json"; + + public string Registry { get; private set; } = DefaultRegistry; + public string Package { get; private set; } = DefaultPackage; + + /// Package version to document, or "latest" for the highest entry in the registry. + public string Version { get; private set; } = "latest"; + + public string? Output { get; private set; } + public string? Annotations { get; private set; } + + /// Offline input: a local package tarball instead of a registry download. + public string? Tarball { get; private set; } + + /// Offline input: an already-unpacked package directory. + public string? SourceDir { get; private set; } + + /// Report the diff but do not write; exit 3 when the output would change. + public bool Check { get; private set; } + + /// Write the document to stdout instead of the output file. + public bool ToStdout { get; private set; } + + public bool KeepTemp { get; private set; } + + /// + /// Emergency ceiling on a description, in characters. Descriptions are never silently + /// truncated: hitting this prints a warning naming the command or argument. + /// + public int MaxDescription { get; private set; } = 1000; + + public static string Usage => """ + CommandRefGen — generates the Unity Pipeline command reference from package sources. + + Usage: + dotnet run --project tools/CommandRefGen -- [options] + + Input (pick one; the registry is the default): + --version Package version to document. "latest" resolves to the highest + version in the registry listing. Default: latest. + --tarball Read a local package tarball (.tgz) instead of downloading. + --source-dir Read an already-unpacked package directory. + --package Package id. Default: com.unity.pipeline. + --registry UPM registry base URL. Default: https://packages.unity.com. + + Output: + --output Markdown file to own. Default: skills/unity-pipeline/references/editor-commands.md. + --stdout Write the document to stdout; leave the output file alone. + --check Do not write. Exit 3 if the output file is out of date. + --annotations Field notes merged into descriptions. + Default: tools/CommandRefGen/annotations.json (skipped if absent). + --max-description Emergency description ceiling in characters. Default: 1000. + --keep-temp Keep the unpacked package directory and print its path. + -h, --help Print this help. + + Exit codes: 0 ok · 1 error · 3 --check found the output file out of date. + """; + + public static Options Parse(string[] args) + { + var o = new Options(); + var sawVersion = false; + + for (var i = 0; i < args.Length; i++) + { + var a = args[i]; + string Next(string name) => ++i < args.Length + ? args[i] + : throw new UsageException($"{name} needs a value"); + + switch (a) + { + case "--version": o.Version = Next(a); sawVersion = true; break; + case "--package": o.Package = Next(a); break; + case "--registry": o.Registry = Next(a).TrimEnd('/'); break; + case "--output": o.Output = Next(a); break; + case "--annotations": o.Annotations = Next(a); break; + case "--tarball": o.Tarball = Next(a); break; + case "--source-dir": o.SourceDir = Next(a); break; + case "--check": o.Check = true; break; + case "--stdout": o.ToStdout = true; break; + case "--keep-temp": o.KeepTemp = true; break; + case "--max-description": + var raw = Next(a); + if (!int.TryParse(raw, out var max) || max < 1) + throw new UsageException($"--max-description needs a positive integer, got '{raw}'"); + o.MaxDescription = max; + break; + case "-h" or "--help": + throw new UsageException(""); + default: + throw new UsageException($"unknown option '{a}'"); + } + } + + if (o.Tarball is not null && o.SourceDir is not null) + throw new UsageException("--tarball and --source-dir are mutually exclusive"); + + // Offline inputs carry their own version in package.json; an explicit --version would + // silently mislabel the document, so only accept one when it is not the default. + if (!sawVersion && (o.Tarball is not null || o.SourceDir is not null)) + o.Version = ""; + + return o; + } +} diff --git a/tools/CommandRefGen/src/Program.cs b/tools/CommandRefGen/src/Program.cs new file mode 100644 index 0000000..a60dbc5 --- /dev/null +++ b/tools/CommandRefGen/src/Program.cs @@ -0,0 +1,201 @@ +using System.Text; + +namespace CommandRefGen; + +/// +/// Generates skills/unity-pipeline/references/editor-commands.md from the com.unity.pipeline +/// package sources on the public UPM registry. No Unity Editor, no `unity --json command` dump. +/// +internal static class Program +{ + private const int ExitError = 1; + private const int ExitOutOfDate = 3; + + public static async Task Main(string[] args) + { + Options options; + try + { + options = Options.Parse(args); + } + catch (UsageException e) + { + if (e.Message.Length > 0) Console.Error.WriteLine($"error: {e.Message}\n"); + Console.Error.WriteLine(Options.Usage); + return e.Message.Length > 0 ? ExitError : 0; + } + + var temp = (string?)null; + try + { + var repoRoot = FindRepoRoot(); + var outputPath = Path.GetFullPath(options.Output ?? Path.Combine(repoRoot, Options.DefaultOutput)); + var annotationsPath = Path.GetFullPath( + options.Annotations ?? Path.Combine(repoRoot, Options.DefaultAnnotations)); + + var (packageRoot, version) = await AcquirePackageAsync(options, path => temp = path); + + var commands = new SourceParser(options.MaxDescription).Parse(packageRoot); + if (commands.Count == 0) + throw new InvalidOperationException( + $"no [CliCommand] methods found under '{packageRoot}' — is this the right package?"); + + var annotations = Annotations.Load(annotationsPath); + foreach (var orphan in annotations.Orphans(commands.Select(c => c.Name))) + Log.Warn($"annotations: '{orphan}' matches no command in {version}; the note is unused"); + + var generated = MarkdownWriter.Render(commands, version, annotations); + var previous = File.Exists(outputPath) ? await File.ReadAllTextAsync(outputPath) : null; + + Console.Error.WriteLine(); + Console.Error.WriteLine(DiffReporter.Report(previous, generated, Relative(outputPath))); + Console.Error.WriteLine(); + + var upToDate = previous is not null && string.Equals(previous, generated, StringComparison.Ordinal); + + if (options.ToStdout) + { + Console.Out.Write(generated); + } + else if (options.Check) + { + if (upToDate) + { + Log.Info($"{Relative(outputPath)} is up to date with {version}"); + } + else + { + Log.Info($"{Relative(outputPath)} is out of date with {version}; " + + "rerun without --check to update it"); + Log.Summary(); + return ExitOutOfDate; + } + } + else if (upToDate) + { + Log.Info($"{Relative(outputPath)} already matches {version}; left untouched"); + } + else + { + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); + await File.WriteAllTextAsync(outputPath, generated, new UTF8Encoding(false)); + Log.Info($"wrote {Relative(outputPath)} — {commands.Count} commands from {version}"); + } + + Log.Summary(); + return 0; + } + catch (Exception e) when (e is InvalidOperationException + or IOException + or HttpRequestException + or UnauthorizedAccessException + or System.Text.Json.JsonException) + { + Console.Error.WriteLine($"error: {e.Message}"); + if (e is HttpRequestException) + Console.Error.WriteLine( + "hint: the registry is reachable without auth; if egress is filtered, fetch the tarball " + + "elsewhere and pass it with --tarball, or unpack it and pass --source-dir."); + return ExitError; + } + finally + { + if (temp is not null && Directory.Exists(temp)) + { + if (options.KeepTemp) Log.Info($"kept unpacked package at {temp}"); + else TryDelete(temp); + } + } + } + + /// Resolves the package to read, downloading it unless an offline input was given. + private static async Task<(string PackageRoot, string Version)> AcquirePackageAsync( + Options options, Action registerTemp) + { + if (options.SourceDir is { } dir) + { + if (!Directory.Exists(dir)) + throw new InvalidOperationException($"--source-dir '{dir}' does not exist"); + + var root = UpmRegistry.FindPackageRoot(Path.GetFullPath(dir)); + return (root, VersionOf(options, root)); + } + + var temp = Path.Combine(Path.GetTempPath(), "CommandRefGen-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(temp); + registerTemp(temp); + + string tarball; + var version = options.Version; + + if (options.Tarball is { } local) + { + if (!File.Exists(local)) + throw new InvalidOperationException($"--tarball '{local}' does not exist"); + tarball = Path.GetFullPath(local); + } + else + { + using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; + http.DefaultRequestHeaders.UserAgent.ParseAdd("CommandRefGen/1.0"); + + var resolved = await UpmRegistry.ResolveAsync( + http, options.Registry, options.Package, string.IsNullOrEmpty(version) ? "latest" : version); + version = resolved.Version; + tarball = await UpmRegistry.DownloadTarballAsync(http, resolved.TarballUrl, temp); + } + + var unpacked = Path.Combine(temp, "unpacked"); + UpmRegistry.ExtractTarball(tarball, unpacked); + + var packageRoot = UpmRegistry.FindPackageRoot(unpacked); + return (packageRoot, options.Tarball is null ? version : VersionOf(options, packageRoot)); + } + + /// Prefers package.json for offline inputs; an explicit --version overrides it. + private static string VersionOf(Options options, string packageRoot) + { + var declared = UpmRegistry.ReadPackageVersion(packageRoot); + + if (!string.IsNullOrEmpty(options.Version) && options.Version != "latest") + { + if (declared is not null && declared != options.Version) + Log.Warn($"--version {options.Version} does not match the package.json version {declared}; " + + "labelling the document with the requested version"); + return options.Version; + } + + if (declared is not null) return declared; + + Log.Warn($"{packageRoot}/package.json has no version; labelling the document 'unknown'"); + return "unknown"; + } + + /// Walks up from the working directory to the repository root. + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(Directory.GetCurrentDirectory()); + for (var d = dir; d is not null; d = d.Parent) + if (Directory.Exists(Path.Combine(d.FullName, ".git")) || File.Exists(Path.Combine(d.FullName, ".git"))) + return d.FullName; + return dir.FullName; + } + + private static string Relative(string path) + { + var relative = Path.GetRelativePath(Directory.GetCurrentDirectory(), path); + return relative.StartsWith("..", StringComparison.Ordinal) ? path : relative; + } + + private static void TryDelete(string dir) + { + try + { + Directory.Delete(dir, recursive: true); + } + catch (IOException e) + { + Log.Warn($"could not remove the temp directory {dir}: {e.Message}"); + } + } +} diff --git a/tools/CommandRefGen/src/SemVer.cs b/tools/CommandRefGen/src/SemVer.cs new file mode 100644 index 0000000..d196dae --- /dev/null +++ b/tools/CommandRefGen/src/SemVer.cs @@ -0,0 +1,91 @@ +using System.Globalization; + +namespace CommandRefGen; + +/// +/// Minimal semver-2.0 precedence, enough to pick the highest entry out of a UPM +/// version listing (`0.4.0-exp.1` < `0.4.0-exp.2` < `0.4.0`). +/// Unparseable versions sort below every parseable one, ordered by ordinal string. +/// +internal sealed class SemVer : IComparable +{ + public string Raw { get; } + private readonly int[] _core; + private readonly string[] _pre; + private readonly bool _valid; + + private SemVer(string raw, int[] core, string[] pre, bool valid) + { + Raw = raw; + _core = core; + _pre = pre; + _valid = valid; + } + + public static SemVer Parse(string raw) + { + var body = raw; + + // Build metadata is ignored for precedence. + var plus = body.IndexOf('+'); + if (plus >= 0) body = body[..plus]; + + string[] pre = []; + var dash = body.IndexOf('-'); + if (dash >= 0) + { + pre = body[(dash + 1)..].Split('.'); + body = body[..dash]; + } + + var parts = body.Split('.'); + var core = new int[3]; + var valid = parts.Length is >= 1 and <= 3; + for (var i = 0; i < parts.Length && valid; i++) + { + if (int.TryParse(parts[i], NumberStyles.None, CultureInfo.InvariantCulture, out var n)) + core[i] = n; + else + valid = false; + } + + return new SemVer(raw, core, pre, valid); + } + + public int CompareTo(SemVer? other) + { + if (other is null) return 1; + + if (_valid != other._valid) return _valid ? 1 : -1; + if (!_valid) return string.CompareOrdinal(Raw, other.Raw); + + for (var i = 0; i < 3; i++) + { + var c = _core[i].CompareTo(other._core[i]); + if (c != 0) return c; + } + + // A release outranks any prerelease of the same core version. + if (_pre.Length == 0 || other._pre.Length == 0) + return (other._pre.Length == 0 ? 1 : 0) - (_pre.Length == 0 ? 1 : 0); + + for (var i = 0; i < Math.Min(_pre.Length, other._pre.Length); i++) + { + var a = _pre[i]; + var b = other._pre[i]; + var aNum = int.TryParse(a, NumberStyles.None, CultureInfo.InvariantCulture, out var an); + var bNum = int.TryParse(b, NumberStyles.None, CultureInfo.InvariantCulture, out var bn); + + int c; + if (aNum && bNum) c = an.CompareTo(bn); + else if (aNum != bNum) c = aNum ? -1 : 1; // numeric identifiers rank below alphanumeric + else c = string.CompareOrdinal(a, b); + + if (c != 0) return c; + } + + return _pre.Length.CompareTo(other._pre.Length); + } + + public override string ToString() => Raw; +} diff --git a/tools/CommandRefGen/src/SourceParser.cs b/tools/CommandRefGen/src/SourceParser.cs new file mode 100644 index 0000000..b1c11c6 --- /dev/null +++ b/tools/CommandRefGen/src/SourceParser.cs @@ -0,0 +1,419 @@ +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace CommandRefGen; + +/// +/// Reads `[CliCommand]` / `[CliArg]` declarations out of the package sources through the +/// Roslyn syntax tree. Attributes span multiple lines and defaults come from two places +/// (the attribute's DefaultValue, else the C# parameter default), which is why this is a +/// parse and not a set of regexes. +/// +internal sealed class SourceParser(int maxDescription) +{ + private static readonly Regex WhitespaceRun = new(@"\s+", RegexOptions.Compiled); + private static readonly Regex UnityVersionSymbol = + new(@"^UNITY_(\d+)(?:_(\d+))?(?:_(\d+))?_OR_NEWER$", RegexOptions.Compiled); + private static readonly Regex Identifier = + new(@"[A-Za-z_][A-Za-z0-9_]*", RegexOptions.Compiled); + + /// Parses every command in a package root, keyed by command name. + public List Parse(string packageRoot) + { + var byName = new Dictionary(StringComparer.Ordinal); + var files = 0; + + foreach (var file in EnumerateSources(packageRoot)) + { + files++; + var rel = Relative(packageRoot, file); + var text = File.ReadAllText(file); + + var symbols = CollectPreprocessorSymbols(text); + var regions = ConditionRegions(ParseTree(text, [])); + + // Pass A defines every symbol seen in an #if, so version-gated commands + // (`#if UNITY_6000_7_OR_NEWER`) land in the tree instead of in disabled text. + foreach (var c in ReadCommands(ParseTree(text, symbols), rel, regions)) + Merge(byName, c); + + // Pass B defines nothing, which is the only way to reach `#else` and `#if !SYMBOL` + // branches. Commands present in both passes are unconditional. + if (symbols.Count > 0) + foreach (var c in ReadCommands(ParseTree(text, []), rel, regions)) + Merge(byName, c); + } + + foreach (var fixture in FixtureCommands.Where(byName.ContainsKey)) + Log.Warn($"'{fixture}' is a test-assembly registration fixture but was found in " + + $"{byName[fixture].SourcePath}; check the Tests/ exclusion"); + + Log.Info($"parsed {files} source file(s), found {byName.Count} [CliCommand] method(s)"); + return byName.Values.OrderBy(c => c.Name, StringComparer.Ordinal).ToList(); + } + + // ---- source discovery ------------------------------------------------- + + private static IEnumerable EnumerateSources(string root) => + Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories) + .Where(f => !IsExcluded(root, f)) + .OrderBy(f => f, StringComparer.Ordinal); + + private static bool IsExcluded(string root, string file) + { + var dirs = Segments(Relative(root, file))[..^1]; + + for (var i = 0; i < dirs.Length; i++) + { + // Trailing-~ directories are invisible to Unity's importer. + if (dirs[i].EndsWith('~')) return true; + + // The package's Tests/ assembly holds registration fixtures (log_editor, + // test_types, test_structured), not real commands. A `Commands/Tests/` category + // directory is a different thing — that is where run_tests and friends live. + if (!dirs[i].Equals("Tests", StringComparison.OrdinalIgnoreCase)) continue; + if (i > 0 && dirs[i - 1].Equals("Commands", StringComparison.Ordinal)) continue; + return true; + } + + return false; + } + + /// Registration fixtures from the package's test assembly; never real commands. + private static readonly string[] FixtureCommands = ["log_editor", "test_types", "test_structured"]; + + private static string Relative(string root, string file) => + Path.GetRelativePath(root, file).Replace('\\', '/'); + + private static string[] Segments(string relPath) => relPath.Split('/'); + + /// + /// The category directory a source file sits in: the segment right after the last + /// `Commands/` directory, e.g. `Editor/Commands/Scenes/SceneCommands.cs` -> `Scenes`. + /// Null when the file sits directly in `Commands/` or outside one entirely. + /// + public static string? CategoryDirOf(string relPath) + { + var segs = Segments(relPath); + for (var i = segs.Length - 2; i >= 0; i--) + { + if (!segs[i].Equals("Commands", StringComparison.Ordinal)) continue; + return i + 1 <= segs.Length - 2 ? segs[i + 1] : null; + } + return null; + } + + // ---- preprocessor ----------------------------------------------------- + + private static SyntaxTree ParseTree(string text, IEnumerable symbols) => + CSharpSyntaxTree.ParseText(text, new CSharpParseOptions( + LanguageVersion.Latest, DocumentationMode.None, SourceCodeKind.Regular, symbols)); + + private static IEnumerable Directives(SyntaxTree tree) + { + var d = ((CSharpSyntaxNode)tree.GetRoot()).GetFirstDirective(); + while (d is not null) + { + yield return d; + d = d.GetNextDirective(); + } + } + + private static List CollectPreprocessorSymbols(string text) + { + var names = new SortedSet(StringComparer.Ordinal); + foreach (var d in Directives(ParseTree(text, []))) + { + var cond = d switch + { + IfDirectiveTriviaSyntax f => f.Condition, + ElifDirectiveTriviaSyntax e => e.Condition, + _ => null, + }; + if (cond is null) continue; + foreach (var id in cond.DescendantNodesAndSelf().OfType()) + names.Add(id.Identifier.ValueText); + } + return names.ToList(); + } + + /// Text spans covered by each `#if`/`#elif`/`#else` branch, with its condition. + private static List<(TextSpan Span, string Condition)> ConditionRegions(SyntaxTree tree) + { + var regions = new List<(TextSpan, string)>(); + var stack = new Stack<(int Start, string Condition)>(); + + foreach (var d in Directives(tree)) + { + switch (d) + { + case IfDirectiveTriviaSyntax f: + stack.Push((f.FullSpan.End, f.Condition.ToString().Trim())); + break; + case ElifDirectiveTriviaSyntax e: + Close(e.FullSpan.Start); + stack.Push((e.FullSpan.End, e.Condition.ToString().Trim())); + break; + case ElseDirectiveTriviaSyntax el: + var previous = Close(el.FullSpan.Start); + stack.Push((el.FullSpan.End, previous is null ? "else" : $"!({previous})")); + break; + case EndIfDirectiveTriviaSyntax end: + Close(end.FullSpan.Start); + break; + } + } + + return regions; + + string? Close(int end) + { + if (stack.Count == 0) return null; + var top = stack.Pop(); + regions.Add((TextSpan.FromBounds(top.Start, Math.Max(top.Start, end)), top.Condition)); + return top.Condition; + } + } + + /// + /// Splits enclosing conditions into the highest Unity version gate and the rest. + /// + private static (string? MinUnityVersion, List Other) ClassifyConditions(IEnumerable conditions) + { + string? version = null; + var other = new List(); + + foreach (var condition in conditions) + { + // `A && B` gates independently; anything with ! or || is reported verbatim. + string[] parts = condition.Contains('!') || condition.Contains("||") + ? [condition] + : condition.Split("&&", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + + foreach (var part in parts) + { + var m = UnityVersionSymbol.Match(part.Trim('(', ')', ' ')); + if (!m.Success) + { + if (!other.Contains(part)) other.Add(part); + continue; + } + + var v = string.Join('.', m.Groups.Cast().Skip(1).Where(g => g.Success).Select(g => g.Value)); + if (version is null || SemVer.Parse(v).CompareTo(SemVer.Parse(version)) > 0) version = v; + } + } + + return (version, other); + } + + /// The Unity symbol a version string came from, for the generated note. + public static string UnitySymbolFor(string version) => + "UNITY_" + version.Replace('.', '_') + "_OR_NEWER"; + + // ---- command extraction ---------------------------------------------- + + private IEnumerable ReadCommands( + SyntaxTree tree, string relPath, List<(TextSpan Span, string Condition)> regions) + { + var category = CategoryDirOf(relPath); + + foreach (var method in tree.GetRoot().DescendantNodes().OfType()) + { + var attribute = Attribute(method.AttributeLists, "CliCommand"); + if (attribute is null) continue; + + var line = tree.GetLineSpan(method.Span).StartLinePosition.Line + 1; + var where = $"{relPath}:{line}"; + var args = new AttributeArgs(attribute); + + if (!Literals.TryEvaluate(args.Get(0, "name"), out var nameValue) + || nameValue is not string name || name.Length == 0) + { + Log.Warn($"{where}: [CliCommand] name is not a literal string; skipping this method"); + continue; + } + + var (minUnity, conditions) = ClassifyConditions( + regions.Where(r => r.Span.Contains(method.Span.Start)).Select(r => r.Condition)); + + yield return new CommandInfo + { + Name = name, + Description = Describe(args.Get(1, "description"), $"{where}: command '{name}'"), + MainThreadRequired = Flag(args.Named("MainThreadRequired"), true, where, name), + RuntimeOnly = Flag(args.Named("RuntimeOnly"), false, where, name), + CategoryDir = category, + MinUnityVersion = minUnity, + Conditions = conditions, + SourcePath = relPath, + SourceLine = line, + Args = ReadArgs(method, where, name), + }; + } + } + + private List ReadArgs(MethodDeclarationSyntax method, string where, string command) + { + var result = new List(); + var unannotated = new List(); + + foreach (var parameter in method.ParameterList.Parameters) + { + var attribute = Attribute(parameter.AttributeLists, "CliArg"); + if (attribute is null) + { + unannotated.Add(parameter.Identifier.ValueText); + continue; + } + + var args = new AttributeArgs(attribute); + var name = Literals.TryEvaluate(args.Get(0, "name"), out var n) && n is string s && s.Length > 0 + ? s + : parameter.Identifier.ValueText; + + // The attribute's DefaultValue wins; otherwise the C# parameter default applies. + var defaultExpr = args.Named("DefaultValue") ?? parameter.Default?.Value; + object? defaultValue = null; + if (defaultExpr is not null) + { + if (defaultExpr.IsKind(SyntaxKind.DefaultLiteralExpression) + || defaultExpr is DefaultExpressionSyntax) + { + defaultValue = parameter.Type is null ? null : Literals.DefaultOf(parameter.Type); + } + else if (!Literals.TryEvaluate(defaultExpr, out defaultValue)) + { + var text = WhitespaceRun.Replace(defaultExpr.ToString(), " "); + Log.Warn($"{where}: '{command}' argument '{name}' has a non-constant default `{text}`; " + + "emitting it verbatim"); + defaultValue = new RawDefault(text); + } + } + + var required = Literals.TryEvaluate(args.Named("Required") ?? args.Named("IsRequired"), out var r) + && r is bool explicitly + ? explicitly + : defaultExpr is null; + + result.Add(new CliArgInfo + { + Name = name, + Type = parameter.Type is null ? "Object" : Literals.FormatType(parameter.Type), + Default = Literals.FormatDefault(defaultValue), + Required = required, + Description = Describe(args.Get(1, "description"), $"{where}: '{command}' argument '{name}'"), + }); + } + + if (unannotated.Count > 0) + Log.Warn($"{where}: '{command}' has parameter(s) without [CliArg]: " + + $"{string.Join(", ", unannotated)}; they are left out of the reference"); + + return result; + } + + /// A default the parser could not fold; rendered verbatim. + private sealed record RawDefault(string Text) + { + public override string ToString() => Text; + } + + private static AttributeSyntax? Attribute(SyntaxList lists, string name) => + lists.SelectMany(l => l.Attributes).FirstOrDefault(a => + { + var simple = Literals.SimpleName(a.Name) ?? ""; + if (simple.EndsWith("Attribute", StringComparison.Ordinal)) + simple = simple[..^"Attribute".Length]; + return simple.Equals(name, StringComparison.Ordinal); + }); + + private static bool Flag(ExpressionSyntax? expr, bool fallback, string where, string command) + { + if (expr is null) return fallback; + if (Literals.TryEvaluate(expr, out var v) && v is bool b) return b; + Log.Warn($"{where}: '{command}' has a non-constant flag `{expr}`; assuming {fallback}"); + return fallback; + } + + private string Describe(ExpressionSyntax? expr, string what) + { + if (expr is null) return ""; + + if (!Literals.TryEvaluate(expr, out var value) || value is not string text) + { + Log.Warn($"{what}: description is not a constant string; using its source text"); + text = expr.ToString(); + } + + // Collapse newlines and whitespace runs: a multi-line description must not break the + // markdown list layout. Never shortened below the emergency ceiling. + var description = WhitespaceRun.Replace(text, " ").Trim(); + + if (description.Length > maxDescription) + { + Log.Warn($"{what}: description is {description.Length} chars, over the " + + $"{maxDescription}-char ceiling; truncated (raise --max-description to keep it whole)"); + description = description[..maxDescription].TrimEnd() + "…"; + } + + return description; + } + + // ---- duplicate resolution -------------------------------------------- + + private static void Merge(Dictionary map, CommandInfo command) + { + if (!map.TryGetValue(command.Name, out var existing)) + { + map[command.Name] = command; + return; + } + + if (existing.SourcePath != command.SourcePath) + { + Log.Warn($"command '{command.Name}' is declared twice: " + + $"{existing.SourcePath}:{existing.SourceLine} and " + + $"{command.SourcePath}:{command.SourceLine}; keeping the first"); + return; + } + + // Same declaration seen again by the second preprocessor pass. + if (existing.SourceLine == command.SourceLine) return; + + // Two declarations in the same file means two preprocessor branches, so the command + // exists whichever branch compiles: drop the gate rather than claim a version floor. + if (Gates(command) == 0) map[command.Name] = command; + else if (Gates(existing) > 0) map[command.Name] = existing with { MinUnityVersion = null, Conditions = [] }; + + static int Gates(CommandInfo c) => c.Conditions.Count + (c.MinUnityVersion is null ? 0 : 1); + } + + /// Positional and named attribute arguments, addressable either way. + private sealed class AttributeArgs + { + private readonly List _positional = []; + private readonly Dictionary _named = new(StringComparer.OrdinalIgnoreCase); + + public AttributeArgs(AttributeSyntax attribute) + { + if (attribute.ArgumentList is null) return; + + foreach (var argument in attribute.ArgumentList.Arguments) + { + var name = argument.NameEquals?.Name.Identifier.ValueText + ?? argument.NameColon?.Name.Identifier.ValueText; + if (name is null) _positional.Add(argument.Expression); + else _named[name] = argument.Expression; + } + } + + public ExpressionSyntax? Named(string name) => _named.GetValueOrDefault(name); + + public ExpressionSyntax? Get(int index, string name) => + Named(name) ?? (index < _positional.Count ? _positional[index] : null); + } +} diff --git a/tools/CommandRefGen/src/UpmRegistry.cs b/tools/CommandRefGen/src/UpmRegistry.cs new file mode 100644 index 0000000..5d233db --- /dev/null +++ b/tools/CommandRefGen/src/UpmRegistry.cs @@ -0,0 +1,111 @@ +using System.Formats.Tar; +using System.IO.Compression; +using System.Text.Json; + +namespace CommandRefGen; + +/// +/// Reads the public UPM registry (no auth) and unpacks package tarballs. +/// +internal static class UpmRegistry +{ + /// Resolves a version to its tarball URL. may be "latest". + public static async Task<(string Version, string TarballUrl)> ResolveAsync( + HttpClient http, string registry, string package, string requested) + { + var url = $"{registry}/{package}"; + Log.Info($"registry: GET {url}"); + + await using var stream = await http.GetStreamAsync(url); + using var doc = await JsonDocument.ParseAsync(stream); + + if (!doc.RootElement.TryGetProperty("versions", out var versions) + || versions.ValueKind != JsonValueKind.Object) + throw new InvalidOperationException($"{url}: no 'versions' object in the registry response"); + + var names = versions.EnumerateObject().Select(p => p.Name).ToList(); + if (names.Count == 0) + throw new InvalidOperationException($"{url}: 'versions' is empty"); + + var version = requested is "latest" + ? names.OrderBy(SemVer.Parse).Last() + : requested; + + if (!versions.TryGetProperty(version, out var entry)) + throw new InvalidOperationException( + $"version '{version}' not found in {url}. Available: {string.Join(", ", names.OrderBy(SemVer.Parse))}"); + + if (!entry.TryGetProperty("dist", out var dist) + || !dist.TryGetProperty("tarball", out var tarball) + || tarball.GetString() is not { Length: > 0 } tarballUrl) + throw new InvalidOperationException($"version '{version}' has no dist.tarball in {url}"); + + if (requested is "latest") + Log.Info($"registry: latest of {names.Count} versions is {version}"); + + return (version, tarballUrl); + } + + public static async Task DownloadTarballAsync(HttpClient http, string tarballUrl, string destDir) + { + Log.Info($"registry: GET {tarballUrl}"); + var path = Path.Combine(destDir, "package.tgz"); + + await using var response = await http.GetStreamAsync(tarballUrl); + await using var file = File.Create(path); + await response.CopyToAsync(file); + + return path; + } + + /// Unpacks a gzipped tar into . + public static void ExtractTarball(string tarballPath, string destDir) + { + Directory.CreateDirectory(destDir); + using var file = File.OpenRead(tarballPath); + using var gzip = new GZipStream(file, CompressionMode.Decompress); + // TarFile rejects entries that would escape destDir. + TarFile.ExtractToDirectory(gzip, destDir, overwriteFiles: true); + } + + /// + /// Finds the directory holding the package's package.json. UPM tarballs nest everything + /// under `package/`, but an unpacked directory passed via --source-dir may be the root itself. + /// + public static string FindPackageRoot(string dir) + { + if (File.Exists(Path.Combine(dir, "package.json"))) + return dir; + + var nested = Directory.EnumerateDirectories(dir) + .Where(d => File.Exists(Path.Combine(d, "package.json"))) + .OrderBy(d => d, StringComparer.Ordinal) + .ToList(); + + return nested.Count switch + { + 1 => nested[0], + 0 => throw new InvalidOperationException($"no package.json found in '{dir}' or its immediate subdirectories"), + _ => throw new InvalidOperationException( + $"several packages found under '{dir}': {string.Join(", ", nested.Select(Path.GetFileName))}"), + }; + } + + /// Reads the version field out of a package root's package.json, if present. + public static string? ReadPackageVersion(string packageRoot) + { + var path = Path.Combine(packageRoot, "package.json"); + if (!File.Exists(path)) return null; + + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + return doc.RootElement.TryGetProperty("version", out var v) ? v.GetString() : null; + } + catch (JsonException e) + { + Log.Warn($"{path}: unreadable ({e.Message})"); + return null; + } + } +} diff --git a/tools/CommandRefGen/tests/expected/editor-commands.md b/tools/CommandRefGen/tests/expected/editor-commands.md new file mode 100644 index 0000000..c9a36b2 --- /dev/null +++ b/tools/CommandRefGen/tests/expected/editor-commands.md @@ -0,0 +1,106 @@ + + +# Editor command reference + +Generated by `tools/CommandRefGen` from the `com.unity.pipeline` package source, version `9.9.9-fixture.1` — 16 commands, read from the `[CliCommand]`/`[CliArg]` attributes of the package's C# sources rather than from a live editor. The file therefore covers every command the package declares, on every Unity version it supports. + +**This file is more complete than `unity command`.** The live listing is dynamic (package version, project code, optional packages, project-defined `[CliCommand]` methods), and it filters out commands whose attribute sets `RuntimeOnly = true` — those 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 [RuntimeOnly commands](#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. The reverse also holds: a project's own `[CliCommand]` methods are listed by the CLI but are not documented here, because this reference covers the package surface only. + +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. A command the package compiles only on newer Unity versions says so in its entry — it does not exist on older ones, and calling it there fails with exit code 6. + +## Contents + +[Capture](#capture) · [Scenes](#scenes) · [Tests](#tests) · [Widgets](#widgets) · [RuntimeOnly commands](#runtimeonly-commands-hidden-from-the-listing) + +## Capture + +### capture_editor_element +Capture a UI Toolkit VisualElement from an editor panel to a PNG. **Unity 6000.7+ only** — the command is compiled under `#if UNITY_6000_7_OR_NEWER` and does not exist on older versions. +- `selector`\* String — Element selector: '#name', '.class', or a type name. +- `output` String (default "") — Output PNG path. + +### capture_game_view +Render a camera to a PNG. +- `width` Int32 (default 1280) — Output width in px. +- `height` Int32 (default 720) — Output height in px. +- `exposure` Single (default -3.40282347e+38) — Exposure override. Defaults to Unity's current value. +- `save_path` String — Project-relative PNG path. + +### capture_pipeline_debug +Dump render-graph debug data for the active pipeline. **Unity 6000.7+ only** — the command is compiled under `#if UNITY_6000_7_OR_NEWER` and does not exist on older versions. **Conditional** — the command is compiled only where `ENABLE_CAPTURE_PIPELINE` holds. +- *(no arguments)* + +### screenshot +Write a screenshot of the focused window. *(works while main thread is busy)* +- `path`\* String — Output path. + +## Scenes + +### list_open_scenes +List every open scene. Reports name, path, and isDirty for each. +- *(no arguments)* + +### open_scene +Open a scene by asset path, replacing the current one. +- `path`\* String — Project-relative scene path, e.g. Assets/Scenes/Main.unity +- `additive` Boolean (default false) — Load the scene additively instead of replacing the open one. + +### save_scene +Save the open scene. +- `path` String (default "") — Where to save. Defaults to the scene's current path. +- `mode`\* Int32 (default 0) — Save mode. + +### scene_status +Report scene load state. *(works while main thread is busy)* +- `paths` String[] — Scene paths to report on. Omit for every open scene. + +### set_active_scene +Set the active scene. Affects where new objects are created. +- `name`\* String — Scene name. + +## Tests + +### run_tests +Run the project's tests. Async: poll test_status. +- `mode` String (default "editmode") — editmode | playmode +- `filter` String (default "") — Test name filter. + +### test_status +Report the state of the last test run. *(works while main thread is busy)* +- *(no arguments)* + +## Widgets + +### widget_poke +Poke a widget. +- `id`\* Int32 (default -1) — Widget id. + +## RuntimeOnly commands (hidden from the listing) + +The `[CliCommand]` attribute of every command below sets `RuntimeOnly = true`, so `unity command` neither lists them nor shows their schemas — these entries, read out of the `com.unity.pipeline` `9.9.9-fixture.1` package source, are the only schema source for them. They execute against the editor server despite the flag, and against Player connections (`--runtime ` / `--runtime-path `). All of them require the main thread. + +### 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 + +### 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). +- `key`\* String — Input System Key name, e.g. Space, W, Enter, LeftArrow +- `action` String (default "press") — down | up | press (down+up) + +### simulate_pointer +Simulate a mouse/pointer event at screen coordinates (Input System). **Conditional** — the command is compiled only where `ENABLE_INPUT_SYSTEM` holds. **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) diff --git a/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Capture/CaptureCommands.cs b/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Capture/CaptureCommands.cs new file mode 100644 index 0000000..6fd5fe8 --- /dev/null +++ b/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Capture/CaptureCommands.cs @@ -0,0 +1,39 @@ +using Unity.Pipeline.Commands; + +namespace Fixture.Editor.Commands +{ + public static class CaptureCommands + { + // float.MinValue is the package's "leave Unity's own value alone" sentinel; the listing + // renders it as -3.40282347e+38, and so must the generator. + [CliCommand("capture_game_view", "Render a camera to a PNG.")] + public static string CaptureGameView( + [CliArg("width", "Output width in px.")] int width = 1280, + [CliArg("height", "Output height in px.")] int height = 720, + [CliArg("exposure", "Exposure override. Defaults to Unity's current value.")] + float exposure = float.MinValue, + [CliArg("save_path", "Project-relative PNG path.")] string savePath = null) => ""; + +#if UNITY_6000_7_OR_NEWER + // Version-gated: the generator must mark the Unity floor instead of presenting this as + // universally available. Note the argument name falls back to the C# parameter name. + [CliCommand("capture_editor_element", "Capture a UI Toolkit VisualElement from an editor panel to a PNG.")] + public static string CaptureEditorElement( + [CliArg(Description = "Element selector: '#name', '.class', or a type name.")] string selector, + [CliArg("output", "Output PNG path.")] string output = "") => ""; +#endif + +#if UNITY_6000_7_OR_NEWER && ENABLE_CAPTURE_PIPELINE + // Two gates at once: a Unity floor plus a plain feature symbol. + [CliCommand("capture_pipeline_debug", "Dump render-graph debug data for the active pipeline.")] + public static string CapturePipelineDebug() => ""; +#endif + + // A parameter with no [CliArg] is not part of the CLI surface; the generator leaves it + // out and says so on stderr. + [CliCommand("screenshot", "Write a screenshot of the focused window.", MainThreadRequired = false)] + public static string Screenshot( + [CliArg("path", "Output path.")] string path, + System.Threading.CancellationToken cancellation = default) => path; + } +} diff --git a/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Scenes/SceneCommands.cs b/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Scenes/SceneCommands.cs new file mode 100644 index 0000000..7e103a0 --- /dev/null +++ b/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Scenes/SceneCommands.cs @@ -0,0 +1,49 @@ +using Unity.Pipeline.Commands; + +namespace Fixture.Editor.Commands +{ + public static class SceneCommands + { + // Baseline: attribute split over several lines, a required argument with no default, + // and an optional one whose default comes from the C# parameter. + [CliCommand( + "open_scene", + "Open a scene by asset path, replacing the current one.")] + public static string OpenScene( + [CliArg("path", "Project-relative scene path, e.g. Assets/Scenes/Main.unity")] + string path, + [CliArg("additive", "Load the scene additively instead of replacing the open one.")] + bool additive = false) + { + return path + additive; + } + + // The attribute's DefaultValue wins over the C# parameter default, and Required is set + // explicitly on an argument that nonetheless has a default. + [CliCommand("save_scene", "Save the open scene.")] + public static string SaveScene( + [CliArg("path", "Where to save. Defaults to the scene's current path.", DefaultValue = "")] + string path = "unused", + [CliArg("mode", "Save mode.", Required = true)] int mode = 0) + { + return path + mode; + } + + // A verbatim, multi-line description must collapse to one line, and a description built + // by concatenation must be folded rather than dumped as source text. + [CliCommand("list_open_scenes", @"List every open scene. + Reports name, path, + and isDirty for each.")] + public static string ListOpenScenes() => ""; + + [CliCommand("set_active_scene", "Set the active scene. " + "Affects where new objects are created.")] + public static string SetActiveScene( + [CliArg("name", "Scene name.")] string name) => name; + + // MainThreadRequired = false, and an argument list carrying an array type. + [CliCommand("scene_status", "Report scene load state.", MainThreadRequired = false)] + public static string SceneStatus( + [CliArg("paths", "Scene paths to report on. Omit for every open scene.")] + string[] paths = null) => ""; + } +} diff --git a/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Tests/TestRunCommands.cs b/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Tests/TestRunCommands.cs new file mode 100644 index 0000000..980301d --- /dev/null +++ b/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Tests/TestRunCommands.cs @@ -0,0 +1,17 @@ +using Unity.Pipeline.Commands; + +namespace Fixture.Editor.Commands +{ + // A `Commands/Tests/` directory is a category of real commands (run_tests and friends) and + // must survive the Tests/ exclusion, which only targets the package's test assembly. + public static class TestRunCommands + { + [CliCommand("run_tests", "Run the project's tests. Async: poll test_status.")] + public static string RunTests( + [CliArg("mode", "editmode | playmode")] string mode = "editmode", + [CliArg("filter", "Test name filter.")] string filter = "") => ""; + + [CliCommand("test_status", "Report the state of the last test run.", MainThreadRequired = false)] + public static string TestStatus() => ""; + } +} diff --git a/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Widgets/WidgetCommands.cs b/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Widgets/WidgetCommands.cs new file mode 100644 index 0000000..9db3461 --- /dev/null +++ b/tools/CommandRefGen/tests/fixture-package/Editor/Commands/Widgets/WidgetCommands.cs @@ -0,0 +1,13 @@ +using Unity.Pipeline.Commands; + +namespace Fixture.Editor.Commands +{ + // An unmapped category directory: the generator humanizes the name, places the section after + // the known ones, and warns so the mapping can be extended on purpose. + public static class WidgetCommands + { + [CliCommand("widget_poke", "Poke a widget.")] + public static string WidgetPoke( + [CliArg("id", "Widget id.", Required = true)] int id = -1) => ""; + } +} diff --git a/tools/CommandRefGen/tests/fixture-package/Runtime/Commands/Input/InputCommands.cs b/tools/CommandRefGen/tests/fixture-package/Runtime/Commands/Input/InputCommands.cs new file mode 100644 index 0000000..aaee1f9 --- /dev/null +++ b/tools/CommandRefGen/tests/fixture-package/Runtime/Commands/Input/InputCommands.cs @@ -0,0 +1,40 @@ +using Unity.Pipeline.Commands; + +namespace Fixture.Runtime.Commands +{ + public static class InputCommands + { + // RuntimeOnly commands are filtered out of the live listing, so they get their own + // section rather than their category's. + [CliCommand("simulate_key", "Simulate a keyboard key event (Input System).", RuntimeOnly = true)] + public static string SimulateKey( + [CliArg("key", "Input System Key name, e.g. Space, W, Enter, LeftArrow")] string key, + [CliArg("action", "down | up | press (down+up)")] string action = "press") => ""; + + [CliCommand("quit", "Gracefully quit the Unity application.", RuntimeOnly = true)] + public static string Quit( + [CliArg(Description = "Exit code for the application")] int exitCode = 0) => ""; + + // A RuntimeOnly command that is not a Unity version gate but a feature-symbol gate. +#if ENABLE_INPUT_SYSTEM + [CliCommand("simulate_pointer", "Simulate a mouse/pointer event at screen coordinates (Input System).", + RuntimeOnly = true)] + public static string SimulatePointer( + [CliArg("x", "Screen X in pixels (origin bottom-left)")] float x, + [CliArg("y", "Screen Y in pixels (origin bottom-left)")] float y, + [CliArg("action", "move | down | up | click (down+up)")] string action = "click") => ""; +#endif + + // Declared in both preprocessor branches, so it exists either way and must not be + // reported as gated. +#if UNITY_6000_7_OR_NEWER + [CliCommand("set_timescale", "Set the time scale for the application.", RuntimeOnly = true)] + public static string SetTimeScaleModern( + [CliArg("scale", "Time scale multiplier (0.0 to pause, 1.0 for normal speed)")] float scale) => ""; +#else + [CliCommand("set_timescale", "Set the time scale for the application.", RuntimeOnly = true)] + public static string SetTimeScaleLegacy( + [CliArg("scale", "Time scale multiplier (0.0 to pause, 1.0 for normal speed)")] float scale) => ""; +#endif + } +} diff --git a/tools/CommandRefGen/tests/fixture-package/Tests/Editor/RegistrationFixtures.cs b/tools/CommandRefGen/tests/fixture-package/Tests/Editor/RegistrationFixtures.cs new file mode 100644 index 0000000..668e0ef --- /dev/null +++ b/tools/CommandRefGen/tests/fixture-package/Tests/Editor/RegistrationFixtures.cs @@ -0,0 +1,22 @@ +using Unity.Pipeline.Commands; + +namespace Fixture.Tests +{ + // The package's test assembly registers throwaway commands to prove registration works. + // None of these may reach the reference document. + public static class RegistrationFixtures + { + [CliCommand("log_editor", "Test fixture: log a line from the editor.")] + public static string LogEditor( + [CliArg("message", "Anything.")] string message = "hi") => message; + + [CliCommand("test_types", "Test fixture: echo one argument of every supported type.")] + public static string TestTypes( + [CliArg("i", "int")] int i = 0, + [CliArg("f", "float")] float f = 0f, + [CliArg("b", "bool")] bool b = false) => ""; + + [CliCommand("test_structured", "Test fixture: return a structured payload.")] + public static string TestStructured() => ""; + } +} diff --git a/tools/CommandRefGen/tests/fixture-package/package.json b/tools/CommandRefGen/tests/fixture-package/package.json new file mode 100644 index 0000000..df109f6 --- /dev/null +++ b/tools/CommandRefGen/tests/fixture-package/package.json @@ -0,0 +1,6 @@ +{ + "name": "com.unity.pipeline.fixture", + "displayName": "CommandRefGen fixture package", + "version": "9.9.9-fixture.1", + "description": "Not a real package. Exercises the generator's parsing against a checked-in source tree, so the tool can be verified without registry access or a Unity install." +} diff --git a/tools/CommandRefGen/tests/run.sh b/tools/CommandRefGen/tests/run.sh new file mode 100755 index 0000000..d240569 --- /dev/null +++ b/tools/CommandRefGen/tests/run.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Verifies the generator against the checked-in fixture package: no registry access, no Unity. +# +# tools/CommandRefGen/tests/run.sh # compare against the golden file +# tools/CommandRefGen/tests/run.sh --update # rewrite the golden file from the current output +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +project="$here/.." +fixture="$here/fixture-package" +golden="$here/expected/editor-commands.md" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +run() { + dotnet run --project "$project" -- \ + --source-dir "$fixture" \ + --annotations "$project/annotations.json" \ + "$@" +} + +echo "== generating from the fixture package ==" +run --stdout >"$work/actual.md" 2>"$work/stderr.txt" || { + cat "$work/stderr.txt" >&2 + echo "FAIL: the generator exited non-zero" >&2 + exit 1 +} + +if [[ "${1:-}" == "--update" ]]; then + mkdir -p "$(dirname "$golden")" + cp "$work/actual.md" "$golden" + echo "updated $golden" + exit 0 +fi + +failures=0 +fail() { echo "FAIL: $*" >&2; failures=$((failures + 1)); } + +echo "== comparing against the golden file ==" +if ! diff -u "$golden" "$work/actual.md"; then + fail "output does not match $golden (rerun with --update if the change is intended)" +fi + +# Warnings the fixture is built to provoke; silence in any of these means a check stopped working. +echo "== checking the expected diagnostics ==" +expect_warning() { + grep -qF "$1" "$work/stderr.txt" || fail "expected a warning containing: $1" +} +expect_warning "'screenshot' has parameter(s) without [CliArg]: cancellation" +expect_warning "category directory 'Widgets' has no mapped section title" + +# Test-assembly fixtures must never reach the document. +for fixture_command in log_editor test_types test_structured; do + grep -qF "### $fixture_command" "$work/actual.md" \ + && fail "$fixture_command came from the excluded Tests/ assembly" +done + +# The same finding must not be reported twice just because a file is parsed once per +# preprocessor pass. +duplicates="$(grep '^warning:' "$work/stderr.txt" | sort | uniq -d || true)" +[[ -z "$duplicates" ]] || fail "duplicate warnings:"$'\n'"$duplicates" + +echo "== checking --check on an up-to-date file ==" +cp "$work/actual.md" "$work/output.md" +run --output "$work/output.md" --check >/dev/null 2>&1 \ + || fail "--check reported an up-to-date file as stale" + +echo "== checking --check on a stale file ==" +printf '### gone_command\nRemoved upstream.\n- *(no arguments)*\n' >>"$work/output.md" +set +e +run --output "$work/output.md" --check >/dev/null 2>"$work/check.txt" +status=$? +set -e +[[ $status -eq 3 ]] || fail "--check on a stale file exited $status, expected 3" +grep -qF "gone_command" "$work/check.txt" || fail "--check did not report the stale command" + +if [[ $failures -eq 0 ]]; then + echo "PASS" +else + echo "$failures check(s) failed" >&2 + exit 1 +fi 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 > 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');