From f91c1a5b4b237d5276c7437c4acda3e76e0e46fb Mon Sep 17 00:00:00 2001 From: Gordon Lam <73506701+yeelam-gordon@users.noreply.github.com> Date: Wed, 4 Mar 2026 06:37:13 +0800 Subject: [PATCH] feat: add winmd-api-search skill for Windows desktop API discovery (#860) * feat: add winmd-api-search skill for Windows desktop API discovery Add a skill that helps find and explore Windows desktop APIs (WinRT/WinAppSDK). It searches a local WinMD metadata cache to discover APIs for platform capabilities like camera, file access, notifications, UI controls, AI/ML, sensors, and networking. Includes bundled scripts: - Update-WinMdCache.ps1: generates the JSON cache from SDK and NuGet packages - Invoke-WinMdQuery.ps1: searches types, members, enums, and namespaces - cache-generator: .NET tool that parses WinMD files into JSON Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: sync winmd-api-search with latest upstream changes Updates from PowerToys source branch: - Detect installed WinAppSDK runtime via Get-AppxPackage - Respect NUGET_PACKAGES env var for global packages path - Use OS architecture for runtime package detection - Fix method/event visibility to use MemberAccessMask equality - Fix EnumerationOptions, TypeSpecification decoding, search sort - Robust scan with case-insensitive dedup and multi-path search - Deduplicate packages by (Id, Version) - Fix assets.json selection and pin SRM version - Fix SDK version sorting and global namespace handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: remove en-us locale from Microsoft Learn URLs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: filter assets.json by package type; unique scan-mode manifests - Filter assets.json libraries by type==package to skip project references - Append short path hash to manifest names in scan mode to avoid collisions - Support prefix match in query script for scan-mode manifest names Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/README.skills.md | 1 + skills/winmd-api-search/LICENSE.txt | 21 + skills/winmd-api-search/SKILL.md | 192 +++ .../scripts/Invoke-WinMdQuery.ps1 | 505 +++++++ .../scripts/Update-WinMdCache.ps1 | 208 +++ .../cache-generator/CacheGenerator.csproj | 29 + .../cache-generator/Directory.Build.props | 3 + .../cache-generator/Directory.Build.targets | 3 + .../cache-generator/Directory.Packages.props | 3 + .../scripts/cache-generator/Program.cs | 1222 +++++++++++++++++ 10 files changed, 2187 insertions(+) create mode 100644 skills/winmd-api-search/LICENSE.txt create mode 100644 skills/winmd-api-search/SKILL.md create mode 100644 skills/winmd-api-search/scripts/Invoke-WinMdQuery.ps1 create mode 100644 skills/winmd-api-search/scripts/Update-WinMdCache.ps1 create mode 100644 skills/winmd-api-search/scripts/cache-generator/CacheGenerator.csproj create mode 100644 skills/winmd-api-search/scripts/cache-generator/Directory.Build.props create mode 100644 skills/winmd-api-search/scripts/cache-generator/Directory.Build.targets create mode 100644 skills/winmd-api-search/scripts/cache-generator/Directory.Packages.props create mode 100644 skills/winmd-api-search/scripts/cache-generator/Program.cs diff --git a/docs/README.skills.md b/docs/README.skills.md index 58f43ed3..9eb8391b 100644 --- a/docs/README.skills.md +++ b/docs/README.skills.md @@ -227,5 +227,6 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-skills) for guidelines on how to | [webapp-testing](../skills/webapp-testing/SKILL.md) | Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs. | `test-helper.js` | | [what-context-needed](../skills/what-context-needed/SKILL.md) | Ask Copilot what files it needs to see before answering a question | None | | [winapp-cli](../skills/winapp-cli/SKILL.md) | Windows App Development CLI (winapp) for building, packaging, and deploying Windows applications. Use when asked to initialize Windows app projects, create MSIX packages, generate AppxManifest.xml, manage development certificates, add package identity for debugging, sign packages, publish to the Microsoft Store, create external catalogs, or access Windows SDK build tools. Supports .NET (csproj), C++, Electron, Rust, Tauri, and cross-platform frameworks targeting Windows. | None | +| [winmd-api-search](../skills/winmd-api-search/SKILL.md) | Find and explore Windows desktop APIs. Use when building features that need platform capabilities — camera, file access, notifications, UI controls, AI/ML, sensors, networking, etc. Discovers the right API for a task and retrieves full type details (methods, properties, events, enumeration values). | `LICENSE.txt`
`scripts/Invoke-WinMdQuery.ps1`
`scripts/Update-WinMdCache.ps1`
`scripts/cache-generator/CacheGenerator.csproj`
`scripts/cache-generator/Directory.Build.props`
`scripts/cache-generator/Directory.Build.targets`
`scripts/cache-generator/Directory.Packages.props`
`scripts/cache-generator/Program.cs` | | [workiq-copilot](../skills/workiq-copilot/SKILL.md) | Guides the Copilot CLI on how to use the WorkIQ CLI/MCP server to query Microsoft 365 Copilot data (emails, meetings, docs, Teams, people) for live context, summaries, and recommendations. | None | | [write-coding-standards-from-file](../skills/write-coding-standards-from-file/SKILL.md) | Write a coding standards document for a project using the coding styles from the file(s) and/or folder(s) passed as arguments in the prompt. | None | diff --git a/skills/winmd-api-search/LICENSE.txt b/skills/winmd-api-search/LICENSE.txt new file mode 100644 index 00000000..d4b4c80f --- /dev/null +++ b/skills/winmd-api-search/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) Microsoft Corporation. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/skills/winmd-api-search/SKILL.md b/skills/winmd-api-search/SKILL.md new file mode 100644 index 00000000..56110a95 --- /dev/null +++ b/skills/winmd-api-search/SKILL.md @@ -0,0 +1,192 @@ +--- +name: winmd-api-search +description: 'Find and explore Windows desktop APIs. Use when building features that need platform capabilities — camera, file access, notifications, UI controls, AI/ML, sensors, networking, etc. Discovers the right API for a task and retrieves full type details (methods, properties, events, enumeration values).' +license: Complete terms in LICENSE.txt +--- + +# WinMD API Search + +This skill helps you find the right Windows API for any capability and get its full details. It searches a local cache of all WinMD metadata from: + +- **Windows Platform SDK** — all `Windows.*` WinRT APIs (always available, no restore needed) +- **WinAppSDK / WinUI** — bundled as a baseline in the cache generator (always available, no restore needed) +- **NuGet packages** — any additional packages in restored projects that contain `.winmd` files +- **Project-output WinMD** — class libraries (C++/WinRT, C#) that produce `.winmd` as build output + +Even on a fresh clone with no restore or build, you still get full Platform SDK + WinAppSDK coverage. + +## When to Use This Skill + +- User wants to build a feature and you need to find which API provides that capability +- User asks "how do I do X?" where X involves a platform feature (camera, files, notifications, sensors, AI, etc.) +- You need the exact methods, properties, events, or enumeration values of a type before writing code +- You're unsure which control, class, or interface to use for a UI or system task + +## Prerequisites + +- **.NET SDK 8.0 or later** — required to build the cache generator. Install from [dotnet.microsoft.com](https://dotnet.microsoft.com/download) if not available. + +## Cache Setup (Required Before First Use) + +All query and search commands read from a local JSON cache. **You must generate the cache before running any queries.** + +```powershell +# All projects in the repo (recommended for first run) +.\.github\skills\winmd-api-search\scripts\Update-WinMdCache.ps1 + +# Single project +.\.github\skills\winmd-api-search\scripts\Update-WinMdCache.ps1 -ProjectDir +``` + +No project restore or build is needed for baseline coverage (Platform SDK + WinAppSDK). For additional NuGet packages, the project needs `dotnet restore` (which generates `project.assets.json`) or a `packages.config` file. + +Cache is stored at `Generated Files\winmd-cache\`, deduplicated per-package+version. + +### What gets indexed + +| Source | When available | +|--------|----------------| +| Windows Platform SDK | Always (reads from local SDK install) | +| WinAppSDK (latest) | Always (bundled as baseline in cache generator) | +| WinAppSDK Runtime | When installed on the system (detected via `Get-AppxPackage`) | +| Project NuGet packages | After `dotnet restore` or with `packages.config` | +| Project-output `.winmd` | After project build (class libraries that produce WinMD) | + +> **Note:** This cache directory should be in `.gitignore` — it's generated, not source. + +## How to Use + +Pick the path that matches the situation: + +--- + +### Discover — "I don't know which API to use" + +The user describes a capability in their own words. You need to find the right API. + +**0. Ensure the cache exists** + +If the cache hasn't been generated yet, run `Update-WinMdCache.ps1` first — see [Cache Setup](#cache-setup-required-before-first-use) above. + +**1. Translate user language → search keywords** + +Map the user's daily language to programming terms. Try multiple variations: + +| User says | Search keywords to try (in order) | +|-----------|-----------------------------------| +| "take a picture" | `camera`, `capture`, `photo`, `MediaCapture` | +| "load from disk" | `file open`, `picker`, `FileOpen`, `StorageFile` | +| "describe what's in it" | `image description`, `Vision`, `Recognition` | +| "show a popup" | `dialog`, `flyout`, `popup`, `ContentDialog` | +| "drag and drop" | `drag`, `drop`, `DragDrop` | +| "save settings" | `settings`, `ApplicationData`, `LocalSettings` | + +Start with simple everyday words. If results are weak or irrelevant, try the more technical variation. + +**2. Run searches** + +```powershell +.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action search -Query "" +``` + +This returns ranked namespaces with top matching types and the **JSON file path**. + +If results have **low scores (below 60) or are irrelevant**, fall back to searching online documentation: + +1. Use web search to find the right API on Microsoft Learn, for example: + - `site:learn.microsoft.com/uwp/api ` for `Windows.*` APIs + - `site:learn.microsoft.com/windows/windows-app-sdk/api/winrt ` for `Microsoft.*` WinAppSDK APIs +2. Read the documentation pages to identify which type matches the user's requirement. +3. Once you know the type name, come back and use `-Action members` or `-Action enums` to get the exact local signatures. + +**3. Read the JSON to choose the right API** + +Read the file at the path(s) from the top results. The JSON has all types in that namespace — full members, signatures, parameters, return types, enumeration values. + +Read and decide which types and members fit the user's requirement. + +**4. Look up official documentation for context** + +The cache contains only signatures — no descriptions or usage guidance. For explanations, examples, and remarks, look up the type on Microsoft Learn: + +| Namespace prefix | Documentation base URL | +|-----------------|----------------------| +| `Windows.*` | `https://learn.microsoft.com/uwp/api/{fully.qualified.typename}` | +| `Microsoft.*` (WinAppSDK) | `https://learn.microsoft.com/windows/windows-app-sdk/api/winrt/{fully.qualified.typename}` | + +For example, `Microsoft.UI.Xaml.Controls.NavigationView` maps to: +`https://learn.microsoft.com/windows/windows-app-sdk/api/winrt/microsoft.ui.xaml.controls.navigationview` + +**5. Use the API knowledge to answer or write code** + +--- + +### Lookup — "I know the API, show me the details" + +You already know (or suspect) the type or namespace name. Go direct: + +```powershell +# Get all members of a known type +.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action members -TypeName "Microsoft.UI.Xaml.Controls.NavigationView" + +# Get enum values +.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action enums -TypeName "Microsoft.UI.Xaml.Visibility" + +# List all types in a namespace +.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action types -Namespace "Microsoft.UI.Xaml.Controls" + +# Browse namespaces +.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action namespaces -Filter "Microsoft.UI" +``` + +If you need full detail beyond what `-Action members` shows, use `-Action search` to get the JSON file path, then read the JSON file directly. + +--- + +### Other Commands + +```powershell +# List cached projects +.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action projects + +# List packages for a project +.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action packages + +# Show stats +.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action stats +``` + +> If only one project is cached, `-Project` is auto-selected. +> If multiple projects exist, add `-Project ` (use `-Action projects` to see available names). +> In scan mode, manifest names include a short hash suffix to avoid collisions; you can pass the base project name without the suffix if it's unambiguous. + +## Search Scoring + +The search ranks type names and member names against your query: + +| Score | Match type | Example | +|-------|-----------|---------| +| 100 | Exact name | `Button` → `Button` | +| 80 | Starts with | `Navigation` → `NavigationView` | +| 60 | Contains | `Dialog` → `ContentDialog` | +| 50 | PascalCase initials | `ASB` → `AutoSuggestBox` | +| 40 | Multi-keyword AND | `navigation item` → `NavigationViewItem` | +| 20 | Fuzzy character match | `NavVw` → `NavigationView` | + +Results are grouped by namespace. Higher-scored namespaces appear first. + +## Troubleshooting + +| Issue | Fix | +|-------|-----| +| "Cache not found" | Run `Update-WinMdCache.ps1` | +| "Multiple projects cached" | Add `-Project ` | +| "Namespace not found" | Use `-Action namespaces` to list available ones | +| "Type not found" | Use fully qualified name (e.g., `Microsoft.UI.Xaml.Controls.Button`) | +| Stale after NuGet update | Re-run `Update-WinMdCache.ps1` | +| Cache in git history | Add `Generated Files/` to `.gitignore` | + +## References + +- [Windows Platform SDK API reference](https://learn.microsoft.com/uwp/api/) — documentation for `Windows.*` namespaces +- [Windows App SDK API reference](https://learn.microsoft.com/windows/windows-app-sdk/api/winrt/) — documentation for `Microsoft.*` WinAppSDK namespaces diff --git a/skills/winmd-api-search/scripts/Invoke-WinMdQuery.ps1 b/skills/winmd-api-search/scripts/Invoke-WinMdQuery.ps1 new file mode 100644 index 00000000..4ed9e338 --- /dev/null +++ b/skills/winmd-api-search/scripts/Invoke-WinMdQuery.ps1 @@ -0,0 +1,505 @@ +<# +.SYNOPSIS + Query WinMD API metadata from cached JSON files. + +.DESCRIPTION + Reads pre-built JSON cache of WinMD types, members, and namespaces. + The cache is organized per-package (deduplicated) with project manifests + that map each project to its referenced packages. + + Supports listing namespaces, types, members, searching, enum value lookup, + and listing cached projects/packages. + +.PARAMETER Action + The query action to perform: + - projects : List cached projects + - packages : List packages for a project + - stats : Show aggregate statistics for a project + - namespaces : List all namespaces (optional -Filter prefix) + - types : List types in a namespace (-Namespace required) + - members : List members of a type (-TypeName required) + - search : Search types and members by name (-Query required) + - enums : List enum values (-TypeName required) + +.PARAMETER Project + Project name to query. Auto-selected if only one project is cached. + Use -Action projects to list available projects. + +.PARAMETER Namespace + Namespace to query types from (used with -Action types). + +.PARAMETER TypeName + Full type name e.g. "Microsoft.UI.Xaml.Controls.Button" (used with -Action members, enums). + +.PARAMETER Query + Search query string (used with -Action search). + +.PARAMETER Filter + Optional prefix filter for namespaces (used with -Action namespaces). + +.PARAMETER CacheDir + Path to the winmd-cache directory. Defaults to "Generated Files\winmd-cache" + relative to the workspace root. + +.PARAMETER MaxResults + Maximum number of results to return for search. Defaults to 30. + +.EXAMPLE + .\Invoke-WinMdQuery.ps1 -Action projects + .\Invoke-WinMdQuery.ps1 -Action packages -Project BlankWinUI + .\Invoke-WinMdQuery.ps1 -Action stats -Project BlankWinUI + .\Invoke-WinMdQuery.ps1 -Action namespaces -Filter "Microsoft.UI" + .\Invoke-WinMdQuery.ps1 -Action types -Namespace "Microsoft.UI.Xaml.Controls" + .\Invoke-WinMdQuery.ps1 -Action members -TypeName "Microsoft.UI.Xaml.Controls.Button" + .\Invoke-WinMdQuery.ps1 -Action search -Query "NavigationView" + .\Invoke-WinMdQuery.ps1 -Action enums -TypeName "Microsoft.UI.Xaml.Visibility" +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateSet('projects', 'packages', 'stats', 'namespaces', 'types', 'members', 'search', 'enums')] + [string]$Action, + + [string]$Project, + [string]$Namespace, + [string]$TypeName, + [string]$Query, + [string]$Filter, + [string]$CacheDir, + [int]$MaxResults = 30 +) + +# ─── Resolve cache directory ───────────────────────────────────────────────── + +if (-not $CacheDir) { + # Convention: skill lives at .github/skills/winmd-api-search/scripts/ + # so workspace root is 4 levels up from $PSScriptRoot. + $scriptDir = $PSScriptRoot + $root = (Resolve-Path (Join-Path $scriptDir '..\..\..\..')).Path + $CacheDir = Join-Path $root 'Generated Files\winmd-cache' +} + +if (-not (Test-Path $CacheDir)) { + Write-Error "Cache not found at: $CacheDir`nRun: .\Update-WinMdCache.ps1 (from .github\skills\winmd-api-search\scripts\)" + exit 1 +} + +# ─── Project resolution helpers ────────────────────────────────────────────── + +function Get-CachedProjects { + $projectsDir = Join-Path $CacheDir 'projects' + if (-not (Test-Path $projectsDir)) { return @() } + Get-ChildItem $projectsDir -Filter '*.json' | ForEach-Object { $_.BaseName } +} + +function Resolve-ProjectManifest { + param([string]$Name) + + $projectsDir = Join-Path $CacheDir 'projects' + if (-not (Test-Path $projectsDir)) { + Write-Error "No projects cached. Run Update-WinMdCache.ps1 first." + exit 1 + } + + if ($Name) { + $path = Join-Path $projectsDir "$Name.json" + if (-not (Test-Path $path)) { + # Scan mode appends a hash suffix -- try prefix match + $matching = @(Get-ChildItem $projectsDir -Filter "${Name}_*.json" -ErrorAction SilentlyContinue) + if ($matching.Count -eq 1) { + return Get-Content $matching[0].FullName -Raw | ConvertFrom-Json + } + if ($matching.Count -gt 1) { + $names = ($matching | ForEach-Object { $_.BaseName }) -join ', ' + Write-Error "Multiple projects match '$Name'. Specify the full name: $names" + exit 1 + } + $available = (Get-CachedProjects) -join ', ' + Write-Error "Project '$Name' not found. Available: $available" + exit 1 + } + return Get-Content $path -Raw | ConvertFrom-Json + } + + # Auto-select if only one project + $manifests = Get-ChildItem $projectsDir -Filter '*.json' -ErrorAction SilentlyContinue + if ($manifests.Count -eq 0) { + Write-Error "No projects cached. Run Update-WinMdCache.ps1 first." + exit 1 + } + if ($manifests.Count -eq 1) { + return Get-Content $manifests[0].FullName -Raw | ConvertFrom-Json + } + + $available = ($manifests | ForEach-Object { $_.BaseName }) -join ', ' + Write-Error "Multiple projects cached -- use -Project to specify. Available: $available" + exit 1 +} + +function Get-PackageCacheDirs { + param($Manifest) + $dirs = @() + foreach ($pkg in $Manifest.packages) { + $dir = Join-Path (Join-Path (Join-Path $CacheDir 'packages') $pkg.id) $pkg.version + if (Test-Path $dir) { + $dirs += $dir + } + } + return $dirs +} + +# ─── Action: projects ──────────────────────────────────────────────────────── + +function Show-Projects { + $projects = Get-CachedProjects + if ($projects.Count -eq 0) { + Write-Output "No projects cached." + return + } + Write-Output "Cached projects ($($projects.Count)):" + foreach ($p in $projects) { + $manifest = Get-Content (Join-Path (Join-Path $CacheDir 'projects') "$p.json") -Raw | ConvertFrom-Json + $pkgCount = $manifest.packages.Count + Write-Output " $p ($pkgCount package(s))" + } +} + +# ─── Action: packages ──────────────────────────────────────────────────────── + +function Show-Packages { + $manifest = Resolve-ProjectManifest -Name $Project + Write-Output "Packages for project '$($manifest.projectName)' ($($manifest.packages.Count)):" + foreach ($pkg in $manifest.packages) { + $metaPath = Join-Path (Join-Path (Join-Path (Join-Path $CacheDir 'packages') $pkg.id) $pkg.version) 'meta.json' + if (Test-Path $metaPath) { + $meta = Get-Content $metaPath -Raw | ConvertFrom-Json + Write-Output " $($pkg.id)@$($pkg.version) -- $($meta.totalTypes) types, $($meta.totalMembers) members" + } else { + Write-Output " $($pkg.id)@$($pkg.version) -- (cache missing)" + } + } +} + +# ─── Action: stats ─────────────────────────────────────────────────────────── + +function Show-Stats { + $manifest = Resolve-ProjectManifest -Name $Project + $totalTypes = 0 + $totalMembers = 0 + $totalNamespaces = 0 + $totalWinMd = 0 + + foreach ($pkg in $manifest.packages) { + $metaPath = Join-Path (Join-Path (Join-Path (Join-Path $CacheDir 'packages') $pkg.id) $pkg.version) 'meta.json' + if (Test-Path $metaPath) { + $meta = Get-Content $metaPath -Raw | ConvertFrom-Json + $totalTypes += $meta.totalTypes + $totalMembers += $meta.totalMembers + $totalNamespaces += $meta.totalNamespaces + $totalWinMd += $meta.winMdFiles.Count + } + } + + Write-Output "WinMD Index Statistics -- $($manifest.projectName)" + Write-Output "======================================" + Write-Output " Packages: $($manifest.packages.Count)" + Write-Output " Namespaces: $totalNamespaces (may overlap across packages)" + Write-Output " Types: $totalTypes" + Write-Output " Members: $totalMembers" + Write-Output " WinMD files: $totalWinMd" +} + +# ─── Action: namespaces ────────────────────────────────────────────────────── + +function Get-Namespaces { + param([string]$Prefix) + $manifest = Resolve-ProjectManifest -Name $Project + $dirs = Get-PackageCacheDirs -Manifest $manifest + $allNs = @() + + foreach ($dir in $dirs) { + $nsFile = Join-Path $dir 'namespaces.json' + if (Test-Path $nsFile) { + $allNs += (Get-Content $nsFile -Raw | ConvertFrom-Json) + } + } + + $allNs = $allNs | Sort-Object -Unique + if ($Prefix) { + $allNs = $allNs | Where-Object { $_ -like "$Prefix*" } + } + $allNs | ForEach-Object { Write-Output $_ } +} + +# ─── Action: types ─────────────────────────────────────────────────────────── + +function Get-TypesInNamespace { + param([string]$Ns) + if (-not $Ns) { + Write-Error "-Namespace is required for 'types' action." + exit 1 + } + + $manifest = Resolve-ProjectManifest -Name $Project + $dirs = Get-PackageCacheDirs -Manifest $manifest + $safeFile = $Ns.Replace('.', '_') + '.json' + $found = $false + $seen = @{} + + foreach ($dir in $dirs) { + $filePath = Join-Path $dir "types\$safeFile" + if (-not (Test-Path $filePath)) { continue } + $found = $true + $types = Get-Content $filePath -Raw | ConvertFrom-Json + foreach ($t in $types) { + if ($seen.ContainsKey($t.fullName)) { continue } + $seen[$t.fullName] = $true + Write-Output "$($t.kind) $($t.fullName)$(if ($t.baseType) { " : $($t.baseType)" } else { '' })" + } + } + + if (-not $found) { + Write-Error "Namespace not found: $Ns" + exit 1 + } +} + +# ─── Action: members ───────────────────────────────────────────────────────── + +function Get-MembersOfType { + param([string]$FullName) + if (-not $FullName) { + Write-Error "-TypeName is required for 'members' action." + exit 1 + } + + $lastDot = $FullName.LastIndexOf('.') + if ($lastDot -lt 0) { + Write-Error "-TypeName must include a namespace (for example: 'MyNamespace.MyType'). Provided: $FullName" + exit 1 + } + + $ns = $FullName.Substring(0, $lastDot) + $safeFile = $ns.Replace('.', '_') + '.json' + + $manifest = Resolve-ProjectManifest -Name $Project + $dirs = Get-PackageCacheDirs -Manifest $manifest + + foreach ($dir in $dirs) { + $filePath = Join-Path $dir "types\$safeFile" + if (-not (Test-Path $filePath)) { continue } + + $types = Get-Content $filePath -Raw | ConvertFrom-Json + $type = $types | Where-Object { $_.fullName -eq $FullName } + if (-not $type) { continue } + + Write-Output "$($type.kind) $($type.fullName)" + if ($type.baseType) { Write-Output " Extends: $($type.baseType)" } + Write-Output "" + foreach ($m in $type.members) { + Write-Output " [$($m.kind)] $($m.signature)" + } + return + } + + Write-Error "Type not found: $FullName" + exit 1 +} + +# ─── Action: search ────────────────────────────────────────────────────────── +# Ranks namespaces by best match score on type names and member names. +# Outputs: ranked namespaces with top matching types and the JSON file path. +# The agent can then read the JSON file to inspect all members intelligently. + +function Search-WinMd { + param([string]$SearchQuery, [int]$Max) + if (-not $SearchQuery) { + Write-Error "-Query is required for 'search' action." + exit 1 + } + + $manifest = Resolve-ProjectManifest -Name $Project + $dirs = Get-PackageCacheDirs -Manifest $manifest + + # Collect: namespace -> { bestScore, matchingTypes[], filePath } + $nsResults = @{} + + foreach ($dir in $dirs) { + $nsFile = Join-Path $dir 'namespaces.json' + if (-not (Test-Path $nsFile)) { continue } + $nsList = Get-Content $nsFile -Raw | ConvertFrom-Json + + foreach ($n in $nsList) { + $safeFile = $n.Replace('.', '_') + '.json' + $filePath = Join-Path $dir "types\$safeFile" + if (-not (Test-Path $filePath)) { continue } + + $types = Get-Content $filePath -Raw | ConvertFrom-Json + foreach ($t in $types) { + $typeScore = Get-MatchScore -Name $t.name -FullName $t.fullName -Query $SearchQuery + + # Also search member names for matches + $bestMemberScore = 0 + $matchingMember = $null + if ($t.members) { + foreach ($m in $t.members) { + $memberName = $m.name + $mScore = Get-MatchScore -Name $memberName -FullName "$($t.fullName).$memberName" -Query $SearchQuery + if ($mScore -gt $bestMemberScore) { + $bestMemberScore = $mScore + $matchingMember = $m.signature + } + } + } + + $score = [Math]::Max($typeScore, $bestMemberScore) + if ($score -le 0) { continue } + + if (-not $nsResults.ContainsKey($n)) { + $nsResults[$n] = @{ BestScore = 0; Types = @(); FilePaths = @() } + } + $entry = $nsResults[$n] + if ($score -gt $entry.BestScore) { $entry.BestScore = $score } + if ($entry.FilePaths -notcontains $filePath) { + $entry.FilePaths += $filePath + } + + if ($typeScore -ge $bestMemberScore) { + $entry.Types += @{ Text = "$($t.kind) $($t.fullName) [$typeScore]"; Score = $typeScore } + } else { + $entry.Types += @{ Text = "$($t.kind) $($t.fullName) -> $matchingMember [$bestMemberScore]"; Score = $bestMemberScore } + } + } + } + } + + if ($nsResults.Count -eq 0) { + Write-Output "No results found for: $SearchQuery" + return + } + + $ranked = $nsResults.GetEnumerator() | + Sort-Object { $_.Value.BestScore } -Descending | + Select-Object -First $Max + + foreach ($r in $ranked) { + $ns = $r.Key + $info = $r.Value + Write-Output "[$($info.BestScore)] $ns" + foreach ($fp in $info.FilePaths) { + Write-Output " File: $fp" + } + # Show top 5 highest-scoring matching types in this namespace + $info.Types | Sort-Object { $_.Score } -Descending | + Select-Object -First 5 | + ForEach-Object { Write-Output " $($_.Text)" } + Write-Output "" + } +} + +# ─── Search scoring ────────────────────────────────────────────────────────── +# Simple ranked scoring on type names. Higher = better. +# 100 = exact name 80 = starts-with 60 = substring +# 50 = PascalCase 40 = multi-keyword 20 = fuzzy subsequence + +function Get-MatchScore { + param([string]$Name, [string]$FullName, [string]$Query) + + $q = $Query.Trim() + if (-not $q) { return 0 } + + if ($Name -eq $q) { return 100 } + if ($Name -like "$q*") { return 80 } + if ($Name -like "*$q*" -or $FullName -like "*$q*") { return 60 } + + $initials = ($Name.ToCharArray() | Where-Object { [char]::IsUpper($_) }) -join '' + if ($initials.Length -ge 2 -and $initials -like "*$q*") { return 50 } + + $words = $q -split '\s+' | Where-Object { $_.Length -gt 0 } + if ($words.Count -gt 1) { + $allFound = $true + foreach ($w in $words) { + if ($Name -notlike "*$w*" -and $FullName -notlike "*$w*") { + $allFound = $false + break + } + } + if ($allFound) { return 40 } + } + + if (Test-FuzzySubsequence -Text $Name -Pattern $q) { return 20 } + + return 0 +} + +function Test-FuzzySubsequence { + param([string]$Text, [string]$Pattern) + $ti = 0 + $tLower = $Text.ToLowerInvariant() + $pLower = $Pattern.ToLowerInvariant() + foreach ($ch in $pLower.ToCharArray()) { + $idx = $tLower.IndexOf($ch, $ti) + if ($idx -lt 0) { return $false } + $ti = $idx + 1 + } + return $true +} + +# ─── Action: enums ─────────────────────────────────────────────────────────── + +function Get-EnumValues { + param([string]$FullName) + if (-not $FullName) { + Write-Error "-TypeName is required for 'enums' action." + exit 1 + } + + $lastDot = $FullName.LastIndexOf('.') + if ($lastDot -lt 1) { + Write-Error "-TypeName must be a fully-qualified type name including namespace, e.g. 'Namespace.TypeName'. Provided: $FullName" + exit 1 + } + + $ns = $FullName.Substring(0, $lastDot) + $safeFile = $ns.Replace('.', '_') + '.json' + + $manifest = Resolve-ProjectManifest -Name $Project + $dirs = Get-PackageCacheDirs -Manifest $manifest + + foreach ($dir in $dirs) { + $filePath = Join-Path $dir "types\$safeFile" + if (-not (Test-Path $filePath)) { continue } + + $types = Get-Content $filePath -Raw | ConvertFrom-Json + $type = $types | Where-Object { $_.fullName -eq $FullName } + if (-not $type) { continue } + + if ($type.kind -ne 'Enum') { + Write-Error "$FullName is not an Enum (kind: $($type.kind))" + exit 1 + } + Write-Output "Enum $($type.fullName)" + if ($type.enumValues) { + $type.enumValues | ForEach-Object { Write-Output " $_" } + } else { + Write-Output " (no values)" + } + return + } + + Write-Error "Type not found: $FullName" + exit 1 +} + +# ─── Dispatch ───────────────────────────────────────────────────────────────── + +switch ($Action) { + 'projects' { Show-Projects } + 'packages' { Show-Packages } + 'stats' { Show-Stats } + 'namespaces' { Get-Namespaces -Prefix $Filter } + 'types' { Get-TypesInNamespace -Ns $Namespace } + 'members' { Get-MembersOfType -FullName $TypeName } + 'search' { Search-WinMd -SearchQuery $Query -Max $MaxResults } + 'enums' { Get-EnumValues -FullName $TypeName } +} diff --git a/skills/winmd-api-search/scripts/Update-WinMdCache.ps1 b/skills/winmd-api-search/scripts/Update-WinMdCache.ps1 new file mode 100644 index 00000000..11cb16a8 --- /dev/null +++ b/skills/winmd-api-search/scripts/Update-WinMdCache.ps1 @@ -0,0 +1,208 @@ +<# +.SYNOPSIS + Generate or refresh the WinMD cache for the Agent Skill. + +.DESCRIPTION + Builds and runs the standalone cache generator to export cached JSON files + from all WinMD metadata found in project NuGet packages and Windows SDK. + + The cache is per-package+version: if two projects reference the same + package at the same version, the WinMD data is parsed once and shared. + + Supports single project or recursive scan of an entire repo. + +.PARAMETER ProjectDir + Path to a project directory (contains .csproj/.vcxproj), or a project file itself. + Defaults to scanning the workspace root. + +.PARAMETER Scan + Recursively discover all .csproj/.vcxproj files under ProjectDir. + +.PARAMETER OutputDir + Path to the cache output directory. Defaults to "Generated Files\winmd-cache". + +.EXAMPLE + .\Update-WinMdCache.ps1 + .\Update-WinMdCache.ps1 -ProjectDir BlankWinUI + .\Update-WinMdCache.ps1 -Scan -ProjectDir . + .\Update-WinMdCache.ps1 -ProjectDir "src\MyApp\MyApp.csproj" +#> +[CmdletBinding()] +param( + [string]$ProjectDir, + [switch]$Scan, + [string]$OutputDir = 'Generated Files\winmd-cache' +) + +$ErrorActionPreference = 'Stop' + +# Convention: skill lives at .github/skills/winmd-api-search/scripts/ +# so workspace root is 4 levels up from $PSScriptRoot. +$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..\..')).Path +$generatorProj = Join-Path (Join-Path $PSScriptRoot 'cache-generator') 'CacheGenerator.csproj' + +# --------------------------------------------------------------------------- +# WinAppSDK version detection -- look only at the repo root folder (no recursion) +# --------------------------------------------------------------------------- + +function Get-WinAppSdkVersionFromDirectoryPackagesProps { + <# + .SYNOPSIS + Extract Microsoft.WindowsAppSDK version from a Directory.Packages.props + (Central Package Management) at the repo root. + #> + param([string]$RepoRoot) + $propsFile = Join-Path $RepoRoot 'Directory.Packages.props' + if (-not (Test-Path $propsFile)) { return $null } + try { + [xml]$xml = Get-Content $propsFile -Raw + $node = $xml.SelectNodes('//PackageVersion') | + Where-Object { $_.Include -eq 'Microsoft.WindowsAppSDK' } | + Select-Object -First 1 + if ($node) { return $node.Version } + } catch { + Write-Verbose "Could not parse $propsFile : $_" + } + return $null +} + +function Get-WinAppSdkVersionFromPackagesConfig { + <# + .SYNOPSIS + Extract Microsoft.WindowsAppSDK version from a packages.config at the repo root. + #> + param([string]$RepoRoot) + $configFile = Join-Path $RepoRoot 'packages.config' + if (-not (Test-Path $configFile)) { return $null } + try { + [xml]$xml = Get-Content $configFile -Raw + $node = $xml.SelectNodes('//package') | + Where-Object { $_.id -eq 'Microsoft.WindowsAppSDK' } | + Select-Object -First 1 + if ($node) { return $node.version } + } catch { + Write-Verbose "Could not parse $configFile : $_" + } + return $null +} + +# Try Directory.Packages.props first (CPM), then packages.config +$winAppSdkVersion = Get-WinAppSdkVersionFromDirectoryPackagesProps -RepoRoot $root +if (-not $winAppSdkVersion) { + $winAppSdkVersion = Get-WinAppSdkVersionFromPackagesConfig -RepoRoot $root +} +if ($winAppSdkVersion) { + Write-Host "Detected WinAppSDK version from repo: $winAppSdkVersion" -ForegroundColor Cyan +} else { + Write-Host "No WinAppSDK version found at repo root; will use latest (Version=*)" -ForegroundColor Yellow +} + +# Default: if no ProjectDir, scan the workspace root +if (-not $ProjectDir) { + $ProjectDir = $root + $Scan = $true +} + +Push-Location $root + +try { + # Detect installed .NET SDK -- require >= 8.0, prefer stable over preview + $dotnetSdks = dotnet --list-sdks 2>$null + $bestMajor = $dotnetSdks | + Where-Object { $_ -notmatch 'preview|rc|alpha|beta' } | + ForEach-Object { if ($_ -match '^(\d+)\.') { [int]$Matches[1] } } | + Where-Object { $_ -ge 8 } | + Sort-Object -Descending | + Select-Object -First 1 + + # Fall back to preview SDKs if no stable SDK found + if (-not $bestMajor) { + $bestMajor = $dotnetSdks | + ForEach-Object { if ($_ -match '^(\d+)\.') { [int]$Matches[1] } } | + Where-Object { $_ -ge 8 } | + Sort-Object -Descending | + Select-Object -First 1 + } + + if (-not $bestMajor) { + Write-Error "No .NET SDK >= 8.0 found. Install from https://dotnet.microsoft.com/download" + exit 1 + } + + $targetFramework = "net$bestMajor.0" + Write-Host "Using .NET SDK: $targetFramework" -ForegroundColor Cyan + + # Build MSBuild properties -- pass detected WinAppSDK version when available + $sdkVersionProp = '' + if ($winAppSdkVersion) { + $sdkVersionProp = "-p:WinAppSdkVersion=$winAppSdkVersion" + } + + Write-Host "Building cache generator..." -ForegroundColor Cyan + $restoreArgs = @($generatorProj, "-p:TargetFramework=$targetFramework", '--nologo', '-v', 'q') + if ($sdkVersionProp) { $restoreArgs += $sdkVersionProp } + dotnet restore @restoreArgs + if ($LASTEXITCODE -ne 0) { + Write-Error "Restore failed" + exit 1 + } + $buildArgs = @($generatorProj, '-c', 'Release', '--nologo', '-v', 'q', "-p:TargetFramework=$targetFramework", '--no-restore') + if ($sdkVersionProp) { $buildArgs += $sdkVersionProp } + dotnet build @buildArgs + if ($LASTEXITCODE -ne 0) { + Write-Error "Build failed" + exit 1 + } + + # Run the built executable directly (avoids dotnet run target framework mismatch issues) + $generatorDir = Join-Path $PSScriptRoot 'cache-generator' + $exePath = Join-Path $generatorDir "bin\Release\$targetFramework\CacheGenerator.exe" + if (-not (Test-Path $exePath)) { + # Fallback: try dll with dotnet + $dllPath = Join-Path $generatorDir "bin\Release\$targetFramework\CacheGenerator.dll" + if (Test-Path $dllPath) { + $exePath = $null + } else { + Write-Error "Built executable not found at: $exePath" + exit 1 + } + } + + $runArgs = @() + if ($Scan) { + $runArgs += '--scan' + } + + # Detect installed WinAppSDK runtime via Get-AppxPackage (the WindowsApps + # folder is ACL-restricted so C# cannot enumerate it directly). + # WinMD files are architecture-independent metadata, so pick whichever arch + # matches the current OS to ensure the package is present. + $osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + $runtimePkg = Get-AppxPackage -Name 'Microsoft.WindowsAppRuntime.*' -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notmatch 'CBS' -and $_.Architecture -eq $osArch } | + Sort-Object -Property Version -Descending | + Select-Object -First 1 + if ($runtimePkg -and $runtimePkg.InstallLocation -and (Test-Path $runtimePkg.InstallLocation)) { + Write-Host "Detected WinAppSDK runtime: $($runtimePkg.Name) v$($runtimePkg.Version)" -ForegroundColor Cyan + $runArgs += '--winappsdk-runtime' + $runArgs += $runtimePkg.InstallLocation + } + + $runArgs += $ProjectDir + $runArgs += $OutputDir + + Write-Host "Exporting WinMD cache..." -ForegroundColor Cyan + if ($exePath) { + & $exePath @runArgs + } else { + dotnet $dllPath @runArgs + } + if ($LASTEXITCODE -ne 0) { + Write-Error "Cache export failed" + exit 1 + } + + Write-Host "Cache updated at: $OutputDir" -ForegroundColor Green +} finally { + Pop-Location +} diff --git a/skills/winmd-api-search/scripts/cache-generator/CacheGenerator.csproj b/skills/winmd-api-search/scripts/cache-generator/CacheGenerator.csproj new file mode 100644 index 00000000..63b52471 --- /dev/null +++ b/skills/winmd-api-search/scripts/cache-generator/CacheGenerator.csproj @@ -0,0 +1,29 @@ + + + Exe + + net8.0 + enable + enable + + + + + + + + + + + + + + diff --git a/skills/winmd-api-search/scripts/cache-generator/Directory.Build.props b/skills/winmd-api-search/scripts/cache-generator/Directory.Build.props new file mode 100644 index 00000000..c6262dfd --- /dev/null +++ b/skills/winmd-api-search/scripts/cache-generator/Directory.Build.props @@ -0,0 +1,3 @@ + + + diff --git a/skills/winmd-api-search/scripts/cache-generator/Directory.Build.targets b/skills/winmd-api-search/scripts/cache-generator/Directory.Build.targets new file mode 100644 index 00000000..a776d08e --- /dev/null +++ b/skills/winmd-api-search/scripts/cache-generator/Directory.Build.targets @@ -0,0 +1,3 @@ + + + diff --git a/skills/winmd-api-search/scripts/cache-generator/Directory.Packages.props b/skills/winmd-api-search/scripts/cache-generator/Directory.Packages.props new file mode 100644 index 00000000..4c6affab --- /dev/null +++ b/skills/winmd-api-search/scripts/cache-generator/Directory.Packages.props @@ -0,0 +1,3 @@ + + + diff --git a/skills/winmd-api-search/scripts/cache-generator/Program.cs b/skills/winmd-api-search/scripts/cache-generator/Program.cs new file mode 100644 index 00000000..9ce7efcc --- /dev/null +++ b/skills/winmd-api-search/scripts/cache-generator/Program.cs @@ -0,0 +1,1222 @@ +// Standalone WinMD cache generator — per-package deduplicate, multi-project support. +// Parses WinMD files from NuGet packages and Windows SDK, exports JSON cache +// keyed by package+version to avoid duplication across projects. +// +// Usage: +// CacheGenerator +// CacheGenerator --scan + +using System.Collections.Immutable; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Security.Cryptography; +using System.Xml.Linq; + +// --- Arg parsing --- + +var scanMode = args.Contains("--scan"); + +// Parse --winappsdk-runtime option +string? winAppSdkRuntimePath = null; +for (int i = 0; i < args.Length - 1; i++) +{ + if (args[i].Equals("--winappsdk-runtime", StringComparison.OrdinalIgnoreCase)) + { + winAppSdkRuntimePath = args[i + 1]; + break; + } +} + +var positionalArgs = args + .Where(a => !a.StartsWith('-')) + .Where(a => a != winAppSdkRuntimePath) // exclude the runtime path value + .ToArray(); + +if (positionalArgs.Length < 2) +{ + Console.Error.WriteLine("Usage:"); + Console.Error.WriteLine(" CacheGenerator "); + Console.Error.WriteLine(" CacheGenerator --scan "); + Console.Error.WriteLine(" CacheGenerator --winappsdk-runtime "); + Console.Error.WriteLine(); + Console.Error.WriteLine(" project-dir: Path containing .csproj/.vcxproj (or a project file itself)"); + Console.Error.WriteLine(" root-dir: Root to scan recursively for project files"); + Console.Error.WriteLine(" output-dir: Cache output (e.g. \"Generated Files\\winmd-cache\")"); + Console.Error.WriteLine(" --winappsdk-runtime: Path to installed WinAppSDK runtime (from Get-AppxPackage)"); + return 1; +} + +var inputPath = Path.GetFullPath(positionalArgs[0]); +var outputDir = Path.GetFullPath(positionalArgs[1]); + +var jsonOptions = new JsonSerializerOptions +{ + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() }, +}; + +// --- Discover project files --- + +var projectFiles = new List(); + +if (scanMode) +{ + if (!Directory.Exists(inputPath)) + { + Console.Error.WriteLine($"Error: Root directory not found: {inputPath}"); + return 1; + } + + var enumerationOptions = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + MatchType = MatchType.Simple, + }; + + projectFiles.AddRange(Directory.EnumerateFiles(inputPath, "*.csproj", enumerationOptions)); + projectFiles.AddRange(Directory.EnumerateFiles(inputPath, "*.vcxproj", enumerationOptions)); + + // Exclude common non-source directories + projectFiles = projectFiles + .Where(f => !f.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)) + .Where(f => !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)) + .Where(f => !f.Contains($"{Path.DirectorySeparatorChar}node_modules{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase)) + .ToList(); +} +else if (File.Exists(inputPath) && (inputPath.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase) || + inputPath.EndsWith(".vcxproj", StringComparison.OrdinalIgnoreCase))) +{ + projectFiles.Add(inputPath); +} +else if (Directory.Exists(inputPath)) +{ + projectFiles.AddRange(Directory.GetFiles(inputPath, "*.csproj")); + projectFiles.AddRange(Directory.GetFiles(inputPath, "*.vcxproj")); +} +else +{ + Console.Error.WriteLine($"Error: Path not found: {inputPath}"); + return 1; +} + +if (projectFiles.Count == 0) +{ + Console.Error.WriteLine($"No .csproj or .vcxproj files found in: {inputPath}"); + return 1; +} + +// Always include CacheGenerator.csproj as a baseline source of WinAppSDK WinMD files. +// It references Microsoft.WindowsAppSDK with ExcludeAssets="all" so the packages are +// downloaded during restore/build but don't affect the tool's compilation. +var selfCsproj = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "CacheGenerator.csproj"); +selfCsproj = Path.GetFullPath(selfCsproj); +if (File.Exists(selfCsproj) && !projectFiles.Any(f => + Path.GetFullPath(f).Equals(selfCsproj, StringComparison.OrdinalIgnoreCase))) +{ + projectFiles.Add(selfCsproj); +} + +Console.WriteLine($"WinMD Cache Generator (per-package deduplicate)"); +Console.WriteLine($" Output: {outputDir}"); +Console.WriteLine($" Projects: {projectFiles.Count}"); + +// --- Process each project --- + +var totalPackagesCached = 0; +var totalPackagesSkipped = 0; +var totalProjectsProcessed = 0; + +foreach (var projectFile in projectFiles) +{ + var projectDir = Path.GetDirectoryName(projectFile)!; + var projectName = Path.GetFileNameWithoutExtension(projectFile); + + Console.WriteLine($"\n--- {projectName} ({Path.GetFileName(projectFile)}) ---"); + + // Find packages that contain WinMD files + var packages = NuGetResolver.FindPackagesWithWinMd(projectDir, projectFile, winAppSdkRuntimePath); + + if (packages.Count == 0) + { + Console.WriteLine(" No packages with WinMD files (is the project restored?)"); + continue; + } + + Console.WriteLine($" {packages.Count} package(s) with WinMD files"); + totalProjectsProcessed++; + + var projectPackages = new List(); + + foreach (var pkg in packages) + { + var pkgCacheDir = Path.Combine(outputDir, "packages", pkg.Id, pkg.Version); + var metaPath = Path.Combine(pkgCacheDir, "meta.json"); + + if (File.Exists(metaPath)) + { + Console.WriteLine($" [cached] {pkg.Id}@{pkg.Version}"); + totalPackagesSkipped++; + } + else + { + Console.WriteLine($" [parse] {pkg.Id}@{pkg.Version} ({pkg.WinMdFiles.Count} WinMD file(s))"); + ExportPackageCache(pkg, pkgCacheDir); + totalPackagesCached++; + } + + projectPackages.Add(new ProjectPackageRef { Id = pkg.Id, Version = pkg.Version }); + } + + // Write project manifest + var manifest = new ProjectManifest + { + ProjectName = projectName, + ProjectDir = projectDir, + ProjectFile = Path.GetFileName(projectFile), + Packages = projectPackages, + GeneratedAt = DateTime.UtcNow.ToString("o"), + }; + + var projectsDir = Path.Combine(outputDir, "projects"); + Directory.CreateDirectory(projectsDir); + + // In scan mode, different directories may contain same-named projects. + // Append a short path hash to avoid overwriting manifests. + var manifestFileName = projectName; + if (scanMode) + { + var hashBytes = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(projectFile)); + var hashSuffix = Convert.ToHexString(hashBytes)[..8].ToLowerInvariant(); + manifestFileName = $"{projectName}_{hashSuffix}"; + } + + File.WriteAllText( + Path.Combine(projectsDir, $"{manifestFileName}.json"), + JsonSerializer.Serialize(manifest, jsonOptions)); +} + +Console.WriteLine($"\nDone: {totalProjectsProcessed} project(s) processed, " + + $"{totalPackagesCached} package(s) parsed, " + + $"{totalPackagesSkipped} reused from cache"); +return 0; + +// ============================================================================= +// Export a single package's WinMD data to cache +// ============================================================================= + +void ExportPackageCache(PackageWithWinMd pkg, string cacheDir) +{ + var typesDir = Path.Combine(cacheDir, "types"); + Directory.CreateDirectory(typesDir); + + var allTypes = new List(); + foreach (var file in pkg.WinMdFiles) + { + allTypes.AddRange(WinMdParser.ParseFile(file)); + } + + var typesByNamespace = allTypes + .GroupBy(t => t.Namespace) + .ToDictionary(g => g.Key, g => g.ToList()); + + var namespaces = typesByNamespace.Keys + .Where(ns => !string.IsNullOrEmpty(ns)) + .OrderBy(ns => ns) + .ToList(); + + // Include global (empty) namespace types under a reserved bucket name + var hasGlobalNs = typesByNamespace.ContainsKey(string.Empty) + && typesByNamespace[string.Empty].Count > 0; + const string globalNsBucket = "_GlobalNamespace"; + if (hasGlobalNs) + { + namespaces.Insert(0, globalNsBucket); + } + + // meta.json + var meta = new + { + PackageId = pkg.Id, + Version = pkg.Version, + WinMdFiles = pkg.WinMdFiles + .Select(Path.GetFileName) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(), + TotalTypes = allTypes.Count, + TotalMembers = allTypes.Sum(t => t.Members.Count), + TotalNamespaces = namespaces.Count, + GeneratedAt = DateTime.UtcNow.ToString("o"), + }; + + File.WriteAllText( + Path.Combine(cacheDir, "meta.json"), + JsonSerializer.Serialize(meta, jsonOptions)); + + // namespaces.json + File.WriteAllText( + Path.Combine(cacheDir, "namespaces.json"), + JsonSerializer.Serialize(namespaces, jsonOptions)); + + // types/.json + foreach (var ns in namespaces) + { + var lookupKey = ns == globalNsBucket ? string.Empty : ns; + var types = typesByNamespace[lookupKey]; + var safeFileName = ns.Replace('.', '_') + ".json"; + File.WriteAllText( + Path.Combine(typesDir, safeFileName), + JsonSerializer.Serialize(types, jsonOptions)); + } +} + +// ============================================================================= +// Data Models +// ============================================================================= + +enum TypeKind { Class, Struct, Enum, Interface, Delegate } + +enum MemberKind { Method, Property, Event, Field } + +sealed class WinMdTypeInfo +{ + public required string Namespace { get; init; } + public required string Name { get; init; } + public required string FullName { get; init; } + public required TypeKind Kind { get; init; } + public string? BaseType { get; init; } + public required List Members { get; init; } + public List? EnumValues { get; init; } + public required string SourceFile { get; init; } +} + +sealed class WinMdMemberInfo +{ + public required string Name { get; init; } + public required MemberKind Kind { get; init; } + public required string Signature { get; init; } + public string? ReturnType { get; init; } + public List? Parameters { get; init; } +} + +sealed class WinMdParameterInfo +{ + public required string Name { get; init; } + public required string Type { get; init; } +} + +sealed class ProjectPackageRef +{ + public required string Id { get; init; } + public required string Version { get; init; } +} + +sealed class ProjectManifest +{ + public required string ProjectName { get; init; } + public required string ProjectDir { get; init; } + public required string ProjectFile { get; init; } + public required List Packages { get; init; } + public required string GeneratedAt { get; init; } +} + +// ============================================================================= +// NuGet Resolver — finds packages with WinMD files, returns structured data +// ============================================================================= + +record PackageWithWinMd(string Id, string Version, List WinMdFiles); + +static class NuGetResolver +{ + public static List FindPackagesWithWinMd(string projectDir, string projectFile, string? winAppSdkRuntimePath) + { + var result = new List(); + + // 1. Try project.assets.json (PackageReference — .csproj and modern .vcxproj) + var assetsPath = FindProjectAssetsJson(projectDir); + if (assetsPath is not null) + { + result.AddRange(FindPackagesFromAssets(assetsPath)); + } + + // 2. Try packages.config (older .vcxproj / .csproj using NuGet packages.config) + if (result.Count == 0) + { + var packagesConfig = Path.Combine(projectDir, "packages.config"); + if (File.Exists(packagesConfig)) + { + result.AddRange(FindPackagesFromConfig(packagesConfig, projectDir)); + } + } + + // 3. Project references — parse from .csproj/.vcxproj XML, + // then check each referenced project's bin/ for .winmd build output. + // This is the reliable way to find class libraries that generate WinMD. + result.AddRange(FindWinMdFromProjectReferences(projectFile)); + + // 4. Windows SDK as a synthetic "package" + var sdkWinMd = FindWindowsSdkWinMd(); + if (sdkWinMd.Files.Count > 0) + { + result.Add(new PackageWithWinMd("WindowsSDK", sdkWinMd.Version, sdkWinMd.Files)); + } + + // 5. Installed WinAppSDK runtime as a synthetic "package" + // Useful for Electron/Node.js apps that don't reference WinAppSDK via NuGet. + var runtimeWinMd = FindWinAppSdkRuntimeWinMd(winAppSdkRuntimePath); + if (runtimeWinMd.Files.Count > 0) + { + result.Add(new PackageWithWinMd("WinAppSdkRuntime", runtimeWinMd.Version, runtimeWinMd.Files)); + } + + // Deduplicate by (Id, Version), merging WinMdFiles from multiple sources + return result + .GroupBy(p => (p.Id.ToLowerInvariant(), p.Version.ToLowerInvariant())) + .Select(g => + { + var merged = g.SelectMany(p => p.WinMdFiles) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + var first = g.First(); + return new PackageWithWinMd(first.Id, first.Version, merged); + }) + .ToList(); + } + + /// + /// Parse <ProjectReference> from .csproj/.vcxproj and find .winmd output + /// from each referenced project's bin/ directory. + /// + internal static List FindWinMdFromProjectReferences(string projectFile) + { + var result = new List(); + + try + { + var doc = XDocument.Load(projectFile); + var ns = doc.Root?.Name.Namespace ?? XNamespace.None; + var projectRefs = doc.Descendants(ns + "ProjectReference") + .Select(e => e.Attribute("Include")?.Value) + .Where(v => v is not null) + .ToList(); + + if (projectRefs.Count == 0) + { + return result; + } + + var projectDir = Path.GetDirectoryName(projectFile)!; + + foreach (var refPath in projectRefs) + { + var refFullPath = Path.GetFullPath(Path.Combine(projectDir, refPath!)); + if (!File.Exists(refFullPath)) + { + continue; + } + + var refProjectDir = Path.GetDirectoryName(refFullPath)!; + var refProjectName = Path.GetFileNameWithoutExtension(refFullPath); + var refBinDir = Path.Combine(refProjectDir, "bin"); + + if (!Directory.Exists(refBinDir)) + { + continue; + } + + var winmdFiles = Directory.GetFiles(refBinDir, "*.winmd", SearchOption.AllDirectories) + .Where(f => !Path.GetFileName(f).Equals("Windows.winmd", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + // Deduplicate by filename (same WinMD across Debug/Release/x64/etc.) + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + winmdFiles = winmdFiles + .Where(f => seen.Add(Path.GetFileName(f))) + .ToList(); + + if (winmdFiles.Count > 0) + { + result.Add(new PackageWithWinMd($"ProjectRef.{refProjectName}", "local", winmdFiles)); + } + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Failed to parse project references: {ex.Message}"); + } + + return result; + } + + internal static string? FindProjectAssetsJson(string projectDir) + { + // Standard location + var assetsPath = Path.Combine(projectDir, "obj", "project.assets.json"); + if (File.Exists(assetsPath)) + { + return assetsPath; + } + + // Sometimes under platform-specific subdirectories + var objDir = Path.Combine(projectDir, "obj"); + if (Directory.Exists(objDir)) + { + var found = Directory.GetFiles(objDir, "project.assets.json", SearchOption.AllDirectories); + if (found.Length > 0) + { + // Pick the most recently written file to avoid non-deterministic + // selection when multi-targeting creates multiple assets files. + string? bestPath = null; + DateTime bestWriteTime = DateTime.MinValue; + + foreach (var path in found) + { + try + { + var writeTime = File.GetLastWriteTimeUtc(path); + if (writeTime > bestWriteTime) + { + bestWriteTime = writeTime; + bestPath = path; + } + } + catch + { + // Ignore files we cannot access metadata for + } + } + + if (bestPath is not null) + { + return bestPath; + } + } + } + + return null; + } + + internal static List FindPackagesFromAssets(string assetsPath) + { + var result = new List(); + + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(assetsPath)); + var root = doc.RootElement; + + var packageFolders = new List(); + if (root.TryGetProperty("packageFolders", out var folders)) + { + foreach (var folder in folders.EnumerateObject()) + { + packageFolders.Add(folder.Name); + } + } + + if (!root.TryGetProperty("libraries", out var libraries)) + { + return result; + } + + foreach (var lib in libraries.EnumerateObject()) + { + // Only treat libraries with type == "package" as NuGet packages; + // skip project references and other entry types. + if (!lib.Value.TryGetProperty("type", out var typeProp) || + !string.Equals(typeProp.GetString(), "package", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + // Key format: "PackageId/Version" + var slashIdx = lib.Name.IndexOf('/'); + if (slashIdx < 0) + { + continue; + } + + var packageId = lib.Name[..slashIdx]; + var version = lib.Name[(slashIdx + 1)..]; + + if (!lib.Value.TryGetProperty("path", out var pathProp)) + { + continue; + } + + var libPath = pathProp.GetString(); + if (libPath is null) + { + continue; + } + + var winmdFiles = new List(); + foreach (var folder in packageFolders) + { + var fullPath = Path.Combine(folder, libPath); + if (!Directory.Exists(fullPath)) + { + continue; + } + + winmdFiles.AddRange( + Directory.GetFiles(fullPath, "*.winmd", SearchOption.AllDirectories)); + } + + // Deduplicate by filename (WinMD is arch-neutral metadata) + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + winmdFiles = winmdFiles + .Where(f => seen.Add(Path.GetFileName(f))) + .ToList(); + + if (winmdFiles.Count > 0) + { + result.Add(new PackageWithWinMd(packageId, version, winmdFiles)); + } + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Failed to parse project.assets.json: {ex.Message}"); + } + + return result; + } + + /// + /// Parses packages.config (older NuGet format used by some .vcxproj and legacy .csproj). + /// Looks for a solution-level "packages/" folder or the NuGet global cache. + /// + internal static List FindPackagesFromConfig(string configPath, string projectDir) + { + var result = new List(); + + try + { + var doc = System.Xml.Linq.XDocument.Load(configPath); + var packages = doc.Root?.Elements("package"); + if (packages is null) + { + return result; + } + + // packages.config repos typically have a solution-level "packages/" folder. + // Walk up from project dir to find it. + var packagesFolder = FindSolutionPackagesFolder(projectDir); + + // Also check NuGet global packages cache (respect NUGET_PACKAGES override) + var globalPackages = Environment.GetEnvironmentVariable("NUGET_PACKAGES"); + if (string.IsNullOrWhiteSpace(globalPackages)) + { + globalPackages = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".nuget", "packages"); + } + + foreach (var pkg in packages) + { + var id = pkg.Attribute("id")?.Value; + var version = pkg.Attribute("version")?.Value; + if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(version)) + { + continue; + } + + var winmdFiles = new List(); + + // Check solution-level packages/ folder (format: packages/./) + if (packagesFolder is not null) + { + var pkgDir = Path.Combine(packagesFolder, $"{id}.{version}"); + if (Directory.Exists(pkgDir)) + { + winmdFiles.AddRange( + Directory.GetFiles(pkgDir, "*.winmd", SearchOption.AllDirectories)); + } + } + + // Fallback: NuGet global cache (format: //) + if (winmdFiles.Count == 0 && Directory.Exists(globalPackages)) + { + var pkgDir = Path.Combine(globalPackages, id.ToLowerInvariant(), version); + if (Directory.Exists(pkgDir)) + { + winmdFiles.AddRange( + Directory.GetFiles(pkgDir, "*.winmd", SearchOption.AllDirectories)); + } + } + + // Deduplicate by filename + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + winmdFiles = winmdFiles + .Where(f => seen.Add(Path.GetFileName(f))) + .ToList(); + + if (winmdFiles.Count > 0) + { + result.Add(new PackageWithWinMd(id, version, winmdFiles)); + } + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Failed to parse packages.config: {ex.Message}"); + } + + return result; + } + + /// + /// Walk up from project dir to find a solution-level "packages/" folder. + /// + internal static string? FindSolutionPackagesFolder(string startDir) + { + var dir = startDir; + for (var i = 0; i < 5; i++) // Walk up at most 5 levels + { + var packagesDir = Path.Combine(dir, "packages"); + if (Directory.Exists(packagesDir)) + { + return packagesDir; + } + + var parent = Directory.GetParent(dir); + if (parent is null) + { + break; + } + + dir = parent.FullName; + } + + return null; + } + + internal static (List Files, string Version) FindWindowsSdkWinMd() + { + var windowsKitsPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + "Windows Kits", "10", "UnionMetadata"); + + if (!Directory.Exists(windowsKitsPath)) + { + return ([], "unknown"); + } + + // Filter to version-numbered directories only (skip "Facade" etc.) and + // sort by numeric version, not lexicographically, to pick the highest SDK. + var versionDirs = Directory.GetDirectories(windowsKitsPath) + .Select(d => (Dir: d, Name: Path.GetFileName(d))) + .Where(x => !string.IsNullOrEmpty(x.Name) && char.IsDigit(x.Name[0])) + .Select(x => Version.TryParse(x.Name, out var v) + ? (Dir: x.Dir, Version: v) + : (Dir: (string?)null, Version: (Version?)null)) + .Where(x => x.Dir is not null && x.Version is not null) + .OrderByDescending(x => x.Version) + .Select(x => x.Dir!) + .ToList(); + + foreach (var versionDir in versionDirs) + { + var windowsWinMd = Path.Combine(versionDir, "Windows.winmd"); + if (File.Exists(windowsWinMd)) + { + var version = Path.GetFileName(versionDir); + return ([windowsWinMd], version); + } + } + + return ([], "unknown"); + } + + /// + /// Read WinMD files from the installed WinAppSDK runtime path (discovered via + /// Get-AppxPackage in PowerShell and passed as --winappsdk-runtime argument). + /// The WindowsApps folder is ACL-restricted so C# cannot enumerate it directly. + /// + internal static (List Files, string Version) FindWinAppSdkRuntimeWinMd(string? runtimePath) + { + if (string.IsNullOrEmpty(runtimePath) || !Directory.Exists(runtimePath)) + { + return ([], "unknown"); + } + + try + { + var winmdFiles = Directory.EnumerateFiles(runtimePath, "*.winmd", SearchOption.TopDirectoryOnly) + .ToList(); + + if (winmdFiles.Count > 0) + { + // Extract SDK version from path: ...Microsoft.WindowsAppRuntime.1.8_... -> "1.8" + var dirName = Path.GetFileName(runtimePath); + var prefix = dirName.Split('_')[0]; // "Microsoft.WindowsAppRuntime.1.8" + var sdkVersion = prefix.Length > "Microsoft.WindowsAppRuntime.".Length + ? prefix["Microsoft.WindowsAppRuntime.".Length..] + : dirName; + + return (winmdFiles, sdkVersion); + } + } + catch + { + // Path may be inaccessible; degrade gracefully + } + + return ([], "unknown"); + } +} + +// ============================================================================= +// Signature Type Provider — decodes metadata signatures to readable strings +// ============================================================================= + +sealed class SimpleTypeProvider : ISignatureTypeProvider +{ + public string GetPrimitiveType(PrimitiveTypeCode typeCode) => typeCode switch + { + PrimitiveTypeCode.Boolean => "Boolean", + PrimitiveTypeCode.Byte => "Byte", + PrimitiveTypeCode.SByte => "SByte", + PrimitiveTypeCode.Char => "Char", + PrimitiveTypeCode.Int16 => "Int16", + PrimitiveTypeCode.UInt16 => "UInt16", + PrimitiveTypeCode.Int32 => "Int32", + PrimitiveTypeCode.UInt32 => "UInt32", + PrimitiveTypeCode.Int64 => "Int64", + PrimitiveTypeCode.UInt64 => "UInt64", + PrimitiveTypeCode.Single => "Single", + PrimitiveTypeCode.Double => "Double", + PrimitiveTypeCode.String => "String", + PrimitiveTypeCode.Object => "Object", + PrimitiveTypeCode.Void => "void", + PrimitiveTypeCode.IntPtr => "IntPtr", + PrimitiveTypeCode.UIntPtr => "UIntPtr", + PrimitiveTypeCode.TypedReference => "TypedReference", + _ => typeCode.ToString(), + }; + + public string GetTypeFromDefinition(MetadataReader reader, TypeDefinitionHandle handle, byte rawTypeKind) + { + var typeDef = reader.GetTypeDefinition(handle); + var name = reader.GetString(typeDef.Name); + var ns = reader.GetString(typeDef.Namespace); + return string.IsNullOrEmpty(ns) ? name : $"{ns}.{name}"; + } + + public string GetTypeFromReference(MetadataReader reader, TypeReferenceHandle handle, byte rawTypeKind) + { + var typeRef = reader.GetTypeReference(handle); + var name = reader.GetString(typeRef.Name); + var ns = reader.GetString(typeRef.Namespace); + return string.IsNullOrEmpty(ns) ? name : $"{ns}.{name}"; + } + + public string GetSZArrayType(string elementType) => $"{elementType}[]"; + + public string GetArrayType(string elementType, ArrayShape shape) => + $"{elementType}[{new string(',', shape.Rank - 1)}]"; + + public string GetByReferenceType(string elementType) => $"ref {elementType}"; + public string GetPointerType(string elementType) => $"{elementType}*"; + public string GetPinnedType(string elementType) => elementType; + + public string GetGenericInstantiation(string genericType, ImmutableArray typeArguments) + { + var name = genericType; + var backtick = name.IndexOf('`'); + if (backtick >= 0) + { + name = name[..backtick]; + } + + return $"{name}<{string.Join(", ", typeArguments)}>"; + } + + public string GetGenericMethodParameter(object? genericContext, int index) => $"TMethod{index}"; + public string GetGenericTypeParameter(object? genericContext, int index) => $"T{index}"; + public string GetModifiedType(string modifier, string unmodifiedType, bool isRequired) => unmodifiedType; + public string GetFunctionPointerType(MethodSignature signature) => "delegate*"; + + public string GetTypeFromSpecification(MetadataReader reader, object? genericContext, + TypeSpecificationHandle handle, byte rawTypeKind) + { + return reader.GetTypeSpecification(handle).DecodeSignature(this, genericContext); + } +} + +// ============================================================================= +// WinMD Parser — reads WinMD files into structured type info +// ============================================================================= + +static class WinMdParser +{ + public static List ParseFile(string filePath) + { + var types = new List(); + + try + { + using var stream = File.OpenRead(filePath); + using var peReader = new PEReader(stream); + + if (!peReader.HasMetadata) + { + return types; + } + + var reader = peReader.GetMetadataReader(); + var typeProvider = new SimpleTypeProvider(); + + foreach (var typeDefHandle in reader.TypeDefinitions) + { + var typeDef = reader.GetTypeDefinition(typeDefHandle); + var name = reader.GetString(typeDef.Name); + var ns = reader.GetString(typeDef.Namespace); + + if (ShouldSkipType(name, typeDef)) + { + continue; + } + + var kind = DetermineTypeKind(reader, typeDef); + var baseType = GetBaseTypeName(reader, typeDef); + var members = ParseMembers(reader, typeDef, typeProvider); + var enumValues = kind == TypeKind.Enum ? ParseEnumValues(reader, typeDef) : null; + var fullName = string.IsNullOrEmpty(ns) ? name : $"{ns}.{name}"; + + types.Add(new WinMdTypeInfo + { + Namespace = ns, + Name = name, + FullName = fullName, + Kind = kind, + BaseType = baseType, + Members = members, + EnumValues = enumValues, + SourceFile = Path.GetFileName(filePath), + }); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Failed to parse {filePath}: {ex.Message}"); + } + + return types; + } + + internal static bool ShouldSkipType(string name, TypeDefinition typeDef) + { + if (string.IsNullOrEmpty(name) || name == "" || name.StartsWith('<')) + { + return true; + } + + var visibility = typeDef.Attributes & TypeAttributes.VisibilityMask; + return visibility != TypeAttributes.Public && visibility != TypeAttributes.NestedPublic; + } + + internal static TypeKind DetermineTypeKind(MetadataReader reader, TypeDefinition typeDef) + { + if ((typeDef.Attributes & TypeAttributes.Interface) != 0) + { + return TypeKind.Interface; + } + + var baseType = GetBaseTypeName(reader, typeDef); + return baseType switch + { + "System.Enum" => TypeKind.Enum, + "System.ValueType" => TypeKind.Struct, + "System.MulticastDelegate" or "System.Delegate" => TypeKind.Delegate, + _ => TypeKind.Class, + }; + } + + private static string? GetBaseTypeName(MetadataReader reader, TypeDefinition typeDef) + { + if (typeDef.BaseType.IsNil) + { + return null; + } + + return typeDef.BaseType.Kind switch + { + HandleKind.TypeDefinition => GetTypeDefName(reader, (TypeDefinitionHandle)typeDef.BaseType), + HandleKind.TypeReference => GetTypeRefName(reader, (TypeReferenceHandle)typeDef.BaseType), + _ => null, + }; + } + + private static string GetTypeDefName(MetadataReader reader, TypeDefinitionHandle handle) + { + var td = reader.GetTypeDefinition(handle); + var ns = reader.GetString(td.Namespace); + var name = reader.GetString(td.Name); + return string.IsNullOrEmpty(ns) ? name : $"{ns}.{name}"; + } + + private static string GetTypeRefName(MetadataReader reader, TypeReferenceHandle handle) + { + var tr = reader.GetTypeReference(handle); + var ns = reader.GetString(tr.Namespace); + var name = reader.GetString(tr.Name); + return string.IsNullOrEmpty(ns) ? name : $"{ns}.{name}"; + } + + private static List ParseMembers( + MetadataReader reader, TypeDefinition typeDef, SimpleTypeProvider typeProvider) + { + var members = new List(); + + // Collect property/event accessor methods so we can skip them in the methods loop + var accessorMethods = new HashSet(); + foreach (var propHandle in typeDef.GetProperties()) + { + var accessors = reader.GetPropertyDefinition(propHandle).GetAccessors(); + if (!accessors.Getter.IsNil) accessorMethods.Add(accessors.Getter); + if (!accessors.Setter.IsNil) accessorMethods.Add(accessors.Setter); + } + + foreach (var eventHandle in typeDef.GetEvents()) + { + var accessors = reader.GetEventDefinition(eventHandle).GetAccessors(); + if (!accessors.Adder.IsNil) accessorMethods.Add(accessors.Adder); + if (!accessors.Remover.IsNil) accessorMethods.Add(accessors.Remover); + if (!accessors.Raiser.IsNil) accessorMethods.Add(accessors.Raiser); + } + + // Methods + foreach (var methodHandle in typeDef.GetMethods()) + { + if (accessorMethods.Contains(methodHandle)) + { + continue; + } + + var method = reader.GetMethodDefinition(methodHandle); + var methodName = reader.GetString(method.Name); + + if (methodName.StartsWith('.') || methodName.StartsWith('<')) + { + continue; + } + + if ((method.Attributes & MethodAttributes.MemberAccessMask) != MethodAttributes.Public) + { + continue; + } + + try + { + var sig = method.DecodeSignature(typeProvider, null); + var parameters = GetMethodParameters(reader, method, sig); + var paramStr = string.Join(", ", parameters.Select(p => $"{p.Type} {p.Name}")); + + members.Add(new WinMdMemberInfo + { + Name = methodName, + Kind = MemberKind.Method, + Signature = $"{sig.ReturnType} {methodName}({paramStr})", + ReturnType = sig.ReturnType, + Parameters = parameters, + }); + } + catch + { + members.Add(new WinMdMemberInfo + { + Name = methodName, + Kind = MemberKind.Method, + Signature = $"{methodName}(/* signature not decodable */)", + }); + } + } + + // Properties + foreach (var propHandle in typeDef.GetProperties()) + { + var prop = reader.GetPropertyDefinition(propHandle); + var propName = reader.GetString(prop.Name); + + try + { + var propSig = prop.DecodeSignature(typeProvider, null); + var propType = propSig.ReturnType; + var accessors = prop.GetAccessors(); + + var hasGetter = false; + if (!accessors.Getter.IsNil) + { + var getterDef = reader.GetMethodDefinition(accessors.Getter); + if ((getterDef.Attributes & MethodAttributes.MemberAccessMask) == MethodAttributes.Public) + { + hasGetter = true; + } + } + + var hasSetter = false; + if (!accessors.Setter.IsNil) + { + var setterDef = reader.GetMethodDefinition(accessors.Setter); + if ((setterDef.Attributes & MethodAttributes.MemberAccessMask) == MethodAttributes.Public) + { + hasSetter = true; + } + } + + // Skip properties where neither accessor is public + if (!hasGetter && !hasSetter) + { + continue; + } + var accessStr = (hasGetter, hasSetter) switch + { + (true, true) => "{ get; set; }", + (true, false) => "{ get; }", + (false, true) => "{ set; }", + _ => "{ }", + }; + + members.Add(new WinMdMemberInfo + { + Name = propName, + Kind = MemberKind.Property, + Signature = $"{propType} {propName} {accessStr}", + ReturnType = propType, + }); + } + catch + { + members.Add(new WinMdMemberInfo + { + Name = propName, + Kind = MemberKind.Property, + Signature = $"/* type not decodable */ {propName}", + }); + } + } + + // Events + foreach (var eventHandle in typeDef.GetEvents()) + { + var evt = reader.GetEventDefinition(eventHandle); + var evtName = reader.GetString(evt.Name); + var accessors = evt.GetAccessors(); + + var isPublicEvent = false; + if (!accessors.Adder.IsNil) + { + var adder = reader.GetMethodDefinition(accessors.Adder); + if ((adder.Attributes & MethodAttributes.MemberAccessMask) == MethodAttributes.Public) + { + isPublicEvent = true; + } + } + + if (!isPublicEvent && !accessors.Remover.IsNil) + { + var remover = reader.GetMethodDefinition(accessors.Remover); + if ((remover.Attributes & MethodAttributes.MemberAccessMask) == MethodAttributes.Public) + { + isPublicEvent = true; + } + } + + if (!isPublicEvent) + { + continue; + } + + var evtType = GetHandleTypeName(reader, evt.Type); + + members.Add(new WinMdMemberInfo + { + Name = evtName, + Kind = MemberKind.Event, + Signature = $"event {evtType} {evtName}", + ReturnType = evtType, + }); + } + + return members; + } + + private static List GetMethodParameters( + MetadataReader reader, MethodDefinition method, MethodSignature sig) + { + var parameters = new List(); + var paramHandles = method.GetParameters().ToList(); + var paramNames = new List(); + + foreach (var ph in paramHandles) + { + var param = reader.GetParameter(ph); + if (param.SequenceNumber > 0) + { + paramNames.Add(reader.GetString(param.Name)); + } + } + + for (var i = 0; i < sig.ParameterTypes.Length; i++) + { + parameters.Add(new WinMdParameterInfo + { + Name = i < paramNames.Count ? paramNames[i] : $"arg{i}", + Type = sig.ParameterTypes[i], + }); + } + + return parameters; + } + + internal static List ParseEnumValues(MetadataReader reader, TypeDefinition typeDef) + { + var values = new List(); + + foreach (var fieldHandle in typeDef.GetFields()) + { + var field = reader.GetFieldDefinition(fieldHandle); + var fieldName = reader.GetString(field.Name); + + if (fieldName == "value__") + { + continue; + } + + if ((field.Attributes & FieldAttributes.FieldAccessMask) == FieldAttributes.Public && + (field.Attributes & FieldAttributes.Static) != 0) + { + values.Add(fieldName); + } + } + + return values; + } + + private static string GetHandleTypeName(MetadataReader reader, EntityHandle handle) => handle.Kind switch + { + HandleKind.TypeDefinition => GetTypeDefName(reader, (TypeDefinitionHandle)handle), + HandleKind.TypeReference => GetTypeRefName(reader, (TypeReferenceHandle)handle), + HandleKind.TypeSpecification => DecodeTypeSpecification(reader, (TypeSpecificationHandle)handle), + _ => "unknown", + }; + + private static string DecodeTypeSpecification(MetadataReader reader, TypeSpecificationHandle handle) + { + try + { + var typeSpec = reader.GetTypeSpecification(handle); + return typeSpec.DecodeSignature(new SimpleTypeProvider(), null); + } + catch + { + return "unknown"; + } + } +}