diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f76499..d155b25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,8 +23,35 @@ The `version-bump` and `changelog-entry` CI jobs enforce this. ### Added +- New `winui.exe` AOT sidecar in `src/tools/winui-cli/` exposing a unified + `winui ` surface (`api`, `controls`, `project`, `analyzer`) over + `winmd-cli`, `winui-search`, and the embedded WinUI 3 / Windows App SDK Roslyn + analyzer. Intended for framework-agnostic hosts (e.g. `winappcli`) that want + one sidecar instead of bundling multiple exes. +- Committed JSON Schemas (Draft 2020-12) at `src/tools/winui-cli/schemas/*.schema.json`, + five total: one `winui.text-result.v1` shared by all verbs that wrap inner-CLI + text output, plus structured `winui.project.build.v1`, `winui.analyzer.info.v1`, + `winui.error.v1`, `winui.help.v1`. Auto-generated from `[WinUiJsonSchema]`-tagged + records in `src/tools/winui-cli/Schemas/JsonPayloads.cs`. The `schema` + discriminator in every payload is `const`-locked to its shape id; text-wrapper + consumers dispatch on a separate `verb` field (e.g. `"api.update"`, + `"controls.search"`). +- `scripts/build-tools.ps1 -CheckSchemaDrift` snapshots emitted schemas to a + staging directory and diffs against the committed copies — fails the build if + a record's shape changes without regenerating the on-disk schema. Intended for + CI. +- `winui project build` transiently injects the embedded analyzer via a temp + `Directory.Build.props` next to the `.csproj` (then cleans it up in `finally` + with retry-with-backoff), mirroring step 4a of the legacy `BuildAndRun.ps1`. + ### Changed +- `winmd-cli`: `api search` now applies `--max` to ambiguous-name results + (previously ignored, returning the full ambiguous set regardless). +- `winui-search` and `winmd-cli`: exposed runner entry points via small library + csproj wrappers (`winmd-lib`, `winui-search-lib`) so `winui.exe` can call them + in-process without spawning subprocesses. + ### Fixed ### Removed diff --git a/README.md b/README.md index 7417724..9d31206 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,8 @@ plugins/winui/ Copilot CLI plugin manifest + agent + skill files src/tools/ Source for the in-repo tools shipped with the skills winmd-cli/ Native-AOT WinRT/.NET metadata indexer (winmd.exe) winui-search/ Native-AOT search over WinUI Gallery + Toolkit (winui-search.exe) + winui-cli/ Native-AOT sidecar that subsumes the above behind a single + `winui ` surface (winui.exe), plus JSON-schema emitter winui-analyzer/ Microsoft.WindowsAppSDK.Analyzers Roslyn analyzer scripts/ Helper scripts (see scripts/build-tools.ps1) ``` @@ -151,6 +153,7 @@ Several skills ship helper binaries and PowerShell scripts that run under your u | **`Microsoft.WindowsAppSDK.Analyzers.dll`** (Roslyn analyzer) | [`src/tools/winui-analyzer/`](src/tools/winui-analyzer/) | Catches common WinUI 3 / WinAppSDK pitfalls at build time: UWP namespace leaks, `Window.Current`, `CoreDispatcher`, `WebView2` without `EnsureCoreWebView2Async`, raw `TabView` content, attached-property syntax bugs, removed ONNX GenAI APIs, the old field-backed `[ObservableProperty]` pattern, and more. Every rule ships at `Warning` severity (no `Error`s) and includes a `helpLinkUri`. Verified against source on every PR by the `analyzer-provenance` CI job. | Publish as the `Microsoft.WindowsAppSDK.Analyzers` NuGet package; skill stops shipping the prebuilt DLL and projects pick it up via ``. | | **`winmd.exe`** (winmd-cli) | [`src/tools/winmd-cli/`](src/tools/winmd-cli/) | Native-AOT WinRT/.NET metadata indexer. The agent uses it to verify an API actually exists and has the signature it thinks it does — *before* writing code that won't compile. Reads `.winmd` and managed `.dll` metadata from NuGet, the Windows SDK, and WinAppSDK and returns the same XML doc text Visual Studio IntelliSense uses. | Publish as a `dotnet tool` on NuGet, or fold relevant subcommands into [`winappcli`](https://github.com/microsoft/winappcli). | | **`winui-search.exe`** (winui-search) | [`src/tools/winui-search/`](src/tools/winui-search/) | Native-AOT BM25 search over [WinUI Gallery](https://github.com/microsoft/WinUI-Gallery) and [CommunityToolkit/Windows](https://github.com/CommunityToolkit/Windows) scenarios. Lets the agent see real shipping samples for a control before writing a single line of XAML. Embedded JSON snapshots ship offline; live refresh via `winui-search update`. **Distributed today as a prebuilt unsigned exe inside the `winui-design` skill payload**, verified on every PR by the `winui-search-provenance` CI job. | Same as `winmd-cli` — `dotnet tool`, fold into `winappcli`, or expose over a small MCP server. | +| **`winui.exe`** (winui-cli sidecar) | [`src/tools/winui-cli/`](src/tools/winui-cli/) | Native-AOT sidecar that exposes one `winui ` surface over the other in-repo tools (`winui api search`/`members`/…, `winui controls search`/`detail`/…, `winui project build`, `winui analyzer info`). `winui project build` transiently injects the embedded `Microsoft.WindowsAppSDK.Analyzers` for the duration of a single build — the project tree is never modified. Designed to be sideloaded by framework-agnostic hosts (e.g. `winappcli`) so they don't have to bundle four separate exes. Every JSON-mode payload is versioned by a schema in [`src/tools/winui-cli/schemas/`](src/tools/winui-cli/schemas/), generated from source via the `winui-schema-emit` tool and checked for drift via `./scripts/build-tools.ps1 -CheckSchemaDrift`. | Publish as a `dotnet tool` on NuGet, or fold into [`winappcli`](https://github.com/microsoft/winappcli). | | **`BuildAndRun.ps1`** | [`plugins/winui/skills/winui-dev-workflow/BuildAndRun.ps1`](plugins/winui/skills/winui-dev-workflow/BuildAndRun.ps1) | Picks MSBuild over `dotnet build` to work around the XAML-compiler diagnostic gap called out above. After a successful build it hands off to `winapp run`. | **Removed entirely once the next Windows App SDK release fixes the XAML compiler under `dotnet build`** — the skills will switch to `dotnet build` / `dotnet run` directly. | | **`Analyze-Session.ps1`** | [`plugins/winui/skills/winui-session-report/Analyze-Session.ps1`](plugins/winui/skills/winui-session-report/Analyze-Session.ps1) | Reads your local Copilot session events and produces a `session-report.md` for bug filing. **The report can include excerpts of your prompts, file paths, and command output — review it before sharing.** The skill prints a privacy notice when it runs. | Fold into the `copilot` CLI as a session-report subcommand, or publish as a `dotnet tool`. | @@ -159,7 +162,7 @@ If any of this is a deal-breaker for your environment, please [open an issue](ht ### Building the in-repo tools yourself ```powershell -# Build all three tools (AOT-publishes winmd-cli and winui-search), run the +# Build all tools (AOT-publishes winmd-cli, winui-search, and winui-cli), run the # analyzer test suite, and refresh both committed payloads (analyzer DLL inside # winui-dev-workflow, winui-search.exe inside winui-design). This is the same # build the pr-validation workflow runs in CI. @@ -171,6 +174,7 @@ Per-tool READMEs cover what they do and how to consume them in more detail: * [`src/tools/winui-analyzer/README.md`](src/tools/winui-analyzer/README.md) — analyzer + rule catalog * [`src/tools/winmd-cli/README.md`](src/tools/winmd-cli/README.md) — `winmd` CLI usage * [`src/tools/winui-search/README.md`](src/tools/winui-search/README.md) — `winui-search` CLI usage +* [`src/tools/winui-cli/README.md`](src/tools/winui-cli/README.md) — `winui` sidecar usage (noun/verb surface + JSON schemas) ## Pinning to a release diff --git a/plugins/winui/skills/winui-design/winui-search.exe b/plugins/winui/skills/winui-design/winui-search.exe index 8dd4fbe..bdb7da4 100644 Binary files a/plugins/winui/skills/winui-design/winui-search.exe and b/plugins/winui/skills/winui-design/winui-search.exe differ diff --git a/plugins/winui/skills/winui-dev-workflow/analyzer/Microsoft.WindowsAppSDK.Analyzers.dll b/plugins/winui/skills/winui-dev-workflow/analyzer/Microsoft.WindowsAppSDK.Analyzers.dll index ff43456..1c06485 100644 Binary files a/plugins/winui/skills/winui-dev-workflow/analyzer/Microsoft.WindowsAppSDK.Analyzers.dll and b/plugins/winui/skills/winui-dev-workflow/analyzer/Microsoft.WindowsAppSDK.Analyzers.dll differ diff --git a/scripts/build-tools.ps1 b/scripts/build-tools.ps1 index 6c70af5..3db76e0 100644 --- a/scripts/build-tools.ps1 +++ b/scripts/build-tools.ps1 @@ -2,12 +2,14 @@ <# .SYNOPSIS One-shot build for every C# tool in this repo, including the analyzer DLL - payload refresh that the winui-dev-workflow skill ships with. + payload refresh. .DESCRIPTION Builds and tests the WinUI 3 / Windows App SDK Roslyn analyzer, then - AOT-publishes winmd-cli and winui-search and refreshes the skill payload - folders that ship the resulting binaries. + AOT-publishes winmd-cli, winui-search, and the winui-cli sidecar, emits + JSON schemas for the winui-cli JSON payloads, and refreshes the skill / + plugin payload folders that ship the resulting binaries (analyzer DLL, + winui-search.exe, src/tools/winui-cli/schemas/). This script exists to give contributors one verb to run before opening a PR. The pr-validation.yml workflow will rebuild everything in CI @@ -23,8 +25,13 @@ .PARAMETER SkipPayloadRefresh Don't copy the freshly built artifacts into the - plugins/winui/skills/.../ payload folders. Default: payloads are - refreshed (this is what keeps CI provenance happy). + plugins/winui/skills/.../ payload folders. Default: + payloads are refreshed (this is what keeps CI provenance happy). + +.PARAMETER CheckSchemaDrift + Fail the script if the freshly-emitted src/tools/winui-cli/schemas/*.json set + (added / changed / removed files) does not match the committed copy. + Use in CI / pre-commit to catch stale schemas. Default: off. .EXAMPLE ./scripts/build-tools.ps1 @@ -33,17 +40,26 @@ .EXAMPLE ./scripts/build-tools.ps1 -SkipTests -SkipPayloadRefresh # Quick build only — skip tests and payload copy. Useful while iterating. + +.EXAMPLE + ./scripts/build-tools.ps1 -SkipTests -SkipPayloadRefresh -CheckSchemaDrift + # Verify committed winui-cli JSON schemas are still in sync with source. #> [CmdletBinding()] param( [string]$Configuration = 'Release', [switch]$SkipTests, - [switch]$SkipPayloadRefresh + [switch]$SkipPayloadRefresh, + [switch]$CheckSchemaDrift ) $ErrorActionPreference = 'Stop' $repoRoot = Split-Path -Parent $PSScriptRoot +$vsInstallerDir = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer' +if (Test-Path (Join-Path $vsInstallerDir 'vswhere.exe')) { + $env:PATH = "$vsInstallerDir;$env:PATH" +} function Step([string]$msg) { Write-Host "" @@ -115,6 +131,91 @@ if (-not $SkipPayloadRefresh) { Warn "skipping winui-search payload refresh (-SkipPayloadRefresh)" } +# -------------------- 4. winui-cli ------------------------------------------ + +$winuiProj = Join-Path $repoRoot 'src/tools/winui-cli/winui-cli.csproj' +$schemaEmitProj = Join-Path $repoRoot 'src/tools/winui-cli/SchemaGen/WinUi.SchemaEmit.csproj' + +Step "Building winui-cli ($Configuration)" +dotnet publish $winuiProj -c $Configuration -r win-x64 --self-contained true /p:PublishAot=true /p:StripSymbols=true --nologo +if ($LASTEXITCODE -ne 0) { throw "winui-cli build failed" } +Ok "winui-cli built" + +Step "Building winui schema emitter ($Configuration)" +dotnet build $schemaEmitProj -c $Configuration --nologo +if ($LASTEXITCODE -ne 0) { throw "winui schema emitter build failed" } +Ok "winui schema emitter built" + +Step "Emitting JSON schemas from winui-cli payloads" +$winuiManagedDll = Join-Path $repoRoot "src/tools/winui-cli/bin/$Configuration/net10.0/win-x64/winui.dll" +if (-not (Test-Path $winuiManagedDll)) { throw "Managed winui.dll not found at: $winuiManagedDll" } +$schemasOutDir = Join-Path $repoRoot 'src/tools/winui-cli/schemas' +$schemaEmitDll = Join-Path $repoRoot "src/tools/winui-cli/SchemaGen/bin/$Configuration/net10.0/winui-schema-emit.dll" + +# Always emit into a clean staging dir, then sync to the committed dir. +# This is what lets -CheckSchemaDrift detect deletions/renames: the committed +# dir is the baseline, and the staging dir is authoritative for what the +# emitter currently produces. +$stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) "winui-schemas-$([Guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Force -Path $stagingDir | Out-Null +try { + dotnet $schemaEmitDll $winuiManagedDll $stagingDir + if ($LASTEXITCODE -ne 0) { throw "schema emission failed" } + + if ($CheckSchemaDrift) { + $committedFiles = @{} + if (Test-Path $schemasOutDir) { + foreach ($f in Get-ChildItem -Path $schemasOutDir -Filter '*.json' -File) { + $committedFiles[$f.Name] = [System.IO.File]::ReadAllBytes($f.FullName) + } + } + $stagedFiles = @{} + foreach ($f in Get-ChildItem -Path $stagingDir -Filter '*.json' -File) { + $stagedFiles[$f.Name] = [System.IO.File]::ReadAllBytes($f.FullName) + } + + $driftReasons = @() + foreach ($name in $stagedFiles.Keys) { + if (-not $committedFiles.ContainsKey($name)) { + $driftReasons += "added: $name" + } elseif (-not [Linq.Enumerable]::SequenceEqual([byte[]]$committedFiles[$name], [byte[]]$stagedFiles[$name])) { + $driftReasons += "changed: $name" + } + } + foreach ($name in $committedFiles.Keys) { + if (-not $stagedFiles.ContainsKey($name)) { + $driftReasons += "removed: $name" + } + } + if ($driftReasons.Count -gt 0) { + Write-Host "" + Write-Host "ERROR: Schema drift detected." -ForegroundColor Red + foreach ($r in $driftReasons) { Write-Host " $r" -ForegroundColor Red } + Write-Host " Committed src/tools/winui-cli/schemas/ does not match the live winui-cli payload shape." -ForegroundColor Red + Write-Host " Run './scripts/build-tools.ps1' locally and commit the regenerated schemas." -ForegroundColor Red + throw "schema drift" + } + Ok "schemas in sync with payload (no drift)" + } + + # Sync staging -> committed: remove obsolete .json, copy current. + New-Item -ItemType Directory -Force -Path $schemasOutDir | Out-Null + $stagedNames = @{} + foreach ($f in Get-ChildItem -Path $stagingDir -Filter '*.json' -File) { + $stagedNames[$f.Name] = $true + Copy-Item $f.FullName (Join-Path $schemasOutDir $f.Name) -Force + } + foreach ($f in Get-ChildItem -Path $schemasOutDir -Filter '*.json' -File) { + if (-not $stagedNames.ContainsKey($f.Name)) { + Remove-Item $f.FullName -Force + } + } + Ok "schemas emitted: $schemasOutDir" +} +finally { + if (Test-Path $stagingDir) { Remove-Item $stagingDir -Recurse -Force } +} + # -------------------- Done -------------------------------------------------- Step "All tools built successfully" @@ -122,5 +223,8 @@ Write-Host " Analyzer payload: plugins/winui/skills/winui-dev-workflow/analyz Write-Host " AOT exes:" -ForegroundColor DarkGray Write-Host " src/tools/winmd-cli/bin/$Configuration/net10.0//publish/winmd.exe" -ForegroundColor DarkGray Write-Host " src/tools/winui-search/bin/$Configuration/net10.0//publish/winui-search.exe" -ForegroundColor DarkGray +Write-Host " src/tools/winui-cli/bin/$Configuration/net10.0/win-x64/publish/winui.exe" -ForegroundColor DarkGray Write-Host " Skill payloads:" -ForegroundColor DarkGray Write-Host " plugins/winui/skills/winui-design/winui-search.exe (refreshed)" -ForegroundColor DarkGray +Write-Host " Schemas:" -ForegroundColor DarkGray +Write-Host " src/tools/winui-cli/schemas/*.schema.json (auto-generated from [WinUiJsonSchema] records)" -ForegroundColor DarkGray diff --git a/src/tools/winmd-cli/Program.cs b/src/tools/winmd-cli/Program.cs index 0037860..26a4472 100644 --- a/src/tools/winmd-cli/Program.cs +++ b/src/tools/winmd-cli/Program.cs @@ -14,9 +14,26 @@ using System.Xml.Linq; [CompilerGenerated] -internal class Program +public static class WinMdCliRunner { - private static int Main(string[] args) + public static int Run(string[] args, TextWriter stdout, TextWriter stderr, bool json) + { + TextWriter oldOut = Console.Out; + TextWriter oldErr = Console.Error; + try + { + Console.SetOut(stdout); + Console.SetError(stderr); + return Run(args); + } + finally + { + Console.SetOut(oldOut); + Console.SetError(oldErr); + } + } + + public static int Run(string[] args) { if (args.Length == 0) { @@ -446,7 +463,25 @@ static string ResolveCacheDir(CliArgs cli) } if (files.Length == 1) { - return DeserializeManifest(files[0]); + ProjectManifest only = DeserializeManifest(files[0]); + if (only != null) + { + string cwd = Path.GetFullPath(cli.ProjectDir ?? Directory.GetCurrentDirectory()); + string? cwdName = FindProjectNameInDir(cwd); + bool cwdMatches = only.ProjectDir != null && only.ProjectDir.Equals(cwd, StringComparison.OrdinalIgnoreCase); + bool nameMatches = cwdName != null && (Path.GetFileNameWithoutExtension(files[0]).Equals(cwdName, StringComparison.OrdinalIgnoreCase) || Path.GetFileNameWithoutExtension(files[0]).StartsWith(cwdName + "_", StringComparison.OrdinalIgnoreCase)); + if (cwdMatches || nameMatches || cwdName == null) + { + if (!cwdMatches && cwdName == null) + { + Console.Error.WriteLine("Note: Using only cached project '" + Path.GetFileNameWithoutExtension(files[0]) + "' (no project detected in current directory)."); + } + return only; + } + Console.Error.WriteLine("Cached project '" + Path.GetFileNameWithoutExtension(files[0]) + "' does not match current directory. Run '" + WinMdInvocation.CommandPrefix + " update' to index this project, or pass --project."); + return null; + } + return null; } string text2 = FindProjectNameInDir(cli.ProjectDir ?? Directory.GetCurrentDirectory()); if (text2 != null) @@ -559,3 +594,8 @@ static int RunUpdate(CliArgs cli) } } } + +internal sealed class Program +{ + private static int Main(string[] args) => WinMdCliRunner.Run(args); +} diff --git a/src/tools/winmd-cli/QueryEngine.cs b/src/tools/winmd-cli/QueryEngine.cs index a04319f..8dcdfeb 100644 --- a/src/tools/winmd-cli/QueryEngine.cs +++ b/src/tools/winmd-cli/QueryEngine.cs @@ -18,7 +18,7 @@ public static int Search(string query, int maxResults, string cacheDir, ProjectM } if (manifest == null) { - Console.Error.WriteLine("Error: No project found. Run 'winmd update' first."); + Console.Error.WriteLine("Error: No project found. Run '" + WinMdInvocation.CommandPrefix + " update' first."); return 1; } List packageCacheDirs = GetPackageCacheDirs(cacheDir, manifest); @@ -112,7 +112,7 @@ public static int Search(string query, int maxResults, string cacheDir, ProjectM if (ambiguousGroups.Count > 0) { - foreach (var group in ambiguousGroups) + foreach (var group in ambiguousGroups.Take(maxResults)) { Console.WriteLine($"⚠️ AMBIGUOUS — '{group.Key}' found in multiple namespaces:"); Console.WriteLine(); @@ -163,7 +163,7 @@ public static int Members(string fullName, string cacheDir, ProjectManifest? man } if (manifest == null) { - Console.Error.WriteLine("Error: No project found. Run 'winmd update' first."); + Console.Error.WriteLine("Error: No project found. Run '" + WinMdInvocation.CommandPrefix + " update' first."); return 1; } int num = fullName.LastIndexOf('.'); @@ -299,7 +299,7 @@ public static int Types(string ns, string cacheDir, ProjectManifest? manifest) } if (manifest == null) { - Console.Error.WriteLine("Error: No project found. Run 'winmd update' first."); + Console.Error.WriteLine("Error: No project found. Run '" + WinMdInvocation.CommandPrefix + " update' first."); return 1; } string path = ns.Replace('.', '_') + ".json"; @@ -345,7 +345,7 @@ public static int Enums(string fullName, string cacheDir, ProjectManifest? manif } if (manifest == null) { - Console.Error.WriteLine("Error: No project found. Run 'winmd update' first."); + Console.Error.WriteLine("Error: No project found. Run '" + WinMdInvocation.CommandPrefix + " update' first."); return 1; } int num = fullName.LastIndexOf('.'); @@ -394,7 +394,7 @@ public static int Namespaces(string? filter, string cacheDir, ProjectManifest? m { if (manifest == null) { - Console.Error.WriteLine("Error: No project found. Run 'winmd update' first."); + Console.Error.WriteLine("Error: No project found. Run '" + WinMdInvocation.CommandPrefix + " update' first."); return 1; } List packageCacheDirs = GetPackageCacheDirs(cacheDir, manifest); @@ -430,7 +430,7 @@ public static int Packages(string cacheDir, ProjectManifest? manifest) { if (manifest == null) { - Console.Error.WriteLine("Error: No project found. Run 'winmd update' first."); + Console.Error.WriteLine("Error: No project found. Run '" + WinMdInvocation.CommandPrefix + " update' first."); return 1; } Console.WriteLine($"Packages for project '{manifest.ProjectName}' ({manifest.Packages.Count}):"); @@ -488,7 +488,7 @@ public static int Stats(string cacheDir, ProjectManifest? manifest) { if (manifest == null) { - Console.Error.WriteLine("Error: No project found. Run 'winmd update' first."); + Console.Error.WriteLine("Error: No project found. Run '" + WinMdInvocation.CommandPrefix + " update' first."); return 1; } int num = 0; @@ -536,7 +536,7 @@ public static int CheckProperty(string typeName, string propertyName, string cac } if (manifest == null) { - Console.Error.WriteLine("Error: No project found. Run 'winmd update' first."); + Console.Error.WriteLine("Error: No project found. Run '" + WinMdInvocation.CommandPrefix + " update' first."); return 1; } List packageCacheDirs = GetPackageCacheDirs(cacheDir, manifest); diff --git a/src/tools/winmd-cli/WinMdInvocation.cs b/src/tools/winmd-cli/WinMdInvocation.cs new file mode 100644 index 0000000..2c99284 --- /dev/null +++ b/src/tools/winmd-cli/WinMdInvocation.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Single source of truth for the user-facing command prefix used inside winmd +// error messages and usage strings. Defaults to "winmd" when invoked as the +// standalone winmd.exe; the winui-cli sidecar sets this to "winui api" before +// dispatching, so messages like "Run ' update' first." stay accurate +// across both invocation paths. +public static class WinMdInvocation +{ + public static string CommandPrefix { get; set; } = "winmd"; +} diff --git a/src/tools/winmd-lib/winmd-lib.csproj b/src/tools/winmd-lib/winmd-lib.csproj new file mode 100644 index 0000000..ca52cdb --- /dev/null +++ b/src/tools/winmd-lib/winmd-lib.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + true + WinUI.Cli.WinMd + + + + + diff --git a/src/tools/winui-cli/Commands/Analyzer/AnalyzerCommand.cs b/src/tools/winui-cli/Commands/Analyzer/AnalyzerCommand.cs new file mode 100644 index 0000000..09da1a2 --- /dev/null +++ b/src/tools/winui-cli/Commands/Analyzer/AnalyzerCommand.cs @@ -0,0 +1,12 @@ +namespace WinUi.Cli.Commands.Analyzer; + +internal sealed class AnalyzerCommand : CommandNode +{ + public AnalyzerCommand() : base("analyzer", "Inspect the embedded WinUI Roslyn analyzer payload") + { + Register(new InfoCommand()); + } + + public override string? TipLine => + "Tip: 'winui project build' auto-injects this analyzer transiently per build. 'analyzer info' shows the embedded version and rule summary."; +} diff --git a/src/tools/winui-cli/Commands/Analyzer/AnalyzerInjection.cs b/src/tools/winui-cli/Commands/Analyzer/AnalyzerInjection.cs new file mode 100644 index 0000000..61bf07c --- /dev/null +++ b/src/tools/winui-cli/Commands/Analyzer/AnalyzerInjection.cs @@ -0,0 +1,118 @@ +using System.Reflection; + +namespace WinUi.Cli.Commands.Analyzer; + +// Transient injection of the embedded WinUI analyzer for `winui project build`. +// +// Mirrors BuildAndRun.ps1 step 4a: extract the embedded analyzer DLL + .targets +// to a temp dir, then write a temp Directory.Build.props next to the .csproj +// that points MSBuild at them. Dispose tears both back down in finally so a +// subsequent vanilla `dotnet build` doesn't see a stray analyzer reference. +// +// If the project already owns a Directory.Build.props, we leave it alone — the +// user's file wins and the analyzer is silently skipped (same as BuildAndRun.ps1). +internal sealed class AnalyzerInjection : IDisposable +{ + private readonly string? _tempPropsFile; + private readonly string? _tempPayloadDir; + + private AnalyzerInjection(string? tempPropsFile, string? tempPayloadDir) + { + _tempPropsFile = tempPropsFile; + _tempPayloadDir = tempPayloadDir; + } + + public static AnalyzerInjection Prepare(string projectPath, GlobalOptions options) + { + if (!AnalyzerPayload.Available) + { + if (!options.Quiet && !options.Json) + Console.Error.WriteLine("--> Microsoft.WindowsAppSDK.Analyzers: skipped (embedded payload missing)"); + return new AnalyzerInjection(null, null); + } + + var projectFull = Path.GetFullPath(projectPath); + var projectDir = Path.GetDirectoryName(projectFull) ?? Directory.GetCurrentDirectory(); + var propsFile = Path.Combine(projectDir, "Directory.Build.props"); + + if (File.Exists(propsFile)) + { + if (!options.Quiet && !options.Json) + Console.Error.WriteLine("--> Microsoft.WindowsAppSDK.Analyzers: skipped (existing Directory.Build.props)"); + return new AnalyzerInjection(null, null); + } + + var payloadDir = Path.Combine(Path.GetTempPath(), "winui-analyzer-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(payloadDir); + try + { + AnalyzerPayload.ExtractTo(payloadDir); + var dllPath = Path.Combine(payloadDir, AnalyzerPayload.DllName); + var targetsPath = Path.Combine(payloadDir, AnalyzerPayload.TargetsName); + + var contents = $@" + + + + + +"; + File.WriteAllText(propsFile, contents); + if (!options.Quiet && !options.Json) + Console.Error.WriteLine("--> Microsoft.WindowsAppSDK.Analyzers: enabled"); + return new AnalyzerInjection(propsFile, payloadDir); + } + catch + { + // Best-effort cleanup if extraction or write failed; never block the build. + TryDeleteWithRetry(payloadDir, recursive: true); + if (!options.Quiet && !options.Json) + Console.Error.WriteLine("--> Microsoft.WindowsAppSDK.Analyzers: skipped (extraction failed)"); + return new AnalyzerInjection(null, null); + } + } + + public void Dispose() => Cleanup(null); + + // BENCH-4: MSBuild may still hold a handle on Directory.Build.props or the + // extracted analyzer DLL right after a failed post-build target. Retry with + // backoff; if still failing, surface a warning (non-JSON only) so the user + // knows to delete the stray file themselves. + public void Cleanup(GlobalOptions? options) + { + var propsLeak = _tempPropsFile != null && !TryDeleteWithRetry(_tempPropsFile, recursive: false); + var payloadLeak = _tempPayloadDir != null && !TryDeleteWithRetry(_tempPayloadDir, recursive: true); + + if (options is null || options.Json || options.Quiet) return; + if (propsLeak) + Console.Error.WriteLine($"--> Microsoft.WindowsAppSDK.Analyzers: warning — could not delete '{_tempPropsFile}'. Remove it manually."); + if (payloadLeak) + Console.Error.WriteLine($"--> Microsoft.WindowsAppSDK.Analyzers: warning — could not delete '{_tempPayloadDir}'."); + } + + private static bool TryDeleteWithRetry(string path, bool recursive) + { + // 3 tries with 100ms backoff. MSBuild can hold a handle on + // Directory.Build.props or the extracted analyzer DLL for a few ms after a + // failed post-build target exits. After the loop we return whether the + // path is gone — the caller surfaces a user-visible warning if not. + for (int attempt = 0; attempt < 3; attempt++) + { + try + { + if (recursive && Directory.Exists(path)) Directory.Delete(path, true); + else if (!recursive && File.Exists(path)) File.Delete(path); + return true; + } + catch (IOException) when (attempt < 2) { Thread.Sleep(100); } + catch (UnauthorizedAccessException) when (attempt < 2) { Thread.Sleep(100); } + catch { /* final attempt failed — fall through to existence check */ } + } + return !PathStillExists(path); + } + + private static bool PathStillExists(string path) => File.Exists(path) || Directory.Exists(path); + + private static string EscapeAttribute(string value) => + value.Replace("&", "&").Replace("\"", """).Replace("<", "<").Replace(">", ">"); +} diff --git a/src/tools/winui-cli/Commands/Analyzer/AnalyzerPayload.cs b/src/tools/winui-cli/Commands/Analyzer/AnalyzerPayload.cs new file mode 100644 index 0000000..40b047d --- /dev/null +++ b/src/tools/winui-cli/Commands/Analyzer/AnalyzerPayload.cs @@ -0,0 +1,42 @@ +using System.Reflection; +using System.Text.Json; +using WinUi.Cli.Schemas; + +namespace WinUi.Cli.Commands.Analyzer; + +internal static class AnalyzerPayload +{ + public const string DllName = "Microsoft.WindowsAppSDK.Analyzers.dll"; + public const string TargetsName = "Microsoft.WindowsAppSDK.Analyzers.targets"; + private const string DllResource = "analyzer/" + DllName; + private const string TargetsResource = "analyzer/" + TargetsName; + + public static bool Available => Assembly.GetExecutingAssembly().GetManifestResourceStream(DllResource) != null + && Assembly.GetExecutingAssembly().GetManifestResourceStream(TargetsResource) != null; + + public static string[] Rules => new[] + { + "WUI0001-WUI0004 UWP-to-WinUI API compatibility", + "WUI1001-WUI1010 migration mapping hints", + "WUI2001-WUI2030 XAML/layout/accessibility pitfalls", + "WUI3001 MVVM pattern guidance", + "WUI4001-WUI4103 interop and GenAI API guidance" + }; + + public static string Version => typeof(AnalyzerPayload).Assembly.GetName().Version?.ToString() ?? "unknown"; + + public static void ExtractTo(string targetDir) + { + Directory.CreateDirectory(targetDir); + WriteResource(DllResource, Path.Combine(targetDir, DllName)); + WriteResource(TargetsResource, Path.Combine(targetDir, TargetsName)); + } + + private static void WriteResource(string logicalName, string destination) + { + using var source = Assembly.GetExecutingAssembly().GetManifestResourceStream(logicalName) + ?? throw new FileNotFoundException($"Embedded resource missing: {logicalName}"); + using var target = File.Create(destination); + source.CopyTo(target); + } +} diff --git a/src/tools/winui-cli/Commands/Analyzer/InfoCommand.cs b/src/tools/winui-cli/Commands/Analyzer/InfoCommand.cs new file mode 100644 index 0000000..0a59434 --- /dev/null +++ b/src/tools/winui-cli/Commands/Analyzer/InfoCommand.cs @@ -0,0 +1,31 @@ +using System.Text.Json; +using WinUi.Cli.Schemas; + +namespace WinUi.Cli.Commands.Analyzer; + +internal sealed class InfoCommand : ICommand +{ + public string Name => "info"; + public string Description => "Print embedded analyzer version and rule summary"; + public string? UsageHint => "winui analyzer info"; + public string[] Examples => new[] { "winui analyzer info", "winui --json analyzer info" }; + + public int Run(string[] args, GlobalOptions options) + { + if (args.Length > 0 && (args[0] is "--help" or "-h")) + { + HelpRenderer.RenderVerb("analyzer", this, options); + return (int)ExitCode.Success; + } + var result = new AnalyzerInfoResultV1("winui.analyzer.info.v1", AnalyzerPayload.Version, AnalyzerPayload.Rules, AnalyzerPayload.Available); + if (options.Json) + Console.Out.WriteLine(JsonSerializer.Serialize(result, WinUiJsonContext.Default.AnalyzerInfoResultV1)); + else + { + Console.Out.WriteLine($"Microsoft.WindowsAppSDK.Analyzers: {result.Version}"); + Console.Out.WriteLine($"Embedded payload: {(result.EmbeddedPayloadAvailable ? "available" : "missing")}"); + foreach (var rule in result.Rules) Console.Out.WriteLine($" - {rule}"); + } + return (int)ExitCode.Success; + } +} diff --git a/src/tools/winui-cli/Commands/Api/ApiCommand.cs b/src/tools/winui-cli/Commands/Api/ApiCommand.cs new file mode 100644 index 0000000..3e83fbb --- /dev/null +++ b/src/tools/winui-cli/Commands/Api/ApiCommand.cs @@ -0,0 +1,47 @@ +using WinUi.Cli.Schemas; + +namespace WinUi.Cli.Commands.Api; + +internal sealed class ApiCommand : CommandNode +{ + public ApiCommand() : base("api", "WinRT and Windows App SDK API lookup") + { + Register(Verb("update", "Refresh the WinMD project cache (run this first)", + usageHint: "winui api update [--project-dir ] [--winappsdk-runtime ]", + examples: new[] { "winui api update", "winui api update --project-dir ./src/MyApp" })); + Register(Verb("search", "Full-text search across cached WinMD APIs", + usageHint: "winui api search [--max ] [--filter ]", + examples: new[] { "winui api search TabView", "winui api search \"app window\" --max 10" })); + Register(Verb("members", "List members of a specific type", + usageHint: "winui api members ", + examples: new[] { "winui api members Microsoft.UI.Xaml.Window" })); + Register(Verb("types", "List types matching a query", + usageHint: "winui api types ", + examples: new[] { "winui api types Button" })); + Register(Verb("enums", "List enum types and values", + usageHint: "winui api enums ", + examples: new[] { "winui api enums Visibility" })); + Register(Verb("check-property", "Check whether a type defines a property", + usageHint: "winui api check-property ", + examples: new[] { "winui api check-property Grid Row", "winui api check-property TextBox Icon" })); + Register(Verb("namespaces", "List cached namespaces (filterable)", + usageHint: "winui api namespaces [--filter ]", + examples: new[] { "winui api namespaces", "winui api namespaces --filter Microsoft.UI" })); + Register(Verb("packages", "List NuGet packages contributing WinMD to the current project", + usageHint: "winui api packages")); + Register(Verb("projects", "List projects known to the local cache", + usageHint: "winui api projects")); + Register(Verb("stats", "Print cache statistics", + usageHint: "winui api stats")); + } + + private static WrappingVerbCommand Verb(string verb, string description, string? usageHint = null, string[]? examples = null) => + new("api", verb, description, + runner: global::WinMdCliRunner.Run, + setInnerPrefix: s => global::WinMdInvocation.CommandPrefix = s, + usageHint: usageHint, examples: examples); + + public override string? TipLine => + "Tip: run 'winui api update' once per project to build the WinMD cache before searching."; +} + diff --git a/src/tools/winui-cli/Commands/CommandNode.cs b/src/tools/winui-cli/Commands/CommandNode.cs new file mode 100644 index 0000000..6378a77 --- /dev/null +++ b/src/tools/winui-cli/Commands/CommandNode.cs @@ -0,0 +1,33 @@ +namespace WinUi.Cli.Commands; + +internal abstract class CommandNode : ICommand +{ + private readonly Dictionary _children = new(StringComparer.OrdinalIgnoreCase); + protected CommandNode(string name, string description) + { + Name = name; + Description = description; + } + + public string Name { get; } + public string Description { get; } + public virtual bool Hidden => false; + public virtual string? UsageHint => null; + public virtual string[] Examples => Array.Empty(); + public virtual string? TipLine => null; + public IEnumerable Children => _children.Values; + + protected void Register(ICommand command) => _children.Add(command.Name, command); + + public virtual int Run(string[] args, GlobalOptions options) + { + if (args.Length == 0 || args[0] is "--help" or "-h" or "help") + { + HelpRenderer.RenderNode(this, options); + return (int)ExitCode.Success; + } + if (_children.TryGetValue(args[0], out var command)) + return command.Run(args[1..], options); + return Output.Error("unknown_command", $"Unknown command under '{Name}': {args[0]}", ExitCode.UsageError, options); + } +} diff --git a/src/tools/winui-cli/Commands/CommandRegistry.cs b/src/tools/winui-cli/Commands/CommandRegistry.cs new file mode 100644 index 0000000..20299c7 --- /dev/null +++ b/src/tools/winui-cli/Commands/CommandRegistry.cs @@ -0,0 +1,12 @@ +namespace WinUi.Cli.Commands; + +internal sealed class CommandRegistry : CommandNode +{ + public CommandRegistry() : base("winui", "WinUI sidecar CLI") + { + Register(new Api.ApiCommand()); + Register(new Controls.ControlsCommand()); + Register(new Project.ProjectCommand()); + Register(new Analyzer.AnalyzerCommand()); + } +} diff --git a/src/tools/winui-cli/Commands/Controls/ControlsCommand.cs b/src/tools/winui-cli/Commands/Controls/ControlsCommand.cs new file mode 100644 index 0000000..1872cf8 --- /dev/null +++ b/src/tools/winui-cli/Commands/Controls/ControlsCommand.cs @@ -0,0 +1,34 @@ +namespace WinUi.Cli.Commands.Controls; + +internal sealed class ControlsCommand : CommandNode +{ + public ControlsCommand() : base("controls", "WinUI control and pattern lookup (Gallery + Toolkit)") + { + Register(Verb("search", "Search WinUI Gallery + Community Toolkit scenarios", + usageHint: "winui controls search \"\" [\"\" ...] [--max N] [--source gallery|toolkit|core]", + examples: new[] { "winui controls search \"settings card\"", "winui controls search \"file picker\" --source core" })); + Register(Verb("get", "Fetch full XAML + C# for one or more pattern IDs", + usageHint: "winui controls get [ ...]", + examples: new[] { "winui controls get gallery-tabview-1", "winui controls get toolkit-settingscard-9 gallery-infobar-1" })); + Register(Verb("list", "List all available patterns (optionally filtered by source)", + usageHint: "winui controls list [--source gallery|toolkit|core]", + examples: new[] { "winui controls list", "winui controls list --source toolkit" })); + Register(Verb("update", "Force-refresh the cache from GitHub", + usageHint: "winui controls update", + examples: new[] { "winui controls update" })); + Register(Verb("debug", "Diagnostic dump for a query (tokens, synonyms, top matches)", + usageHint: "winui controls debug \"\"", + examples: new[] { "winui controls debug \"settings card with toggle\"" }, + hidden: true)); + } + + private static WrappingVerbCommand Verb(string verb, string description, string? usageHint = null, string[]? examples = null, bool hidden = false) => + new("controls", verb, description, + runner: global::WinUiSearchRunner.Run, + setInnerPrefix: s => global::WinUiSearchInvocation.CommandPrefix = s, + usageHint: usageHint, examples: examples, hidden: hidden); + + public override string? TipLine => + "Tip: 'search' returns IDs; pass IDs to 'get' for full XAML + C#. Cache auto-refreshes; run 'update' to force."; +} + diff --git a/src/tools/winui-cli/Commands/ErrorClassification.cs b/src/tools/winui-cli/Commands/ErrorClassification.cs new file mode 100644 index 0000000..8b24902 --- /dev/null +++ b/src/tools/winui-cli/Commands/ErrorClassification.cs @@ -0,0 +1,53 @@ +namespace WinUi.Cli.Commands; + +// The inner winmd-cli and winui-search both collapse every failure to exit 1, with +// no usage-vs-execution discrimination. BENCH-2 surfaced this: hosts can't tell +// "you called me wrong" from "infrastructure broke." We discriminate by inspecting +// captured stderr for one of two contracts: +// +// 1. A `[USAGE]` sentinel prefix (preferred — see UsageError helper below). +// Underlying CLIs that adopt the sentinel get robust classification with no +// string matching at all. +// +// 2. A small allow-list of stable line-leading prefixes from the underlying CLIs +// that haven't been migrated to the sentinel yet. These ARE brittle to +// reword — every entry below is a contract with the upstream CLI source and +// must be updated in lockstep if the wording changes. The marker comment on +// each line points at the source-of-truth file. +// +// Anything else falls through to ExecutionError (exit 4). +internal static class ErrorClassification +{ + public const string UsageSentinel = "[USAGE]"; + + // Each entry is matched at the start of a trimmed stderr line. Anchoring to + // line-start avoids false positives from prose that happens to contain one of + // these tokens further into a multi-line error. + private static readonly string[] UsageLinePrefixes = new[] + { + "Error: ", // winmd-cli QueryEngine.cs: all "Error: " lines + "Unknown command", // winmd-cli Program.cs:81 + "Unknown option", // winui-search Program.cs argument parser + "Multiple projects cached", // winmd-cli Program.cs:500 + "No .csproj", // winmd-cli Program.cs:528 + "--max must be", // winui-search Program.cs --max validation + "Search query cannot be empty", // winui-search Program.cs + }; + + public static ExitCode Classify(string captured) + { + if (string.IsNullOrWhiteSpace(captured)) return ExitCode.ExecutionError; + if (captured.Contains(UsageSentinel, StringComparison.Ordinal)) return ExitCode.UsageError; + + foreach (var rawLine in captured.Split('\n')) + { + var line = rawLine.TrimStart(); + foreach (var prefix in UsageLinePrefixes) + { + if (line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return ExitCode.UsageError; + } + } + return ExitCode.ExecutionError; + } +} diff --git a/src/tools/winui-cli/Commands/ExitCode.cs b/src/tools/winui-cli/Commands/ExitCode.cs new file mode 100644 index 0000000..a6f9ba9 --- /dev/null +++ b/src/tools/winui-cli/Commands/ExitCode.cs @@ -0,0 +1,10 @@ +namespace WinUi.Cli.Commands; + +internal enum ExitCode +{ + Success = 0, + UsageError = 2, + NotFound = 3, + ExecutionError = 4, + DependencyMissing = 5 +} diff --git a/src/tools/winui-cli/Commands/GlobalOptions.cs b/src/tools/winui-cli/Commands/GlobalOptions.cs new file mode 100644 index 0000000..51110e3 --- /dev/null +++ b/src/tools/winui-cli/Commands/GlobalOptions.cs @@ -0,0 +1,21 @@ +namespace WinUi.Cli.Commands; + +internal sealed record GlobalOptions(bool Json, bool NoColor, bool Quiet) +{ + public static (GlobalOptions Options, string[] Args) Parse(string[] args) + { + var rest = new List(); + bool json = false, noColor = false, quiet = false; + foreach (var arg in args) + { + switch (arg) + { + case "--json": json = true; noColor = true; break; + case "--no-color": noColor = true; break; + case "--quiet": quiet = true; break; + default: rest.Add(arg); break; + } + } + return (new GlobalOptions(json, noColor, quiet), rest.ToArray()); + } +} diff --git a/src/tools/winui-cli/Commands/HelpRenderer.cs b/src/tools/winui-cli/Commands/HelpRenderer.cs new file mode 100644 index 0000000..0b55ead --- /dev/null +++ b/src/tools/winui-cli/Commands/HelpRenderer.cs @@ -0,0 +1,113 @@ +using System.Reflection; +using System.Text.Json; +using WinUi.Cli.Schemas; + +namespace WinUi.Cli.Commands; + +internal static class HelpRenderer +{ + public static void RenderRoot(CommandRegistry registry, GlobalOptions options) + { + if (options.Json) + { + var verbs = registry.Children + .Where(c => !c.Hidden) + .Select(c => new HelpVerbV1(c.Name, c.Description, null, false)) + .ToArray(); + var payload = new HelpEnvelopeV1( + "winui.help.v1", + "winui", + "WinUI sidecar CLI", + "winui [args] [--json] [--no-color] [--quiet]", + verbs, + Array.Empty(), + null); + Console.Out.WriteLine(JsonSerializer.Serialize(payload, WinUiJsonContext.Default.HelpEnvelopeV1)); + return; + } + Console.WriteLine("winui - WinUI sidecar CLI"); + Console.WriteLine(); + Console.WriteLine("Usage:"); + Console.WriteLine(" winui [args]"); + Console.WriteLine(" winui --version | --help"); + Console.WriteLine(); + Console.WriteLine("Global flags:"); + Console.WriteLine(" --json Emit machine-readable JSON and disable ANSI/progress"); + Console.WriteLine(" --no-color Disable ANSI color"); + Console.WriteLine(" --quiet Suppress non-essential human output"); + Console.WriteLine(); + Console.WriteLine("Commands:"); + foreach (var child in registry.Children.Where(c => !c.Hidden)) + Console.WriteLine($" {child.Name,-10} {child.Description}"); + } + + public static void RenderNode(CommandNode node, GlobalOptions options) + { + if (options.Json) + { + var verbs = node.Children + .Where(c => !c.Hidden) + .Select(c => new HelpVerbV1(c.Name, c.Description, c.UsageHint, false)) + .ToArray(); + var payload = new HelpEnvelopeV1( + "winui.help.v1", + $"winui {node.Name}", + node.Description, + $"winui {node.Name} [args] [--json] [--help]", + verbs, + Array.Empty(), + node.TipLine); + Console.Out.WriteLine(JsonSerializer.Serialize(payload, WinUiJsonContext.Default.HelpEnvelopeV1)); + return; + } + Console.WriteLine($"winui {node.Name} - {node.Description}"); + Console.WriteLine(); + Console.WriteLine("Usage:"); + Console.WriteLine($" winui {node.Name} [args] [--json] [--help]"); + Console.WriteLine(); + Console.WriteLine("Verbs:"); + var verbsList = node.Children.Where(c => !c.Hidden).ToList(); + var width = verbsList.Count == 0 ? 4 : Math.Max(4, verbsList.Max(v => v.Name.Length) + 2); + foreach (var child in verbsList) + Console.WriteLine($" {child.Name.PadRight(width)}{child.Description}"); + if (!string.IsNullOrEmpty(node.TipLine)) + { + Console.WriteLine(); + Console.WriteLine(node.TipLine); + } + Console.WriteLine(); + Console.WriteLine($"Run 'winui {node.Name} --help' for verb-specific usage and examples."); + } + + public static void RenderVerb(string noun, ICommand verb, GlobalOptions options) + { + if (options.Json) + { + var payload = new HelpEnvelopeV1( + "winui.help.v1", + $"winui {noun} {verb.Name}", + verb.Description, + verb.UsageHint ?? $"winui {noun} {verb.Name} [args]", + Array.Empty(), + verb.Examples, + null); + Console.Out.WriteLine(JsonSerializer.Serialize(payload, WinUiJsonContext.Default.HelpEnvelopeV1)); + return; + } + Console.WriteLine($"winui {noun} {verb.Name} - {verb.Description}"); + Console.WriteLine(); + Console.WriteLine("Usage:"); + Console.WriteLine($" {verb.UsageHint ?? $"winui {noun} {verb.Name} [args]"}"); + if (verb.Examples.Length > 0) + { + Console.WriteLine(); + Console.WriteLine("Examples:"); + foreach (var ex in verb.Examples) + Console.WriteLine($" {ex}"); + } + } + + public static string Version => Assembly.GetExecutingAssembly().GetCustomAttribute()?.InformationalVersion + ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString() + ?? "0.0.0"; +} diff --git a/src/tools/winui-cli/Commands/ICommand.cs b/src/tools/winui-cli/Commands/ICommand.cs new file mode 100644 index 0000000..851b202 --- /dev/null +++ b/src/tools/winui-cli/Commands/ICommand.cs @@ -0,0 +1,11 @@ +namespace WinUi.Cli.Commands; + +internal interface ICommand +{ + string Name { get; } + string Description { get; } + bool Hidden => false; + string? UsageHint => null; + string[] Examples => Array.Empty(); + int Run(string[] args, GlobalOptions options); +} diff --git a/src/tools/winui-cli/Commands/Output.cs b/src/tools/winui-cli/Commands/Output.cs new file mode 100644 index 0000000..5fe0336 --- /dev/null +++ b/src/tools/winui-cli/Commands/Output.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using WinUi.Cli.Schemas; + +namespace WinUi.Cli.Commands; + +internal static class Output +{ + public static int Error(string code, string message, ExitCode exitCode, GlobalOptions options) + { + if (options.Json) + { + Console.Out.WriteLine(JsonSerializer.Serialize(new ErrorEnvelopeV1("winui.error.v1", new ErrorBodyV1(code, message)), WinUiJsonContext.Default.ErrorEnvelopeV1)); + } + else + { + Console.Error.WriteLine($"ERROR {code}: {message}"); + } + return (int)exitCode; + } +} diff --git a/src/tools/winui-cli/Commands/Project/BuildCommand.cs b/src/tools/winui-cli/Commands/Project/BuildCommand.cs new file mode 100644 index 0000000..ddf2402 --- /dev/null +++ b/src/tools/winui-cli/Commands/Project/BuildCommand.cs @@ -0,0 +1,270 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using System.Text.Json; +using System.Threading.Tasks; +using WinUi.Cli.Schemas; + +namespace WinUi.Cli.Commands.Project; + +internal sealed class BuildCommand : ICommand +{ + public string Name => "build"; + public string Description => "Build a WinUI project and hand off to winapp run"; + public string? UsageHint => "winui project build [--project ] [--skip-run] [--detach] [--configuration ] [--platform ] [-- ]"; + public string[] Examples => new[] + { + "winui project build", + "winui project build --project ./src/MyApp", + "winui project build --configuration Release --platform x64", + "winui project build --skip-run", + "winui project build --detach", + "winui project build --force # bypass WinUI project check", + }; + + public int Run(string[] args, GlobalOptions options) + { + var parsed = BuildArgs.Parse(args); + if (parsed.Help) + { + HelpRenderer.RenderVerb("project", this, options); + return (int)ExitCode.Success; + } + if (!OperatingSystem.IsWindows()) + return Output.Error("windows_required", "winui project build requires Windows.", ExitCode.ExecutionError, options); + + if (!CheckDeveloperMode() && !parsed.SkipDeveloperModeCheck) + return Output.Error("developer_mode_disabled", "Developer Mode is not enabled. Enable Settings > System > For developers > Developer Mode.", ExitCode.ExecutionError, options); + + var project = ResolveProject(parsed.Project); + if (project == null) + return Output.Error("project_not_found", "No .csproj file found, or multiple projects exist. Pass --project .", ExitCode.UsageError, options); + + if (!parsed.Force && !IsWinUiProject(project)) + return Output.Error("not_a_winui_project", $"Project '{Path.GetFileName(project)}' does not reference Microsoft.WindowsAppSDK. Re-run with --force to build anyway, or point --project at a WinUI .csproj.", ExitCode.UsageError, options); + + string platform = parsed.Platform ?? (RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "ARM64" : "x64"); + string configuration = parsed.Configuration ?? "Debug"; + var extra = parsed.ExtraArgs.ToList(); + EnsureProperty(extra, "Platform", platform); + EnsureProperty(extra, "Configuration", configuration); + if (!extra.Any(a => Regex.IsMatch(a, "^[/|-](restore|t:restore)$|^--restore$", RegexOptions.IgnoreCase))) + extra.Insert(0, "/restore"); + + var msbuild = FindMsBuild(); + var buildArgs = msbuild != null + ? new[] { "/nologo", "/v:m" }.Concat(extra.Where(a => a != "--")).Concat(new[] { project }).ToArray() + : ToDotnetArgs(project, extra); + + if (!options.Quiet && !options.Json) + Console.Error.WriteLine($"Building with {(msbuild != null ? "MSBuild" : "dotnet build")} (Platform: {platform}, Config: {configuration})"); + + // Transiently inject the embedded analyzer via a temp Directory.Build.props + // next to the .csproj. Mirrors BuildAndRun.ps1 step 4a: only writes the + // file when there isn't already a user-owned one, and always cleans it up + // in finally so a subsequent vanilla `dotnet build` doesn't see a stray + // file pointing at an analyzer that isn't on disk. + var analyzerInjection = Commands.Analyzer.AnalyzerInjection.Prepare(project, options); + try + { + var buildExit = RunProcess(msbuild ?? "dotnet", buildArgs, options.Json ? null : Console.Out, options.Json ? null : Console.Error, out var buildOut, out var buildErr); + if (buildExit != 0) + { + if (options.Json) + return Output.Error("build_failed", string.IsNullOrWhiteSpace(buildErr) ? "Build failed." : buildErr.Trim(), ExitCode.ExecutionError, options); + return buildExit; + } + + var outputDir = FindOutputDir(project, platform, configuration); + bool runAttempted = false; + int finalExit = 0; + if (!parsed.SkipRun && outputDir != null) + { + var winapp = FindOnPath("winapp.exe") ?? FindOnPath("winapp"); + if (winapp != null) + { + runAttempted = true; + var runArgs = parsed.Detach ? new[] { "run", outputDir, "--detach", "--json" } : new[] { "run", outputDir, "--debug-output" }; + finalExit = RunProcess(winapp, runArgs, options.Json ? null : Console.Out, options.Json ? null : Console.Error, out _, out _); + } + else if (!options.Quiet && !options.Json) + { + Console.Error.WriteLine("WARNING: winapp CLI not found in PATH -- skipping run"); + Console.Out.WriteLine($"Build output at: {outputDir}"); + } + } + + if (options.Json) + { + Console.Out.WriteLine(JsonSerializer.Serialize(new ProjectBuildResultV1("winui.project.build.v1", true, runAttempted, outputDir, finalExit), WinUiJsonContext.Default.ProjectBuildResultV1)); + } + else if (!options.Quiet) + { + Console.Error.WriteLine("BUILD SUCCEEDED"); + if (parsed.SkipRun) Console.Error.WriteLine("Skipping run (--skip-run)"); + } + return finalExit; + } + finally + { + // BENCH-4: widened to wrap ALL post-build work so the temp + // Directory.Build.props gets cleaned up even when a post-build MSBuild + // target (e.g. winapp create-debug-identity) fails or the JSON emit + // throws. Cleanup retries with backoff because MSBuild may still hold + // file handles for a moment after a failure exit. + analyzerInjection.Cleanup(options); + } + } + + private static void PrintUsage() + { + // Kept for any legacy callers; new help path goes through HelpRenderer.RenderVerb. + Console.WriteLine("Usage: winui project build [--project ] [--skip-run] [--detach] [--configuration ] [--platform ] [MSBuild args]"); + } + + private static bool CheckDeveloperMode() + { + try + { + using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"); + return Convert.ToInt32(key?.GetValue("AllowDevelopmentWithoutDevLicense") ?? 0) == 1; + } + catch { return false; } + } + + private static string? ResolveProject(string? project) + { + if (!string.IsNullOrWhiteSpace(project)) + { + var full = Path.GetFullPath(project); + if (Directory.Exists(full)) + { + var inDir = Directory.GetFiles(full, "*.csproj", SearchOption.TopDirectoryOnly); + return inDir.Length == 1 ? inDir[0] : null; + } + return File.Exists(full) && full.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase) ? full : null; + } + var files = Directory.GetFiles(Directory.GetCurrentDirectory(), "*.csproj", SearchOption.TopDirectoryOnly); + return files.Length == 1 ? files[0] : null; + } + + private static bool IsWinUiProject(string csprojPath) + { + try + { + var text = File.ReadAllText(csprojPath); + // Look for the WindowsAppSDK package reference; that's the load-bearing signal. + return text.Contains("Microsoft.WindowsAppSDK", StringComparison.OrdinalIgnoreCase) + || text.Contains("UseWinUI", StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + + private static string? FindMsBuild() + { + var vswhere = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Microsoft Visual Studio", "Installer", "vswhere.exe"); + if (!File.Exists(vswhere)) return null; + var psi = new ProcessStartInfo(vswhere, "-latest -requires Microsoft.Component.MSBuild -property installationPath") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; + using var p = Process.Start(psi); + if (p == null) return null; + var install = p.StandardOutput.ReadToEnd().Trim(); + p.WaitForExit(15000); + var candidate = Path.Combine(install, "MSBuild", "Current", "Bin", "MSBuild.exe"); + return File.Exists(candidate) ? candidate : null; + } + + private static string? FindOnPath(string name) + { + foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator)) + { + if (string.IsNullOrWhiteSpace(dir)) continue; + var candidate = Path.Combine(dir.Trim(), name); + if (File.Exists(candidate)) return candidate; + } + return null; + } + + private static void EnsureProperty(List args, string name, string value) + { + if (!args.Any(a => Regex.IsMatch(a, $"^[/|-]p:{Regex.Escape(name)}=", RegexOptions.IgnoreCase))) + args.Add($"/p:{name}={value}"); + } + + private static string[] ToDotnetArgs(string project, List args) + { + var result = new List { "build", project }; + foreach (var a in args) + { + if (Regex.IsMatch(a, "^[/|-](restore|t:restore)$", RegexOptions.IgnoreCase)) continue; + var m = Regex.Match(a, "^[/|-]p:(.+)$", RegexOptions.IgnoreCase); + result.Add(m.Success ? $"-p:{m.Groups[1].Value}" : a); + } + return result.ToArray(); + } + + private static string? FindOutputDir(string project, string platform, string configuration) + { + var projectDir = Path.GetDirectoryName(Path.GetFullPath(project)) ?? Directory.GetCurrentDirectory(); + var binDir = Path.Combine(projectDir, "bin", platform, configuration); + if (!Directory.Exists(binDir)) return null; + var tfm = Directory.GetDirectories(binDir, "net*").OrderByDescending(Path.GetFileName).FirstOrDefault(); + if (tfm == null) return null; + var rid = Path.Combine(tfm, "win-" + platform.ToLowerInvariant()); + return Directory.Exists(rid) ? rid : tfm; + } + + private static int RunProcess(string fileName, string[] args, TextWriter? stdout, TextWriter? stderr, out string capturedOut, out string capturedErr) + { + var captureOut = stdout == null; + var captureErr = stderr == null; + var psi = new ProcessStartInfo(fileName) { UseShellExecute = false, RedirectStandardOutput = captureOut, RedirectStandardError = captureErr }; + foreach (var arg in args) psi.ArgumentList.Add(arg); + using var process = Process.Start(psi) ?? throw new InvalidOperationException($"Failed to start {fileName}."); + + // Drain both streams concurrently to avoid the classic Process pipe-buffer deadlock: + // if either pipe fills (~4-64KB) while the parent is blocked on the other, the child + // process stalls before it can exit. Real MSBuild output easily exceeds that. + var outTask = captureOut ? process.StandardOutput.ReadToEndAsync() : Task.FromResult(""); + var errTask = captureErr ? process.StandardError.ReadToEndAsync() : Task.FromResult(""); + Task.WaitAll(outTask, errTask); + process.WaitForExit(); + + capturedOut = outTask.Result; + capturedErr = errTask.Result; + if (stdout != null && capturedOut.Length > 0) stdout.Write(capturedOut); + if (stderr != null && capturedErr.Length > 0) stderr.Write(capturedErr); + return process.ExitCode; + } + + private sealed record BuildArgs(string? Project, bool SkipRun, bool Detach, string? Configuration, string? Platform, bool Help, bool SkipDeveloperModeCheck, bool Force, List ExtraArgs) + { + public static BuildArgs Parse(string[] args) + { + string? project = null, config = null, platform = null; + bool skipRun = false, detach = false, help = false, skipDevMode = false, force = false; + var extra = new List(); + for (int i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--help": case "-h": help = true; break; + case "--project" when i + 1 < args.Length: project = args[++i]; break; + case "--skip-run": case "-SkipRun": skipRun = true; break; + case "--detach": case "-Detach": detach = true; break; + case "--configuration" when i + 1 < args.Length: config = args[++i]; extra.Add($"/p:Configuration={config}"); break; + case "--platform" when i + 1 < args.Length: platform = args[++i]; extra.Add($"/p:Platform={platform}"); break; + case "--skip-developer-mode-check": skipDevMode = true; break; + case "--force": force = true; break; + default: + if (project == null && args[i].EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) project = args[i]; + else extra.Add(args[i]); + break; + } + } + return new(project, skipRun, detach, config, platform, help, skipDevMode, force, extra); + } + } +} diff --git a/src/tools/winui-cli/Commands/Project/ProjectCommand.cs b/src/tools/winui-cli/Commands/Project/ProjectCommand.cs new file mode 100644 index 0000000..3e8592d --- /dev/null +++ b/src/tools/winui-cli/Commands/Project/ProjectCommand.cs @@ -0,0 +1,12 @@ +namespace WinUi.Cli.Commands.Project; + +internal sealed class ProjectCommand : CommandNode +{ + public ProjectCommand() : base("project", "WinUI project authoring helpers") + { + Register(new BuildCommand()); + } + + public override string? TipLine => + "Tip: 'winui project build' is a temporary MSBuild workaround for the XAML compiler. It hands off to 'winapp run' on success."; +} diff --git a/src/tools/winui-cli/Commands/TeeWriter.cs b/src/tools/winui-cli/Commands/TeeWriter.cs new file mode 100644 index 0000000..fe0c9d0 --- /dev/null +++ b/src/tools/winui-cli/Commands/TeeWriter.cs @@ -0,0 +1,19 @@ +namespace WinUi.Cli.Commands; + +// Writes every call to two underlying TextWriters. Used by the verb wrappers to +// stream stderr live to the console while also capturing it for exit-code +// re-classification (BENCH-2). +internal sealed class TeeWriter : TextWriter +{ + private readonly TextWriter _a; + private readonly TextWriter _b; + + public TeeWriter(TextWriter a, TextWriter b) { _a = a; _b = b; } + + public override System.Text.Encoding Encoding => _a.Encoding; + + public override void Write(char value) { _a.Write(value); _b.Write(value); } + public override void Write(string? value) { _a.Write(value); _b.Write(value); } + public override void WriteLine(string? value) { _a.WriteLine(value); _b.WriteLine(value); } + public override void Flush() { _a.Flush(); _b.Flush(); } +} diff --git a/src/tools/winui-cli/Commands/WrappingVerbCommand.cs b/src/tools/winui-cli/Commands/WrappingVerbCommand.cs new file mode 100644 index 0000000..d05c9ca --- /dev/null +++ b/src/tools/winui-cli/Commands/WrappingVerbCommand.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using WinUi.Cli.Schemas; + +namespace WinUi.Cli.Commands; + +// Verb that forwards to an inner CLI runner (winmd-cli, winui-search) and wraps +// the result as a winui.text-result.v1 JSON payload tagged with the noun+verb. +// +// Replaces what used to be ApiVerbCommand + ControlsVerbCommand — two +// byte-identical classes differing only in (1) which runner to call, (2) which +// CommandPrefix global to set, (3) the noun string used in help/error/payload. +// Lifting those into constructor parameters lets a single class cover every +// "wrap an inner CLI as a winui noun" verb. Adding a third such noun is now a +// constructor call instead of a new file. +internal sealed class WrappingVerbCommand : ICommand +{ + // (args, stdout, stderr, json) -> exitCode. Matches both WinMdCliRunner.Run + // and WinUiSearchRunner.Run signatures exactly. + public delegate int InnerRunner(string[] args, TextWriter stdout, TextWriter stderr, bool json); + + private readonly string _noun; + private readonly string _verb; + private readonly InnerRunner _runner; + private readonly Action _setInnerPrefix; + + public string Name => _verb; + public string Description { get; } + public string? UsageHint { get; } + public string[] Examples { get; } + public bool Hidden { get; } + + public WrappingVerbCommand( + string noun, + string verb, + string description, + InnerRunner runner, + Action setInnerPrefix, + string? usageHint = null, + string[]? examples = null, + bool hidden = false) + { + _noun = noun; + _verb = verb; + _runner = runner; + _setInnerPrefix = setInnerPrefix; + Description = description; + UsageHint = usageHint; + Examples = examples ?? Array.Empty(); + Hidden = hidden; + } + + public int Run(string[] args, GlobalOptions options) + { + if (args.Length > 0 && (args[0] is "--help" or "-h")) + { + HelpRenderer.RenderVerb(_noun, this, options); + return (int)ExitCode.Success; + } + + _setInnerPrefix($"winui {_noun}"); + var forwarded = new[] { _verb }.Concat(args).ToArray(); + + if (!options.Json) + { + // Even in non-JSON mode we capture stderr so we can re-classify exit codes. + // Stream stdout straight through so the user sees output live. + using var stderrCapture = new StringWriter(); + var rawExit = _runner(forwarded, Console.Out, new TeeWriter(Console.Error, stderrCapture), options.Json); + if (rawExit == 0) return 0; + return (int)ErrorClassification.Classify(stderrCapture.ToString()); + } + + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + var exit = _runner(forwarded, stdout, stderr, options.Json); + if (exit != 0) + { + var classified = ErrorClassification.Classify(stderr.ToString() + " " + stdout.ToString()); + return Output.Error($"{_noun}_{_verb}_failed", First(stderr.ToString(), stdout.ToString(), $"{_noun} {_verb} failed."), classified, options); + } + // Text-wrapper payload — see TextResultV1 comment in JsonPayloads.cs. + var payload = new TextResultV1("winui.text-result.v1", $"{_noun}.{_verb}", exit, stdout.ToString()); + Console.Out.WriteLine(JsonSerializer.Serialize(payload, WinUiJsonContext.Default.TextResultV1)); + return exit; + } + + private static string First(params string[] values) => values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v))?.Trim() ?? "Command failed."; +} diff --git a/src/tools/winui-cli/Program.cs b/src/tools/winui-cli/Program.cs new file mode 100644 index 0000000..f76d080 --- /dev/null +++ b/src/tools/winui-cli/Program.cs @@ -0,0 +1,18 @@ +using WinUi.Cli.Commands; + +var (options, remainingArgs) = GlobalOptions.Parse(args); +var registry = new CommandRegistry(); + +if (remainingArgs.Length == 0 || remainingArgs[0] is "--help" or "-h" or "help") +{ + HelpRenderer.RenderRoot(registry, options); + return (int)ExitCode.Success; +} + +if (remainingArgs[0] == "--version") +{ + Console.WriteLine(HelpRenderer.Version); + return (int)ExitCode.Success; +} + +return registry.Run(remainingArgs, options); diff --git a/src/tools/winui-cli/README.md b/src/tools/winui-cli/README.md new file mode 100644 index 0000000..ba57b7e --- /dev/null +++ b/src/tools/winui-cli/README.md @@ -0,0 +1,83 @@ +# winui-cli (`winui.exe`) + +Native-AOT sidecar that exposes a single `winui ` surface over +the other in-repo WinUI tooling (the WinUI 3 / Windows App SDK Roslyn +analyzer, `winmd-cli`, and `winui-search`). It exists so framework-agnostic +hosts (e.g. [`winappcli`](https://github.com/microsoft/winappcli)) can +sideload one binary and get everything the `winui` plugin needs at +runtime, instead of bundling four exes and three install paths. + +The AOT-published artifact is built to +`src/tools/winui-cli/bin/Release/net10.0/win-x64/publish/winui.exe`. +JSON-mode payload schemas live at `src/tools/winui-cli/schemas/*.json` and +are regenerated from source by the bundled `winui-schema-emit` tool. + +## Surface + +Four nouns, ~19 verbs total. Run `winui --help` for the live tree. + +| Noun | Verbs | Backed by | +|---|---|---| +| `api` | `update`, `search`, `members`, `types`, `enums`, `check-property`, `namespaces`, `packages`, `projects`, `stats` | `src/tools/winmd-cli` (library) | +| `controls` | `search`, `get`, `list`, `update` | `src/tools/winui-search` (library) | +| `project` | `build` (transiently injects the embedded analyzer per build, then hands off to `winapp run`) | scaffolding + MSBuild driver | +| `analyzer` | `info` | embedded analyzer DLL payload (read-only) | + +Every verb accepts `--json` and produces one of two payload shapes: + +- **`winui.text-result.v1`** — for verbs that wrap the underlying CLI's text + output (most `api` and `controls` verbs today). Carries `schema`, + `verb` (e.g. `"api.update"`, `"controls.search"`), `exitCode`, `output`. + Consumers dispatch on the `verb` field. +- **Structured payloads** — verbs whose output has real typed fields get + their own schema (e.g. `winui.project.build.v1`, + `winui.analyzer.info.v1`). New structured schemas are added when a verb + graduates beyond opaque text. + +Errors and help share envelope shapes (`winui.error.v1`, `winui.help.v1`) +so hosts only have to parse one error contract. + +Every emitted shape has a committed schema under `src/tools/winui-cli/schemas/`. +The build's `-CheckSchemaDrift` gate fails if a record's shape changes +without regenerating the schema, so the `schema` discriminator in every +payload corresponds to a contract the host can rely on. + +## Building + +```powershell +# Build everything (analyzer + winmd-cli + winui-search + winui-cli + schemas). +./scripts/build-tools.ps1 + +# Quick local iteration — skip tests and payload copy. +./scripts/build-tools.ps1 -SkipTests -SkipPayloadRefresh + +# Verify committed src/tools/winui-cli/schemas/*.json match source. Use in CI. +./scripts/build-tools.ps1 -SkipTests -SkipPayloadRefresh -CheckSchemaDrift +``` + +The script AOT-publishes `winui.exe` to +`src/tools/winui-cli/bin/Release/net10.0/win-x64/publish/`. + +## JSON schemas + +`SchemaGen/` is a small `MetadataLoadContext`-based console tool that walks +every record in `Schemas/JsonPayloads.cs` tagged with +`[WinUiJsonSchema("winui..v1")]` and emits a JSON Schema +(Draft 2020-12) plus a `manifest.json` with SHA-256 hashes. The `schema` +field on every record is locked to its declared id via JSON Schema `const` +so a payload claiming a shape it doesn't have fails validation. + +The contract is: **never hand-edit a file under `src/tools/winui-cli/schemas/`**. +Change the record in `Schemas/JsonPayloads.cs`, re-run +`./scripts/build-tools.ps1`, and commit the regenerated schemas. The +`-CheckSchemaDrift` flag fails the build if the staged schema set +(added / changed / removed) doesn't match the committed copy. + +## Why one exe instead of three + +Hosts can sideload `winui.exe` once and get a single help tree, a single +JSON contract, and a single set of versioned schemas. Each ` ` +still maps to the same underlying tool source — the libraries under +`src/tools/winmd-lib/` and `src/tools/winui-search-lib/` share source with +the standalone `winmd-cli` and `winui-search` exes so behavior stays in +lockstep. diff --git a/src/tools/winui-cli/SchemaGen/Program.cs b/src/tools/winui-cli/SchemaGen/Program.cs new file mode 100644 index 0000000..6a113d1 --- /dev/null +++ b/src/tools/winui-cli/SchemaGen/Program.cs @@ -0,0 +1,253 @@ +// WinUi.SchemaEmit — reflects a winui-cli managed dll, walks every type tagged with +// [WinUiJsonSchemaAttribute], and emits one JSON Schema (Draft 2020-12) per type into +// the output directory. Run from build-tools.ps1 after the managed Release build, +// before AOT publish. The records in src/tools/winui-cli/Schemas/JsonPayloads.cs are +// the single source of truth: this tool consumes them, no handwritten schemas anywhere. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; + +if (args.Length < 2) +{ + Console.Error.WriteLine("usage: winui-schema-emit "); + return 1; +} + +var assemblyPath = Path.GetFullPath(args[0]); +var outDir = Path.GetFullPath(args[1]); +if (!File.Exists(assemblyPath)) +{ + Console.Error.WriteLine($"assembly not found: {assemblyPath}"); + return 1; +} +Directory.CreateDirectory(outDir); + +// MetadataLoadContext lets us reflect over an assembly without loading it for +// execution. The resolver needs the assembly's own folder + the runtime reference +// assemblies (so primitive types resolve). Dedupe by filename so we don't hand the +// resolver two copies of System.Private.CoreLib etc. +var runtimeDir = Path.GetDirectoryName(typeof(object).Assembly.Location)!; +var resolverByName = new Dictionary(StringComparer.OrdinalIgnoreCase); +foreach (var dll in Directory.GetFiles(runtimeDir, "*.dll")) + resolverByName[Path.GetFileName(dll)] = dll; +foreach (var dll in Directory.GetFiles(Path.GetDirectoryName(assemblyPath)!, "*.dll")) + resolverByName.TryAdd(Path.GetFileName(dll), dll); // runtime wins on conflicts +resolverByName[Path.GetFileName(assemblyPath)] = assemblyPath; +using var mlc = new MetadataLoadContext(new PathAssemblyResolver(resolverByName.Values)); +var asm = mlc.LoadFromAssemblyPath(assemblyPath); + +var taggedTypes = asm.GetTypes() + .Where(t => t.GetCustomAttributesData().Any(a => a.AttributeType.Name == "WinUiJsonSchemaAttribute")) + .ToList(); + +if (taggedTypes.Count == 0) +{ + Console.Error.WriteLine("no [WinUiJsonSchema]-tagged types found"); + return 1; +} + +int written = 0; +foreach (var type in taggedTypes.OrderBy(t => t.FullName)) +{ + var schemaName = (string)type.GetCustomAttributesData() + .First(a => a.AttributeType.Name == "WinUiJsonSchemaAttribute") + .ConstructorArguments[0].Value!; + + var defs = new Dictionary(); + var rootSchema = BuildObjectSchema(type, defs, asm); + + var doc = new Dictionary + { + ["$schema"] = "https://json-schema.org/draft/2020-12/schema", + ["$id"] = $"https://aka.ms/win-dev-skills/schemas/{schemaName}.schema.json", + ["title"] = schemaName, + ["description"] = $"Auto-generated from {type.FullName} in winui-cli. Do not edit by hand.", + }; + foreach (var (k, v) in rootSchema) doc[k] = v; + if (defs.Count > 0) + { + doc["$defs"] = defs.ToDictionary(kv => kv.Key, kv => (object?)kv.Value); + } + + var json = JsonSerializer.Serialize(doc, new JsonSerializerOptions + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }); + var outPath = Path.Combine(outDir, schemaName + ".schema.json"); + File.WriteAllText(outPath, json + Environment.NewLine); + Console.WriteLine($" wrote {Path.GetFileName(outPath)}"); + written++; +} + +// Manifest: list every schema name + sha-256 of its JSON. Lets consumers (and CI +// drift checks) pin a known-good shape without parsing each file. +var manifest = new Dictionary +{ + ["generated_from"] = Path.GetFileName(assemblyPath), + ["schema_count"] = written, + ["schemas"] = Directory.GetFiles(outDir, "*.schema.json") + .OrderBy(f => f) + .Select(f => new Dictionary + { + ["name"] = Path.GetFileNameWithoutExtension(f).Replace(".schema", ""), + ["file"] = Path.GetFileName(f), + ["sha256"] = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(f))).ToLowerInvariant(), + }) + .ToArray(), +}; +var manifestPath = Path.Combine(outDir, "manifest.json"); +File.WriteAllText(manifestPath, JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }) + Environment.NewLine); +Console.WriteLine($" wrote manifest.json ({written} schemas)"); + +return 0; + +// --- helpers --- + +static Dictionary BuildObjectSchema(Type type, Dictionary defs, Assembly asm) +{ + // Records expose their positional parameters as auto-properties. We walk the + // declared instance properties to discover the shape. Properties without a + // JsonPropertyName attribute fall back to their original name. + var props = type.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly); + var jsonProps = new Dictionary(); + var required = new List(); + // Pull this type's own discriminator (if any) so we can const-lock the `schema` + // field. Nested records (ErrorBodyV1, HelpVerbV1) won't have the attribute and + // also won't have a `schema` field, so they fall through naturally. + var schemaName = (string?)type.GetCustomAttributesData() + .FirstOrDefault(a => a.AttributeType.Name == "WinUiJsonSchemaAttribute") + ?.ConstructorArguments[0].Value; + + foreach (var p in props) + { + // Skip compiler-synthesized record machinery (EqualityContract is protected + // and never serialized by System.Text.Json; including it in the schema + // would force consumers to emit a property that the exe will never write). + if (p.Name == "EqualityContract") continue; + // Also skip any non-public property without an explicit [JsonPropertyName] — + // STJ source-gen won't serialize it, so the schema mustn't require it. + var hasJsonName = p.GetCustomAttributesData().Any(a => a.AttributeType.Name == "JsonPropertyNameAttribute"); + var getter = p.GetMethod; + if (!hasJsonName && (getter == null || !getter.IsPublic)) continue; + + var jsonName = GetJsonPropertyName(p) ?? CamelCase(p.Name); + var (schema, isNullable) = SchemaForType(p.PropertyType, p, defs, asm); + // Lock the `schema` discriminator to the schemaName. Without this the + // string is unconstrained and any payload's `schema` field could claim + // an unrelated id. The const fixes the shape→id binding, which is the + // whole point of a discriminator. JSON Schema Draft 2020-12 `const` + // works for any type. + if (jsonName == "schema" && p.PropertyType.FullName == "System.String" && schemaName != null) + { + schema = new() { ["type"] = "string", ["const"] = schemaName }; + } + jsonProps[jsonName] = schema; + if (!isNullable) required.Add(jsonName); + } + + var result = new Dictionary + { + ["type"] = "object", + ["properties"] = jsonProps, + ["additionalProperties"] = false, + }; + if (required.Count > 0) result["required"] = required.ToArray(); + return result; +} + +static (Dictionary schema, bool nullable) SchemaForType( + Type type, MemberInfo? owner, Dictionary defs, Assembly asm) +{ + bool nullable = false; + + // Nullable value types. + var underlying = Nullable.GetUnderlyingType(type); + if (underlying != null) { type = underlying; nullable = true; } + + // Nullable reference types — detected via the C# compiler's NullableAttribute byte. + if (!nullable && owner != null && !type.IsValueType) + { + nullable = IsNullableReference(owner); + } + + Dictionary schema; + + if (type.IsArray) + { + var elem = type.GetElementType()!; + var (elemSchema, _) = SchemaForType(elem, null, defs, asm); + schema = new() { ["type"] = nullable ? new object[] { "array", "null" } : "array", ["items"] = elemSchema }; + } + else if (type.FullName == "System.String") schema = new() { ["type"] = nullable ? new object[] { "string", "null" } : "string" }; + else if (type.FullName == "System.Boolean") schema = new() { ["type"] = nullable ? new object[] { "boolean", "null" } : "boolean" }; + else if (type.FullName is "System.Int32" or "System.Int64" or "System.Int16" or "System.Byte" or "System.UInt32" or "System.UInt64" or "System.UInt16") + schema = new() { ["type"] = nullable ? new object[] { "integer", "null" } : "integer" }; + else if (type.FullName is "System.Double" or "System.Single" or "System.Decimal") + schema = new() { ["type"] = nullable ? new object[] { "number", "null" } : "number" }; + else if (type.IsEnum) + schema = new() { ["type"] = nullable ? new object[] { "string", "null" } : "string", ["enum"] = type.GetEnumNames() }; + else if (type.Assembly == asm) + { + // Nested record — register in $defs and emit $ref. + var defKey = type.Name; + if (!defs.ContainsKey(defKey)) + { + // placeholder to break cycles + defs[defKey] = JsonDocument.Parse("{}").RootElement; + var nested = BuildObjectSchema(type, defs, asm); + defs[defKey] = JsonSerializer.SerializeToElement(nested); + } + schema = nullable + ? new() { ["oneOf"] = new object[] { new Dictionary { ["$ref"] = "#/$defs/" + defKey }, new Dictionary { ["type"] = "null" } } } + : new() { ["$ref"] = "#/$defs/" + defKey }; + } + else + { + // Unknown external type — fall back to "any object" rather than failing the build. + schema = new() { ["type"] = new[] { "object", "string", "number", "boolean", "array", "null" } }; + } + + return (schema, nullable); +} + +static string? GetJsonPropertyName(PropertyInfo p) +{ + var attr = p.GetCustomAttributesData().FirstOrDefault(a => a.AttributeType.Name == "JsonPropertyNameAttribute"); + if (attr == null) return null; + return attr.ConstructorArguments[0].Value as string; +} + +static string CamelCase(string s) => string.IsNullOrEmpty(s) ? s : char.ToLowerInvariant(s[0]) + s.Substring(1); + +static bool IsNullableReference(MemberInfo member) +{ + // C# compiler emits NullableAttribute(byte) where 1 = NotAnnotated, 2 = Annotated. + // If absent on the member, look at NullableContextAttribute on the declaring type or its assembly. + var nullableAttr = member.GetCustomAttributesData() + .FirstOrDefault(a => a.AttributeType.FullName == "System.Runtime.CompilerServices.NullableAttribute"); + if (nullableAttr != null && nullableAttr.ConstructorArguments.Count > 0) + { + var arg = nullableAttr.ConstructorArguments[0]; + if (arg.ArgumentType.FullName == "System.Byte") return (byte)arg.Value! == 2; + if (arg.Value is IReadOnlyCollection col && col.Count > 0) + { + return (byte)col.First().Value! == 2; + } + } + var declaring = (member as PropertyInfo)?.DeclaringType; + if (declaring != null) + { + var ctx = declaring.GetCustomAttributesData() + .FirstOrDefault(a => a.AttributeType.FullName == "System.Runtime.CompilerServices.NullableContextAttribute"); + if (ctx != null && ctx.ConstructorArguments.Count > 0) + return (byte)ctx.ConstructorArguments[0].Value! == 2; + } + return false; +} diff --git a/src/tools/winui-cli/SchemaGen/WinUi.SchemaEmit.csproj b/src/tools/winui-cli/SchemaGen/WinUi.SchemaEmit.csproj new file mode 100644 index 0000000..c6aff04 --- /dev/null +++ b/src/tools/winui-cli/SchemaGen/WinUi.SchemaEmit.csproj @@ -0,0 +1,15 @@ + + + Exe + net10.0 + enable + enable + latest + WinUi.SchemaEmit + winui-schema-emit + false + + + + + diff --git a/src/tools/winui-cli/Schemas/JsonPayloads.cs b/src/tools/winui-cli/Schemas/JsonPayloads.cs new file mode 100644 index 0000000..bfe90e4 --- /dev/null +++ b/src/tools/winui-cli/Schemas/JsonPayloads.cs @@ -0,0 +1,74 @@ +using System.Text.Json.Serialization; + +namespace WinUi.Cli.Schemas; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +internal sealed class WinUiJsonSchemaAttribute(string schema) : Attribute +{ + public string Schema { get; } = schema; +} + +// Shared shape for verbs whose payload is just "the inner CLI's text output, with +// an exit code." Previously each such verb had its own [WinUiJsonSchema] record +// (15+ of them), all byte-identical except for the schema discriminator string. +// That was contract theater — distinct schema files conveying no distinct +// information. The honest model is one shape + a `verb` discriminator field. +// Consumers dispatch on `verb`; schema validators check shape via the single +// committed winui.text-result.v1 schema. +// +// If a specific verb's payload ever grows real structure (typed fields beyond +// opaque text), promote it out of this wrapper into its own [WinUiJsonSchema] +// record at that point. +[WinUiJsonSchema("winui.text-result.v1")] +internal sealed record TextResultV1( + [property: JsonPropertyName("schema")] string Schema, + [property: JsonPropertyName("verb")] string Verb, + [property: JsonPropertyName("exitCode")] int ExitCode, + [property: JsonPropertyName("output")] string Output); + +[WinUiJsonSchema("winui.project.build.v1")] +internal sealed record ProjectBuildResultV1( + [property: JsonPropertyName("schema")] string Schema, + [property: JsonPropertyName("buildSucceeded")] bool BuildSucceeded, + [property: JsonPropertyName("runAttempted")] bool RunAttempted, + [property: JsonPropertyName("outputDirectory")] string? OutputDirectory, + [property: JsonPropertyName("exitCode")] int ExitCode); + +[WinUiJsonSchema("winui.analyzer.info.v1")] +internal sealed record AnalyzerInfoResultV1( + [property: JsonPropertyName("schema")] string Schema, + [property: JsonPropertyName("version")] string Version, + [property: JsonPropertyName("rules")] string[] Rules, + [property: JsonPropertyName("embeddedPayloadAvailable")] bool EmbeddedPayloadAvailable); + +[WinUiJsonSchema("winui.error.v1")] +internal sealed record ErrorEnvelopeV1( + [property: JsonPropertyName("schema")] string Schema, + [property: JsonPropertyName("error")] ErrorBodyV1 Error); + +internal sealed record ErrorBodyV1( + [property: JsonPropertyName("code")] string Code, + [property: JsonPropertyName("message")] string Message); + +[WinUiJsonSchema("winui.help.v1")] +internal sealed record HelpEnvelopeV1( + [property: JsonPropertyName("schema")] string Schema, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("description")] string Description, + [property: JsonPropertyName("usage")] string Usage, + [property: JsonPropertyName("verbs")] HelpVerbV1[] Verbs, + [property: JsonPropertyName("examples")] string[] Examples, + [property: JsonPropertyName("tip")] string? Tip); + +internal sealed record HelpVerbV1( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("description")] string Description, + [property: JsonPropertyName("usage")] string? Usage, + [property: JsonPropertyName("hidden")] bool Hidden); + +[JsonSerializable(typeof(TextResultV1))] +[JsonSerializable(typeof(ProjectBuildResultV1))] +[JsonSerializable(typeof(AnalyzerInfoResultV1))] +[JsonSerializable(typeof(ErrorEnvelopeV1))] +[JsonSerializable(typeof(HelpEnvelopeV1))] +internal partial class WinUiJsonContext : JsonSerializerContext { } diff --git a/src/tools/winui-cli/schemas/manifest.json b/src/tools/winui-cli/schemas/manifest.json new file mode 100644 index 0000000..e6b301e --- /dev/null +++ b/src/tools/winui-cli/schemas/manifest.json @@ -0,0 +1,31 @@ +{ + "generated_from": "winui.dll", + "schema_count": 5, + "schemas": [ + { + "name": "winui.analyzer.info.v1", + "file": "winui.analyzer.info.v1.schema.json", + "sha256": "64577415ca49b51304abb0d09ab51b9cd72fa52067a344c467b12229f76a3e62" + }, + { + "name": "winui.error.v1", + "file": "winui.error.v1.schema.json", + "sha256": "aa91f13bbc5a537dd0cbd3ea9b64d3a9cf7a0bb438090d48c17f902ae023b57b" + }, + { + "name": "winui.help.v1", + "file": "winui.help.v1.schema.json", + "sha256": "6c050b644067da1936b358b92c518ea243f9e037cf515ff440142863907377eb" + }, + { + "name": "winui.project.build.v1", + "file": "winui.project.build.v1.schema.json", + "sha256": "abefc5a33354d2f85aeb3c78b22a7d3f9d08705e9a0912e52aaf3ffd909a2008" + }, + { + "name": "winui.text-result.v1", + "file": "winui.text-result.v1.schema.json", + "sha256": "cbd3f61ddd2050586bd9222556f8db457739edb5c596a6e0c047a9c04b0398fd" + } + ] +} diff --git a/src/tools/winui-cli/schemas/winui.analyzer.info.v1.schema.json b/src/tools/winui-cli/schemas/winui.analyzer.info.v1.schema.json new file mode 100644 index 0000000..60d8caf --- /dev/null +++ b/src/tools/winui-cli/schemas/winui.analyzer.info.v1.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://aka.ms/win-dev-skills/schemas/winui.analyzer.info.v1.schema.json", + "title": "winui.analyzer.info.v1", + "description": "Auto-generated from WinUi.Cli.Schemas.AnalyzerInfoResultV1 in winui-cli. Do not edit by hand.", + "type": "object", + "properties": { + "schema": { + "type": "string", + "const": "winui.analyzer.info.v1" + }, + "version": { + "type": "string" + }, + "rules": { + "type": "array", + "items": { + "type": "string" + } + }, + "embeddedPayloadAvailable": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "schema", + "version", + "rules", + "embeddedPayloadAvailable" + ] +} diff --git a/src/tools/winui-cli/schemas/winui.error.v1.schema.json b/src/tools/winui-cli/schemas/winui.error.v1.schema.json new file mode 100644 index 0000000..bad7dae --- /dev/null +++ b/src/tools/winui-cli/schemas/winui.error.v1.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://aka.ms/win-dev-skills/schemas/winui.error.v1.schema.json", + "title": "winui.error.v1", + "description": "Auto-generated from WinUi.Cli.Schemas.ErrorEnvelopeV1 in winui-cli. Do not edit by hand.", + "type": "object", + "properties": { + "schema": { + "type": "string", + "const": "winui.error.v1" + }, + "error": { + "$ref": "#/$defs/ErrorBodyV1" + } + }, + "additionalProperties": false, + "required": [ + "schema", + "error" + ], + "$defs": { + "ErrorBodyV1": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "code", + "message" + ] + } + } +} diff --git a/src/tools/winui-cli/schemas/winui.help.v1.schema.json b/src/tools/winui-cli/schemas/winui.help.v1.schema.json new file mode 100644 index 0000000..ec560c0 --- /dev/null +++ b/src/tools/winui-cli/schemas/winui.help.v1.schema.json @@ -0,0 +1,77 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://aka.ms/win-dev-skills/schemas/winui.help.v1.schema.json", + "title": "winui.help.v1", + "description": "Auto-generated from WinUi.Cli.Schemas.HelpEnvelopeV1 in winui-cli. Do not edit by hand.", + "type": "object", + "properties": { + "schema": { + "type": "string", + "const": "winui.help.v1" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "usage": { + "type": "string" + }, + "verbs": { + "type": "array", + "items": { + "$ref": "#/$defs/HelpVerbV1" + } + }, + "examples": { + "type": "array", + "items": { + "type": "string" + } + }, + "tip": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false, + "required": [ + "schema", + "name", + "description", + "usage", + "verbs", + "examples" + ], + "$defs": { + "HelpVerbV1": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "usage": { + "type": [ + "string", + "null" + ] + }, + "hidden": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "name", + "description", + "hidden" + ] + } + } +} diff --git a/src/tools/winui-cli/schemas/winui.project.build.v1.schema.json b/src/tools/winui-cli/schemas/winui.project.build.v1.schema.json new file mode 100644 index 0000000..2dcfb63 --- /dev/null +++ b/src/tools/winui-cli/schemas/winui.project.build.v1.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://aka.ms/win-dev-skills/schemas/winui.project.build.v1.schema.json", + "title": "winui.project.build.v1", + "description": "Auto-generated from WinUi.Cli.Schemas.ProjectBuildResultV1 in winui-cli. Do not edit by hand.", + "type": "object", + "properties": { + "schema": { + "type": "string", + "const": "winui.project.build.v1" + }, + "buildSucceeded": { + "type": "boolean" + }, + "runAttempted": { + "type": "boolean" + }, + "outputDirectory": { + "type": [ + "string", + "null" + ] + }, + "exitCode": { + "type": "integer" + } + }, + "additionalProperties": false, + "required": [ + "schema", + "buildSucceeded", + "runAttempted", + "exitCode" + ] +} diff --git a/src/tools/winui-cli/schemas/winui.text-result.v1.schema.json b/src/tools/winui-cli/schemas/winui.text-result.v1.schema.json new file mode 100644 index 0000000..347df3b --- /dev/null +++ b/src/tools/winui-cli/schemas/winui.text-result.v1.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://aka.ms/win-dev-skills/schemas/winui.text-result.v1.schema.json", + "title": "winui.text-result.v1", + "description": "Auto-generated from WinUi.Cli.Schemas.TextResultV1 in winui-cli. Do not edit by hand.", + "type": "object", + "properties": { + "schema": { + "type": "string", + "const": "winui.text-result.v1" + }, + "verb": { + "type": "string" + }, + "exitCode": { + "type": "integer" + }, + "output": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "schema", + "verb", + "exitCode", + "output" + ] +} diff --git a/src/tools/winui-cli/winui-cli.csproj b/src/tools/winui-cli/winui-cli.csproj new file mode 100644 index 0000000..70d34dd --- /dev/null +++ b/src/tools/winui-cli/winui-cli.csproj @@ -0,0 +1,35 @@ + + + Exe + winui + net10.0 + enable + enable + true + win-x64 + true + true + true + true + 0.1.0 + 0.1.0 + Microsoft + Microsoft + winui + Native-AOT WinUI sidecar CLI for API lookup, control search, project build, and analyzer management. + https://github.com/microsoft/win-dev-skills + git + + + + + + + + + + + + + + diff --git a/src/tools/winui-search-lib/winui-search-lib.csproj b/src/tools/winui-search-lib/winui-search-lib.csproj new file mode 100644 index 0000000..643c753 --- /dev/null +++ b/src/tools/winui-search-lib/winui-search-lib.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + true + WinUI.Cli.Search + + + + + + + + + + + diff --git a/src/tools/winui-search/DataLoader.cs b/src/tools/winui-search/DataLoader.cs index 5b8cd76..fb26b0c 100644 --- a/src/tools/winui-search/DataLoader.cs +++ b/src/tools/winui-search/DataLoader.cs @@ -8,35 +8,35 @@ internal static class DataLoader { public static Scenario[] LoadGalleryScenarios() { - using var stream = Assembly.GetExecutingAssembly() + using var stream = typeof(DataLoader).Assembly .GetManifestResourceStream("gallery-scenarios.json")!; return JsonSerializer.Deserialize(stream, JsonContext.Default.ScenarioArray)!; } public static Scenario[] LoadToolkitScenarios() { - using var stream = Assembly.GetExecutingAssembly() + using var stream = typeof(DataLoader).Assembly .GetManifestResourceStream("toolkit-scenarios.json")!; return JsonSerializer.Deserialize(stream, JsonContext.Default.ScenarioArray)!; } public static CorePattern[] LoadCorePatterns() { - using var stream = Assembly.GetExecutingAssembly() + using var stream = typeof(DataLoader).Assembly .GetManifestResourceStream("core-patterns.json")!; return JsonSerializer.Deserialize(stream, JsonContext.Default.CorePatternArray)!; } public static Dictionary LoadGalleryTags() { - using var stream = Assembly.GetExecutingAssembly() + using var stream = typeof(DataLoader).Assembly .GetManifestResourceStream("gallery-tags.json")!; return JsonSerializer.Deserialize(stream, JsonContext.Default.DictionaryStringStringArray)!; } public static Dictionary LoadToolkitTags() { - using var stream = Assembly.GetExecutingAssembly() + using var stream = typeof(DataLoader).Assembly .GetManifestResourceStream("toolkit-tags.json")!; return JsonSerializer.Deserialize(stream, JsonContext.Default.DictionaryStringStringArray)!; } @@ -46,7 +46,7 @@ public static Dictionary LoadToolkitTags() /// auto-extracted tags. Empty/missing → no extra signal. public static Dictionary LoadToolkitKeywords() { - var stream = Assembly.GetExecutingAssembly() + var stream = typeof(DataLoader).Assembly .GetManifestResourceStream("toolkit-keywords.json"); if (stream == null) return new(); using (stream) diff --git a/src/tools/winui-search/GalleryFetcher.cs b/src/tools/winui-search/GalleryFetcher.cs index e8d0f17..b36930a 100644 --- a/src/tools/winui-search/GalleryFetcher.cs +++ b/src/tools/winui-search/GalleryFetcher.cs @@ -9,11 +9,12 @@ internal static partial class GalleryFetcher { private const string ControlInfoUrl = - "https://raw.githubusercontent.com/microsoft/WinUI-Gallery/main/WinUIGallery/Samples/Data/ControlInfoData.json"; - private const string ControlPagesBase = - "https://raw.githubusercontent.com/microsoft/WinUI-Gallery/main/WinUIGallery/Samples/ControlPages/"; - private const string SampleCodeBase = - "https://raw.githubusercontent.com/microsoft/WinUI-Gallery/main/WinUIGallery/Samples/SampleCode/"; + "https://raw.githubusercontent.com/microsoft/WinUI-Gallery/main/WinUIGallery/SampleSupport/Data/ControlInfoData.json"; + // New (post-2024) layout: every control lives in WinUIGallery/Samples// + // with Page.xaml, Page.xaml.cs, and co-located *.txt + // SampleDefinition files. The old ControlPages/ and SampleCode/ trees are gone. + private const string SamplesBase = + "https://raw.githubusercontent.com/microsoft/WinUI-Gallery/main/WinUIGallery/Samples/"; private static readonly TimeSpan CacheTtl = TimeSpan.FromDays(7); @@ -35,12 +36,19 @@ internal static partial class GalleryFetcher [GeneratedRegex(@"")] private static partial Regex FirstXmlCommentRegex(); + /// Legacy attribute, still parsed for cached snapshots and edge cases. [GeneratedRegex(@"CSharpSource=""([^""]+)""", RegexOptions.IgnoreCase)] private static partial Regex CSharpSourceRegex(); + /// Legacy attribute, still parsed for cached snapshots and edge cases. [GeneratedRegex(@"XamlSource=""([^""]+)""", RegexOptions.IgnoreCase)] private static partial Regex XamlSourceRegex(); + /// New (post-2024) attribute: points at a single .txt with sections delimited by + /// "--- header", "--- xaml", and "--- c#". Path is relative to WinUIGallery/Samples/. + [GeneratedRegex(@"SampleDefinition=""([^""]+)""", RegexOptions.IgnoreCase)] + private static partial Regex SampleDefinitionRegex(); + [GeneratedRegex(@"\s*]*>([\s\S]*?)\s*", RegexOptions.IgnoreCase)] private static partial Regex InlineCodeRegex(); @@ -280,10 +288,13 @@ private static async Task> FetchControlPageAsync( await semaphore.WaitAsync(); try { - var pagePath = folder != null - ? $"{folder}/{uniqueId}Page.xaml" - : $"{uniqueId}Page.xaml"; - var url = ControlPagesBase + pagePath; + // Post-2024 layout: Samples//Page.xaml. + // `folder` is kept on the call signature for back-compat with older + // ControlInfoData snapshots that exposed an explicit IsSpecialSection + // Folder override; current upstream data omits it. + var folderName = folder ?? uniqueId; + var pagePath = $"{folderName}/{uniqueId}Page.xaml"; + var url = SamplesBase + pagePath; var response = await Http.GetAsync(url); if (!response.IsSuccessStatusCode) return scenarios; @@ -312,9 +323,12 @@ private static async Task> FetchControlPageAsync( ? ExtractFromCodeBehind(xamlCsContent, block) : null; - // External .txt file — second choice (also templated, but cleaner than inline). - csharp ??= await ExtractCode(block, "CSharp", CSharpSourceRegex()); - string? xaml = await ExtractCode(block, "Xaml", XamlSourceRegex()); + // New (post-2024) layout: single SampleDefinition .txt with --- header/xaml/c# sections. + var (defHeader, defXaml, defCSharp) = await ExtractSampleDefinition(block); + + // External legacy .txt files (XamlSource/CSharpSource) — fallback for cached snapshots. + csharp ??= defCSharp ?? await ExtractCode(block, "CSharp", CSharpSourceRegex()); + string? xaml = defXaml ?? await ExtractCode(block, "Xaml", XamlSourceRegex()); // Inline blocks — last resort (templated with $(...)). csharp ??= ExtractInlineCode(block, "CSharp"); @@ -322,11 +336,15 @@ private static async Task> FetchControlPageAsync( if (csharp == null && xaml == null) continue; - // Fallback header: when upstream omits HeaderText, the first XML comment - // inside the sample is usually a good label (a11y samples in particular - // self-document this way: ). + // Fallback header: prefer ControlExample HeaderText, fall back to the + // SampleDefinition `--- header` section, then the first XML comment in the + // sample (a11y samples in particular self-document this way). // Done BEFORE truncation so the comment isn't lost if the snippet is long. string headerText = rawHeader; + if (string.IsNullOrEmpty(headerText) && !string.IsNullOrEmpty(defHeader)) + { + headerText = defHeader!; + } if (string.IsNullOrEmpty(headerText) && xaml != null) { headerText = DeriveHeaderFromComment(xaml); @@ -580,13 +598,17 @@ private static string TruncateCode(string code, int maxChars, string marker) if (!sourceMatch.Success) return null; var relativePath = sourceMatch.Groups[1].Value.Replace('\\', '/'); - var url = SampleCodeBase + relativePath; + // Legacy XamlSource/CSharpSource paths were rooted at WinUIGallery/Samples/SampleCode/. + // Upstream consolidated everything into per-control Samples// in 2024. + // We try the consolidated path first, then fall back to the legacy SampleCode/ + // tree in case a cached snapshot still references it. + var primary = SamplesBase + relativePath; + var legacy = SamplesBase + "SampleCode/" + relativePath; try { - var response = await Http.GetAsync(url); - if (!response.IsSuccessStatusCode) return null; - var code = await response.Content.ReadAsStringAsync(); + var code = await TryFetchString(primary) ?? await TryFetchString(legacy); + if (code == null) return null; // Reject the templated `_cs.txt` files that ship with WinUI Gallery — // they're explanatory prose with method-name comments, not compileable code // (e.g. `// ... Methods ...`, `// CustomDataObject class definition:`). @@ -597,6 +619,72 @@ private static string TruncateCode(string code, int maxChars, string marker) catch { return null; } } + private static async Task TryFetchString(string url) + { + try + { + var response = await Http.GetAsync(url); + if (!response.IsSuccessStatusCode) return null; + return await response.Content.ReadAsStringAsync(); + } + catch { return null; } + } + + /// Parse the new (post-2024) SampleDefinition .txt format. The file has + /// sections delimited by lines beginning with `--- header`, `--- xaml`, `--- c#`, + /// and (rarely) `--- options`. Returns (header, xaml, c#) — any element may be null + /// if the SampleDefinition attribute is absent or the .txt cannot be fetched. + private static async Task<(string? header, string? xaml, string? csharp)> ExtractSampleDefinition(string block) + { + var match = SampleDefinitionRegex().Match(block); + if (!match.Success) return (null, null, null); + + var relativePath = match.Groups[1].Value.Replace('\\', '/'); + var url = SamplesBase + relativePath; + var content = await TryFetchString(url); + if (string.IsNullOrEmpty(content)) return (null, null, null); + + string? header = null, xaml = null, csharp = null; + string? currentSection = null; + var buf = new System.Text.StringBuilder(); + + void Flush() + { + if (currentSection == null) return; + var value = buf.ToString().Trim(); + if (value.Length == 0) { buf.Clear(); return; } + switch (currentSection) + { + case "header": header ??= value; break; + case "xaml": xaml ??= value; break; + case "c#": + case "cs": + case "csharp": + csharp ??= value; break; + } + buf.Clear(); + } + + foreach (var rawLine in content.Replace("\r\n", "\n").Split('\n')) + { + if (rawLine.StartsWith("---", StringComparison.Ordinal)) + { + Flush(); + currentSection = rawLine.TrimStart('-').Trim().ToLowerInvariant(); + continue; + } + if (currentSection != null) buf.AppendLine(rawLine); + } + Flush(); + + // C# section in SampleDefinitions is method-body sketches (real handlers without + // class scaffolding). Pass through CleanGalleryContent so $(...) substitutions + // get normalized like the rest of the pipeline. + if (xaml != null) xaml = CleanGalleryContent(xaml); + if (csharp != null) csharp = CleanGalleryContent(csharp); + return (header, xaml, csharp); + } + /// True if a Gallery `_cs.txt` is just a doc-style stub, not real compileable code. private static bool IsExplanatoryStub(string code) { diff --git a/src/tools/winui-search/Program.cs b/src/tools/winui-search/Program.cs index 70e4a64..53de426 100644 --- a/src/tools/winui-search/Program.cs +++ b/src/tools/winui-search/Program.cs @@ -1,9 +1,26 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -internal class Program +public static class WinUiSearchRunner { - private static int Main(string[] args) + public static int Run(string[] args, TextWriter stdout, TextWriter stderr, bool json) + { + TextWriter oldOut = Console.Out; + TextWriter oldErr = Console.Error; + try + { + Console.SetOut(stdout); + Console.SetError(stderr); + return Run(args); + } + finally + { + Console.SetOut(oldOut); + Console.SetError(oldErr); + } + } + + public static int Run(string[] args) { if (args.Length == 0) { @@ -165,7 +182,13 @@ private static int RunSearch(SearchEngine engine, string[] args) { if (args[i] == "--max" && i + 1 < args.Length) { - int.TryParse(args[++i], out max); + var raw = args[++i]; + if (!int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsed) || parsed <= 0) + { + Console.Error.WriteLine($"--max must be a positive integer (got: {raw})"); + return 1; + } + max = parsed; } else if (args[i] == "--source" && i + 1 < args.Length) { @@ -177,15 +200,20 @@ private static int RunSearch(SearchEngine engine, string[] args) return 1; } } - else if (!args[i].StartsWith('-')) + else if (!args[i].StartsWith('-') && !string.IsNullOrWhiteSpace(args[i])) { queries.Add(args[i]); } + else if (!args[i].StartsWith('-')) + { + Console.Error.WriteLine("Search query cannot be empty."); + return 1; + } } if (queries.Count == 0) { - Console.Error.WriteLine("Usage: winui-search search \"\" [\"\" ...] [--max N] [--source gallery|toolkit|core]"); + Console.Error.WriteLine($"Usage: {WinUiSearchInvocation.CommandPrefix} search \"\" [\"\" ...] [--max N] [--source gallery|toolkit|core]"); Console.Error.WriteLine(" Pass each query as a SEPARATE quoted argument to batch them in one call."); return 1; } @@ -215,7 +243,7 @@ private static int RunSearch(SearchEngine engine, string[] args) int qrc = EmitSearch(engine, queries[i], max, header: true, queryIndex: i + 1, shownControls: shownControls, sourceFilter: sourceFilter); if (qrc != 0) rc = qrc; } - Console.WriteLine("To get full code, pass one or more IDs to: winui-search get [ ...]"); + Console.WriteLine($"To get full code, pass one or more IDs to: {WinUiSearchInvocation.CommandPrefix} get [ ...]"); return rc; } @@ -282,7 +310,7 @@ private static int EmitSearch( Console.WriteLine($" {row.Id}: {compactHeader}{rowDesc}"); } } - if (!header) Console.WriteLine("To get full code: winui-search get [ ...]"); + if (!header) Console.WriteLine($"To get full code: {WinUiSearchInvocation.CommandPrefix} get [ ...]"); return 0; } @@ -328,7 +356,7 @@ private static int RunGet(SearchEngine engine, string[] args) { if (args.Length == 0) { - Console.Error.WriteLine("Usage: winui-search get [ ...]"); + Console.Error.WriteLine($"Usage: {WinUiSearchInvocation.CommandPrefix} get [ ...]"); Console.Error.WriteLine(" Pass multiple ids to batch them in one call."); return 1; } @@ -337,7 +365,7 @@ private static int RunGet(SearchEngine engine, string[] args) if (args.Length == 1) { var (formatted, found) = engine.GetPattern(args[0]); - Console.WriteLine(formatted); + (found ? Console.Out : Console.Error).WriteLine(formatted); return found ? 0 : 1; } @@ -353,7 +381,7 @@ private static int RunGet(SearchEngine engine, string[] args) Console.WriteLine("---"); } var (formatted, found) = engine.GetPattern(args[i]); - Console.WriteLine(formatted); + (found ? Console.Out : Console.Error).WriteLine(formatted); if (!found) anyMissing = true; } return anyMissing ? 1 : 0; @@ -424,7 +452,7 @@ private static void ForceFetch() private static int PrintUsage() { - Console.WriteLine("winui-search - WinUI 3 control pattern search"); + Console.WriteLine($"{WinUiSearchInvocation.CommandPrefix} - WinUI 3 control pattern search"); Console.WriteLine(); Console.WriteLine("Commands:"); Console.WriteLine(" search \"\" [\"\" ...] [--max N] [--source S] Search controls (batch one focused query per feature)"); @@ -436,13 +464,19 @@ private static int PrintUsage() Console.WriteLine(" --source S Restrict to one of: gallery, toolkit, core (applies to search + list)"); Console.WriteLine(); Console.WriteLine("Examples:"); - Console.WriteLine(" winui-search search \"tabbed document interface\" \"settings card\" \"info bar status\""); - Console.WriteLine(" winui-search search \"file picker\" --source core"); - Console.WriteLine(" winui-search list --source toolkit"); - Console.WriteLine(" winui-search get gallery-tabview-1 toolkit-settingscard-9 gallery-infobar-1"); - Console.WriteLine(" winui-search get jumplist-recent-files"); - Console.WriteLine(" winui-search debug \"settings card with toggle\""); + var p = WinUiSearchInvocation.CommandPrefix; + Console.WriteLine($" {p} search \"tabbed document interface\" \"settings card\" \"info bar status\""); + Console.WriteLine($" {p} search \"file picker\" --source core"); + Console.WriteLine($" {p} list --source toolkit"); + Console.WriteLine($" {p} get gallery-tabview-1 toolkit-settingscard-9 gallery-infobar-1"); + Console.WriteLine($" {p} get jumplist-recent-files"); + Console.WriteLine($" {p} debug \"settings card with toggle\""); return 1; } } + +internal sealed class Program +{ + private static int Main(string[] args) => WinUiSearchRunner.Run(args); +} diff --git a/src/tools/winui-search/WinUiSearchInvocation.cs b/src/tools/winui-search/WinUiSearchInvocation.cs new file mode 100644 index 0000000..1f9bba4 --- /dev/null +++ b/src/tools/winui-search/WinUiSearchInvocation.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Single source of truth for the user-facing command prefix in winui-search +// hint/usage strings. Defaults to "winui-search" when invoked as the standalone +// exe; the winui-cli sidecar sets this to "winui controls" before dispatching +// so messages like "Usage: get " stay accurate across both +// invocation paths. +public static class WinUiSearchInvocation +{ + public static string CommandPrefix { get; set; } = "winui-search"; +}