diff --git a/CLAUDE.md b/CLAUDE.md index 193e591..df0afe2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,10 +46,10 @@ Release/publish process (`npm run release -- ` → PR → GitHub Release w ### Data pipeline (build time, `scripts/`) - `nuget/WpfDocs.csproj` pins the Infragistics Trial package version (currently 26.1.21) and is the single place to bump it. `docs:restore` fills `nuget/packages/`. -- `scripts/type-extractor/Program.cs` loads every Infragistics DLL into one `AssemblyLoadContext` and writes `nuget/type-info.json` — a flat map of `"Assembly|Ns.Type.Prop" → {typeName,isNullable,isEnum,enumValues}` (instance and static properties, plus static `ResourceKey`/`RoutedCommand` fields marked `isField`; keyed by full name because same-named types overwrote each other's entries), `"__baseTypes"` (`"Assembly|Ns.Type" → "Assembly|Ns.Base"`, generic bases as their definition), `"__xaml"` (`"Assembly|Ns.Type" → {xmlns, prefix, ns, xamlName?}`), `"__nonPublicTypes"` (internal / private nested types reflection positively sees as non-public; a type that can't be loaded is never listed) and `"__nonPublicMembers"` (`"Assembly|Ns.Type.Member"` for members of public types that are internal / private / private protected — only when no member of that name on that type is public or protected). The xmlns comes from System.Xaml's `XamlSchemaContext` over the DLLs' own `XmlnsDefinition`/`XmlnsPrefix` attributes — the most specific xmlns that resolves to exactly that type and under which no other type of that name (public or not) lives in a covered namespace, else `clr-namespace:` syntax naming the assembly; nested types are `Outer+Inner`. This is the only source of property types, enum values, inheritance and xmlns; XML docs alone give just summaries. -- `scripts/build-api.ts` parses the NuGet `*.xml` doc files (split-based chunking, not regex-over-whole-file, to avoid backtracking on multi-MB inputs), merges `type-info.json` (its regex-parsed strings are JSON-unescaped: the extractor writes `` ` `` and `<` as ```/`<`), then `enrichWithInheritance()` walks the `__baseTypes` chains by assembly + full name (cycle-guarded) over every parsed type — including bases that lost their short name to a same-named type, so XamGrid's `Cell` subclasses still inherit from it — copying parent members onto children tagged with `declaredOn`. Static key/command fields are listed with the properties (`isField`). Types are filtered by `EXCLUDE_SUFFIXES`/`EXCLUDE_CONTAINS`/`EXCLUDE_PACKAGES`, and non-public types (the XML docs also document ~2,000 internal classes such as `ActivePaneManager`) are left out via `__nonPublicTypes` before short-name collisions are resolved, so a public type wins its name. Members are dropped when their summary is `"internal"`, when `__nonPublicMembers` lists them, or when their doc name has `#` (explicit interface implementations). When two types share a short name, the one with more documented members wins — counted as before the non-public member filter, so no name switches type — and the rest are recorded in `alternates`. Each type's `xamlNamespace`/`defaultPrefix` (and `xamlName` for nested types) come from `__xaml`; types it lacks (open generics, non-public types, DLLs left out of XAML resolution) fall back to `clr-namespace:`. `namespaces.json` is only `Xam*`-prefixed types. `TFM_PREF` and `byVersionDesc` pick one lib folder per package. -- `scripts/build-docs.ts` converts `.adoc` topics to `docs/{slug}.json` + `docs-index.json`. docs-common topics use `{PlaceholderName}` variables resolved from `docs-common/DocsConfig.xml` for the `wpf` product; only folders in `COMMON_INCLUDE` are taken from docs-common. -- `scripts/build-themes.ts` copies `Themes/` (newer ThemeManager family) and `DefaultStyles/` (legacy embedded-BAML `Theme="..."` family) XAML from wpf-resources and records per-file `targetTypes` parsed from `TargetType=` declarations — this is what makes "is component X themeable" lookups content-aware rather than filename-based. +- `scripts/type-extractor/Program.cs` loads every Infragistics DLL into one `AssemblyLoadContext` and writes `nuget/type-info.json` — a flat map of `"Assembly|Ns.Type.Prop" → {typeName,isNullable,isEnum,enumValues}` (public and protected, instance and static properties, plus static `ResourceKey`/`RoutedCommand` fields marked `isField`; keyed by full name because same-named types overwrote each other's entries), `"__baseTypes"` (`"Assembly|Ns.Type" → "Assembly|Ns.Base"`, generic bases as their definition), `"__xaml"` (`"Assembly|Ns.Type" → {xmlns, prefix, ns, xamlName?}`), `"__nonPublicTypes"` (internal / private nested types reflection positively sees as non-public; a type that can't be loaded is never listed), `"__nonPublicMembers"` (`"Assembly|Ns.Type.Member"` for members of public types that are internal / private / private protected — only when no member of that name on that type is public or protected), `"__members"` (`"Assembly|"` → `{sig?, params?, overrides?, obsolete?, hidden?, protected?}`: every public / protected method and constructor with its C# signature and parameter names — and, for an override of a generic base type's method, the doc ID tail of the method it overrides (`InsertItem(System.Int32,`0)` for `InsertItem(System.Int32,Ns.Column)`) — plus indexers, protected properties and events (`protected`: no public accessor; the XML docs list them like public ones), and any member marked `[Obsolete]` (with its message) or `[EditorBrowsable(Never)]`; the doc IDs are generated from reflection in the XML docs' own format, so they join on ``), `"__nonPublicMethods"` (doc IDs of the internal / private methods and constructors of those types), `"__memberTypes"` (the types `__members` covers) and `"__enums"` (`"Assembly|Ns.Type" → {flags?, values: [{name, value}]}`: every public enum, nested ones too, its values in declaration order with their numbers; `flags` for `[Flags]`) and `"__themeMappings"` (`"" → {"Ns.Type" → ".xamDataPresenter.xaml"}`: the controls each `Infragistics.WPF.Themes.` DLL's ThemeManager theme styles and with which of its files — the theme's protected `Mappings`, filled by `ThemeBase`'s private `Initialize()`; keys that are no type, such as `Infragistics.Themes.MsControls`, are left out). The xmlns comes from System.Xaml's `XamlSchemaContext` over the DLLs' own `XmlnsDefinition`/`XmlnsPrefix` attributes — the most specific xmlns that resolves to exactly that type and under which no other type of that name (public or not) lives in a covered namespace, else `clr-namespace:` syntax naming the assembly; nested types are `Outer+Inner`. This is the only source of property types, enum values, inheritance and xmlns; XML docs alone give just summaries. +- `scripts/build-api.ts` parses the NuGet `*.xml` doc files (split-based chunking, not regex-over-whole-file, to avoid backtracking on multi-MB inputs), merges `type-info.json` (its regex-parsed strings are JSON-unescaped: the extractor writes `` ` `` and `<` as ```/`<`), then `enrichWithInheritance()` walks the `__baseTypes` chains by assembly + full name (cycle-guarded) over every parsed type — including bases that lost their short name to a same-named type, so XamGrid's `Cell` subclasses still inherit from it — copying parent members onto children tagged with `declaredOn`. Static key/command fields are listed with the properties (`isField`). Protected properties and events get `isProtected` (from `__members`; inherited copies keep it). Enum pages get `values` (`{name, value, summary, obsolete?}` in `__enums` order, summaries from the XML's `F:` entries; values hidden from IntelliSense are left out, `[Obsolete]` ones kept with their message) and `isFlags`; values don't count in short-name contests and aren't in `search-index.json` (adding their names reshuffled the top 10 of about half the common queries). Types are filtered by `EXCLUDE_SUFFIXES`/`EXCLUDE_CONTAINS`/`EXCLUDE_PACKAGES`, and non-public types (the XML docs also document ~2,000 internal classes such as `ActivePaneManager`) are left out via `__nonPublicTypes` before short-name collisions are resolved, so a public type wins its name. Members are dropped when their summary is `"internal"`, when `__nonPublicMembers` lists them, when their doc name has `#` (explicit interface implementations), or when they're hidden from IntelliSense; a method or constructor overload `__nonPublicMethods` lists is dropped too, while one of a type in `__memberTypes` with neither entry (its generated doc ID didn't match) is kept by name and counts as unsigned in the signature gate. Methods, constructors (public, with parameters only) and indexers get `signature` from `__members` and, from the XML, `parameters` (the `` descriptions whose name is one of the `params`); `[Obsolete]` members move to the page's `obsolete` list with their message (inherited like other members), so the server can answer a lookup with the replacement. A type hidden from IntelliSense (`XamSparklineView`) gets no page or registry entry but stays in `allTypes` as a base; an `[Obsolete]` type keeps its page with `obsoleteMessage`. Inherited methods are matched by overload (doc ID tail, or the `overrides` tail), not name, and a member a type re-declares `[Obsolete]` or hidden blocks every further base's version of it (`TemplateColumn` hides `Column.ValueConverter`). build-api fails if fewer than 95% of methods get a signature (the generated doc IDs stopped matching). `stripXml` turns ``/``, ``, `` and `` into the name they refer to and decodes entities. When two types share a short name, the higher `familyRank()` wins the file name, so the name means the control family Infragistics recommends: a type of a retired control (`RETIRED_ASSEMBLIES`: XamGrid and the xamInputs, whose docs carry retirement banners) ranks lowest, and a legacy `Infragistics.Windows.` type ranks below a newer copy of it in `Infragistics.Controls.` / `Infragistics.` (`XamComboEditor`, `ThemeManager`; the area must match, so XamPivotGrid's `FieldChooser` is not a newer XamDataGrid one). Between equal ranks the one with more documented members wins — counted as before the non-public member filter, so no name switches type. The contest is replayed after the scan, in discovery order. Each of the others gets its own page, built and enriched the same way, in `api/alternates/{lookup}.json`: `lookup` is its full name, plus `, Assembly` when two assemblies have that full name too (`Caret`), and is what `get_wpf_api_reference` takes to open it. Every type of the name lists the others in `alternates` (`lookup`, member count after inheritance, xmlns, first summary sentence, enum value names; the winner marked `isDefault`); the same class listed under a second package would only be a `sameType` note there, not a page. The alternates come last in `search-index.json`, with `f` (their lookup; the winner's entry has its full name), and a `namespaces.json` entry lists its alternates (the legacy XamComboEditor). Each type's `xamlNamespace`/`defaultPrefix` (and `xamlName` for nested types) come from `__xaml`; types it lacks (open generics, non-public types, DLLs left out of XAML resolution) fall back to `clr-namespace:`. `namespaces.json` is only `Xam*`-prefixed types. `TFM_PREF` and `byVersionDesc` pick one lib folder per package, and only XML docs with their DLL beside them are read (Infragistics.WPF.Spreadsheet.Charts.Trial also ships XamSpreadsheet's XML). +- `scripts/build-docs.ts` converts `.adoc` topics to `docs/{slug}.json` + `docs-index.json`. docs-common topics use `{PlaceholderName}` variables resolved from `docs-common/DocsConfig.xml` for the `wpf` product; only folders in `COMMON_FOLDERS` are taken from docs-common (not `data-grid`: the web/Blazor grid; not `win-web-chart`: WinForms/ASP.NET UltraChart; not `scheduler`: the Xamarin XamScheduler, which WPF doesn't have — its topics have no WPF content and matched `xamSchedule` searches by substring; `COMMON_NOT_WPF` also leaves out productivity-tools' Xamarin.Forms App Map, Toolbox and data grid configurator topics, which only `ifdef::xamarin[]` blocks link), each mapped to the DocsConfig variable naming its control, which a topic whose metadata names no control gets (all of shape-chart, most of financial-chart); an unresolved `{Placeholder}` counts as no control, and the folder's alias (`financial-chart`, `FinancialChart`) becomes that control. Nearly every `.adoc` starts with a UTF-8 byte-order mark, stripped on read. A topic with no metadata `name` takes its file name as slug and is kept only with a title: its `= Title`, or failing that its first heading when every other heading is deeper (`== Title` over `===` sections). `include::file.adoc[]` lines are replaced by that file, as Asciidoctor does (path relative to the including file, nested includes too, cycle-guarded); the sources give no include attributes, so one (`leveloffset`, `tag`, `lines`…) fails the build, as do more than 3 includes whose file is missing (each is logged). Included fragments with no metadata `name` and no title (`shared-crossfieldrecordfiltering`) are not topics, and a file that is only an include of another topic (`xamdatagrid-external-filtering-copy`) is left out as a duplicate; one that puts a title of its own over it (`xamdatapresenter-field-grouping`) drops the pasted title. A file other topics include that has a metadata `name` but no title (`configuring-template-field`, the xamDiagram summary charts) is written, so `get_wpf_doc` still opens it, but its index entry has nothing to search on. A docs-wpf file is left out when docs-common has a file of the same name (the docs site serves the shared one; docs-wpf's `bulletgraph.adoc` is only a title). A topic's slug is its metadata `name`; when several files claim one (seven are named `piechart-selection`), the file named after it keeps it, a copy with the same title and body (`home-page`) is left out, and the others take their own file names and, in docs-common, their folder's control (their metadata, `controlName` included, is the other file's); two topics left with one slug, case-insensitively, fail the build. Like a WPF build of the docs, it keeps only WPF's platform content, using the `wpf` product's `BuildFlags` (`xaml,wpf`, matched case-insensitively): `ifdef::…[]` blocks for other platforms, `pick:[platforms="text"]` wording for other platforms, and whole topics whose metadata `buildFlags` name only other platforms (`docx-*` flags are output formats) are dropped. The body is Markdown: `#` headings, code in ``` fences with its language; code blocks are set aside before the prose clean-up, which would otherwise delete `{Binding}`-style tokens. Each index entry carries `mentions` (Xam* names in the body, lowercased → count), which `search_wpf_docs`' `control` filter matches besides the topic's tags and title. `search_wpf_docs` lists a topic published for several controls once (same title but for a `(xamX)` suffix, or same opening words, and same text but for Xam* names: `xamdatagrid-X` and `xamdatapresenter-X` pasting one include), as the copy for `control`, naming the others. +- `scripts/build-themes.ts` copies `Themes/` (newer ThemeManager family) and `DefaultStyles/` (legacy embedded-BAML `Theme="..."` family) XAML from wpf-resources and records per-file `targetTypes` parsed from `TargetType=` declarations — this is what makes "is component X themeable" lookups content-aware rather than filename-based. A `Themes/` file also gets the controls its theme maps to it in `type-info.json`'s `__themeMappings`: the ThemeManager styles most legacy controls too (XamDataGrid, the xamEditors, XamTreeGrid, XamTabControl…), through files that only merge their legacy styles (`RoyalDark.xamDataPresenter.xaml` is ``) and name no `TargetType`, so before, `setup_wpf_theme(component: "XamDataGrid")` offered only `Theme="..."`. The build fails when a mapped file or theme is missing from wpf-resources (the submodule and the theme packages out of step). `setup_wpf_theme` then lists such a control under both families and, when it has a `Theme` property, says to use `ApplicationTheme` for an app-wide theme. - `scripts/build-guard.ts` `assertBuildStep()` is the fail-loud convention: any step whose output could be legitimately zero due to a missing upstream must assert instead of `console.warn`. ### Runtime (`src/`, frozen — not published, not edited; kept for reference and the TS smoke test) @@ -64,7 +64,7 @@ Release/publish process (`npm run release -- ` → PR → GitHub Release w ### C# runtime (`server/`) - `Program.cs` — same bootstrap as `src/index.ts` on the official `ModelContextProtocol` SDK: stderr-only logging, `--debug` file log, `ServerInstructions.cs` (the server instructions; started as a copy of the TS ones), `.WithTools()` per tool class. -- `Data/Models.cs` (records + `WpfJsonContext` source-gen), `Data/DataStore.cs` (startup indexes, on-demand loaders, `ResolveInside` containment check), `Data/NameComparer.cs` (deterministic stand-in for JS `localeCompare`). +- `Data/Models.cs` (records + `WpfJsonContext` source-gen), `Data/DataStore.cs` (startup indexes, on-demand loaders — `LoadApiType` takes a short name or a full name, which it resolves through the short name's page and its `alternates` — and the `ResolveInside` containment check), `Data/NameComparer.cs` (deterministic stand-in for JS `localeCompare`). - `Tools/ComponentTools.cs`, `SearchTools.cs`, `DocsTools.cs`, `ThemeTools.cs` — one `[McpServerTool]` method per tool; parameter `[Description]`s are the agent-facing input guidance (originally ported from the zod `.describe()` text), DataAnnotations are schema-only constraints (the SDK doesn't enforce them; clamp/validate in code). `Tools/ToolDescriptions.cs` the tool descriptions (ported from `constants.ts`), `Tools/Enums.cs` the enum inputs, `Tools/ToolLog.cs` the `--debug` logger. - `Infragistics.Wpf.Mcp.csproj` — `PackAsTool` + `PackageType McpServer`; `src/data/**` is a `Content` item with `Pack="false"` (it ships inside the tool folder, not as NuGet content), root `server.json` is packed as `.mcp/server.json`. - Some JS quirks were ported on purpose (JS `""` falsiness for optional strings) — see comments at each site; keep them unless you mean to change agent-visible behavior. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index cc6d025..6c579c3 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -26,7 +26,7 @@ npm run build:all |---|---|---|---| | NuGet restore | `docs:restore` | `nuget/WpfDocs.csproj` | `nuget/packages/` | | Type extraction | `generate` → `scripts/generate.ts` → `dotnet run scripts/type-extractor` | `nuget/packages/**/*.dll` | `nuget/type-info.json` | -| API build | `generate` → `scripts/build-api.ts` | `nuget/packages/**/*.xml` + `type-info.json` | `src/data/api/*.json`, `namespaces.json`, `search-index.json` | +| API build | `generate` → `scripts/build-api.ts` | `nuget/packages/**/*.xml` + `type-info.json` | `src/data/api/*.json`, `src/data/api/alternates/*.json`, `namespaces.json`, `search-index.json` | | Docs build | `build:docs` | `docs/docs-wpf`, `docs/docs-common` | `src/data/docs/*.json`, `docs-index.json` | | Themes build | `build:themes` | `docs/wpf-resources` | `src/data/theme-resources/**`, `theme-index.json` | | Build info | `build:info` | csproj + submodule SHAs | `src/data/build-info.json` | @@ -39,7 +39,7 @@ First run takes 2–5 minutes, almost all of it the NuGet restore and the type e A healthy build ends with these counts (they drift slightly between Infragistics versions): ``` -Registry: 189 Xam* controls → src/data/namespaces.json +Registry: 183 Xam* controls → src/data/namespaces.json Search index: 6907 types → src/data/search-index.json Topics written : 2678 theme-index.json: 8 newer themes, 45 legacy style folders, 822 XAML files total. @@ -241,7 +241,7 @@ A version can never be pushed twice, so a rerun of the workflow for a tag that a ### Why reflection at build time -Infragistics WPF NuGet packages ship two files per assembly: `InfragisticsWPF.*.dll` and the compiler-generated `InfragisticsWPF.*.xml` doc comments. The XML gives summaries but no property types, enum values or inheritance; only reflection on the DLL does. So `scripts/type-extractor/Program.cs` loads every Infragistics DLL into one `AssemblyLoadContext`, walks all public types and writes `nuget/type-info.json` — a flat map of `"Assembly|Namespace.Type.Prop" → {typeName, isNullable, isEnum, enumValues}` (static properties and static `ResourceKey`/`RoutedCommand` fields included, the latter with `isField`), plus `__baseTypes`, `__xaml`, `__nonPublicTypes` and `__nonPublicMembers`, all keyed by assembly + full name so types sharing a short name can't overwrite each other (with short-name keys, the XGrid `FilterOperand.Icon`, a `String`, replaced the XamGrid one's `DataTemplate` on 49 pages). `scripts/build-api.ts` then merges that with the XML docs. +Infragistics WPF NuGet packages ship two files per assembly: `InfragisticsWPF.*.dll` and the compiler-generated `InfragisticsWPF.*.xml` doc comments. The XML gives summaries but no property types, enum values or inheritance; only reflection on the DLL does. So `scripts/type-extractor/Program.cs` loads every Infragistics DLL into one `AssemblyLoadContext`, walks all public types and writes `nuget/type-info.json` — a flat map of `"Assembly|Namespace.Type.Prop" → {typeName, isNullable, isEnum, enumValues}` (static and protected properties and static `ResourceKey`/`RoutedCommand` fields included, the latter with `isField`), plus `__baseTypes`, `__xaml`, `__nonPublicTypes` and `__nonPublicMembers`, all keyed by assembly + full name so types sharing a short name can't overwrite each other (with short-name keys, the XGrid `FilterOperand.Icon`, a `String`, replaced the XamGrid one's `DataTemplate` on 49 pages). `scripts/build-api.ts` then merges that with the XML docs. The xmlns of each type is resolved here, not guessed: the extractor reads every DLL's `XmlnsDefinition`/`XmlnsPrefix` attributes and asks System.Xaml's `XamlSchemaContext` — the rules the WPF markup compiler applies — which xmlns resolves to exactly that type. When a namespace has several (e.g. `http://infragistics.com/Themes` and the catch-all `http://schemas.infragistics.com/xaml/wpf`), the one mapping the fewest namespaces wins. Types no Infragistics xmlns reaches get `clr-namespace:Ns;assembly=Asm`, with a unique `ig…` prefix; nested types get `xamlName` `Outer+Inner`. So do types with a rival under the xmlns: another top-level type of the same name, public or internal, in a namespace and DLL that xmlns also covers. `XamlSchemaContext` ignores internal types and settles same-named public ones by assembly order, but the WPF markup compiler does neither: XamDataTree's public `NodesManager` under `http://schemas.infragistics.com/xaml` failed with MC3064 because XamDiagram has an internal `NodesManager` under the same xmlns, and the RichTextEditor/SyntaxEditor twins depend on which package the app references (13 indexed types). A DLL whose Infragistics references aren't in the set (the .NET Framework-only Exchange connector) is left out of resolution, because `XamlSchemaContext` throws on every lookup otherwise. Measured on 26.1.21: all 5,180 public non-generic indexed types resolve to the exact type, and a WPF build that uses every one of them with its generated xmlns compiles with no errors (the previous per-assembly table got 1,984 of them wrong). No reflection happens at query time; a tool call is a JSON file read. @@ -250,22 +250,27 @@ The xmlns of each type is resolved here, not guessed: the extractor reads every - XML files are parsed with split-based chunking, not a regex over the whole multi-MB file, and members are bucketed into a `Map` (O(n)). - `type-info.json`'s per-member entries are read with regexes, so the captured strings are JSON-unescaped (System.Text.Json writes `` ` `` and `<` as ```/`<`); without that, generic names never matched and `Action` reached agents as `Action`. - `enrichWithInheritance()` walks `__baseTypes` chains by assembly + full name (cycle-guarded) and copies parent members onto children tagged with `declaredOn`. It looks each base up by full name among every parsed type, not just the pages, so a same-named type (chart `Axis` vs Excel `Axis`) can't leak members in, and a base that lost its short name still passes its members on (XamGrid's `Cell`, whose 11 subclasses had no members from it; 29 types in all). Generic bases (`DataPresenterResourceSet`1`, which holds the theme classes' static styles) are linked through their definition. This is why `XamDataGrid` shows dozens of properties although it declares few directly. -- Static `ResourceKey`/`RoutedCommand` fields (`F:` entries marked `isField` by the extractor — brush keys, commands) are listed with the properties; other fields (enum values, `DependencyProperty` identifiers) are not. +- Static `ResourceKey`/`RoutedCommand` fields (`F:` entries marked `isField` by the extractor — brush keys, commands) are listed with the properties; other fields (`DependencyProperty` identifiers) are not. +- Enum values are not members: the extractor writes every enum's values in declaration order with their numbers and `[Flags]` (`__enums`), and the enum's page gets them as `values` (summaries from the XML's `F:` entries; hidden values left out, `[Obsolete]` ones kept with their message) plus `isFlags`. On 26.1.21 that is 7,260 values on 894 enum pages (48 `[Flags]`), which used to say "No members defined directly on this type". +- Protected and protected internal properties and events look public in the XML docs. The extractor records their types like public ones and flags them `protected` in `__members`; the pages mark them `isProtected` and the server shows `_(protected)_` (methods and indexers say `protected` in their signature). On 26.1.21: 1,575 declared protected properties and 3 events, 10,266 entries with inherited copies (`XamGrid.CurrentEditCell`, a CS0122 from outside the grid). - Types matching `EXCLUDE_SUFFIXES` / `EXCLUDE_CONTAINS` / `EXCLUDE_PACKAGES` are dropped, as are members whose summary is literally `internal`. - Non-public types are dropped too. The XML docs also document internal and private nested classes (`ActivePaneManager`, `XamFunnelController`, …) that no app can use — on 26.1.21 they were 1,653 of the 6,907 pages, and search ranked some first ("active pane" → `ActivePaneManager`). The extractor lists the types reflection positively sees as non-public (`__nonPublicTypes`; a type it can't load is never listed, so a load failure can't hide public API), and `build-api.ts` skips them before short-name collisions are resolved, so 89 names that an internal class used to win now document the public class (`DashboardTileGroupDescription`). No public type inherits from a non-public one (C# forbids it), so no inherited members are lost. - Non-public members of public types are dropped the same way. The extractor lists the member names that reflection sees only as internal, private or private protected on that type (`__nonPublicMembers`; a name with any public or protected overload stays, and nested public types are covered), and explicit interface implementations are recognized by the `#` in their doc name (`System#Collections#IList#Add`). On 26.1.21 that is 2,171 documented members, 9,006 entries across the pages once inherited copies count (`ValueEditor.InternalIsInitialized` on `ValueEditor` and its 13 subclasses, the `XamDockManager.FlyoutOpening` event). Checked by reflection against the DLLs, no internal or private member is left on any page; the check can't load `ExchangeScheduleDataConnector`, whose DLL is .NET Framework-only. - When two types share a short name, the one documenting more members wins the file name; the others are listed in its `alternates`. The count is taken before non-public members are dropped: with the smaller count, `Cell`, `FieldChooser` and `RecordFilterCollection` would switch from the DataPresenter types to the XamGrid / XamPivotGrid / DataManager ones. +- Each type that lost its name gets a page of its own in `api/alternates/`, built and enriched like the others, which `get_wpf_api_reference` opens by full name: before, only the winner could be opened, so an agent on XamDataGrid got XamGrid's `SelectionType` (`None`, `Single`, `Multiple`) and one that wanted the newer `Infragistics.Controls.Editors.XamComboEditor` got "not found" for its full name. The file name is the lookup: the full name, plus `, Assembly` when two assemblies have the same full name (`Infragistics.Controls.Editors.Caret` is in both the XamRichTextEditor and the XamSyntaxEditor assembly). The server finds it through the short name's page, whose `alternates` list every other type of the name with its lookup, so a short-name lookup reads `api/` as before. Every page of a shared name shows the others with the call that opens each and what tells them apart (member or value count after inheritance, NuGet package, xmlns, summary). One case is not another type: Infragistics.WPF.Spreadsheet.Charts.Trial ships the XML docs of the XamSpreadsheet assembly next to its own ChartAdapter DLL, so 87 types are listed twice with the same assembly and full name; their page notes the second package (`sameType`) instead. On 26.1.21, of 237 shared names, 150 belong to different types (185 alternate pages) and 87 are only these twins (`sameType` notes). The alternates are also in `search-index.json`, after the other entries so ties keep their order, with `f` set to their lookup (and the winner's full name on its entry); only the short name is matched, as for every entry, since namespace words ("Editors") would rank all of them up. That adds 1.7% to the index and changes the results of 3 of the 51 queries in the coverage-run transcripts, each time by bringing in the newer XamComboEditor those agents were searching for. - `xamlNamespace`/`defaultPrefix` (and `xamlName` for nested types) come from the extractor's `__xaml`; types it has no entry for (open generics, non-public types, DLLs left out of XAML resolution) fall back to `clr-namespace:` syntax, and the build logs how many. `namespaces.json` contains only `Xam*`-prefixed types. ### `get_wpf_api_reference` size guard Because inherited members are copied onto every subclass, a few hundred types (mostly charts and annotation layers) rendered to 25–72K characters, and clients like Claude Code save results that large to a file instead of returning them inline. The C# `get_wpf_api_reference` therefore compacts any response over `MaxApiReferenceChars` (25,000, in `ComponentTools.cs`), taking the first of these levels whose output, note included, fits: -1. Events and methods are listed by name only; properties keep full detail. Properties go last because they are what XAML needs, and on the grids nearly all of them are inherited (`XamDataGrid`: 1 of 70 is its own), so compacting inherited members first would hide exactly those. Skipped when the response has no properties (`kind: "events"`/`"methods"`). +1. Events and methods are listed by name only; properties keep full detail. Properties go last because they are what XAML needs, and on the grids nearly all of them are inherited (`XamDataGrid`: 1 of 69 is its own), so compacting inherited members first would hide exactly those. Skipped when the response has no properties (`kind: "events"`/`"methods"`). 2. Inherited members are listed by name only, one line per "inherited from" section (chosen over name + type when the guard was added: at a 25K threshold name-only gave `XamCategoryChart` 19.8K and left 8 types over the limit, while name + type gave 26.2K and left 12). -3. All members are listed by name only (9 types, among them `Series` and `XamDomainChart`, which declare 402 and 346 members themselves, and the four `…BrushKeys` classes). +3. All members are listed by name only (9 types, among them `Series` and `XamDomainChart`, which declare 401 and 345 members themselves, and the four `…BrushKeys` classes). -Section headings always stay, so every member name and its declaring type remain visible. A compacted response says so and points to the optional `member` parameter, a case-insensitive name substring that returns only the matching members, inherited ones included, in full detail. The guard applies to `member`-filtered responses as well, so a very broad filter can't bypass it. Measured over all 5,343 public types of the Infragistics 26.1.21 data (re-measure after a data bump): the largest full response is 116,994 chars (`RibbonBrushKeys`, whose keys are listed as static fields) and the largest after the guard 24,678 (`TimeXAxis`); p99 56,929 → 17,694. 191 types are compacted (9 at level 1, among them `XamDataGrid` and `XamTreeGrid` at 18.3K with every property in full; 173 at level 2, among them the charts; 9 at level 3) and all others render in full (`XamGrid` 23.5K). +Before it moves on to the next level, the guard tries the current one (full detail included) again with its protected properties and events listed by name only, in one `- protected: …` line per section: only a subclass can use them, and since they carry their type and the `_(protected)_` marker, the `kind: "properties"` responses of 21 types would otherwise go over the limit (25.2K–27.7K; `GeographicHighDensityScatterSeries`: 25.3K in full, 23.0K with them by name) and lose the detail of every inherited property. Protected methods stay out of this retry: a method's full line is its signature, which says `protected` itself. In a list of names, protected members come last, after `protected:`, so they don't pass for public ones: properties and events by their flag, and a method name when every overload listed in that section is protected (one with a public overload stays with the public names, overload count and all). On 26.1.21, across the default and `kind` responses, that is 40,340 of the 67,140 method names in compacted lists (XamDataChart's `SeriesViewer` methods: 25 of 36), and 290 stay public for a public overload; it costs up to 66 characters per response and moves no response to another level. + +Section headings always stay, so every member name and its declaring type remain visible. A compacted response says so and points to the optional `member` parameter, a case-insensitive name substring that returns only the matching members, inherited ones included, in full detail. The guard applies to `member`-filtered responses as well, so a very broad filter can't bypass it. Measured over all 5,186 pages of the Infragistics 26.1.21 data (re-measure after a data bump): with the default `kind`, the largest full response is 116,896 chars (`RibbonBrushKeys`, whose keys are listed as static fields) and the largest after the guard 24,976 (`UnboundField`); p99 67,799 → 19,279. 198 types are compacted (29 at level 1, among them `XamDataGrid` and `XamTreeGrid` at 17.7K with every property in full, and `XamGrid` at 15.7K; 160 at level 2, among them the charts; 9 at level 3), and the 2 enums too long to list in full (`UTMZone`, 1,197 values, and `SpreadsheetCommandType`) list their values by name; all others render in full (the largest, `StackedFragmentSeries`, 24.5K). With `kind: "properties"`, 21 types (20 chart series and `SeriesViewer`, the charts' base class) fit by listing their protected properties by name, 136 types are compacted at level 2 and 5 at level 3. ### `get_wpf_theme_resource` paging @@ -279,12 +284,13 @@ Theme files used to be cut at 24,000 characters, and 436 of the 822 (26.1.21) ar | File | Size | Loaded | Purpose | |---|---|---|---| -| `dist/data/namespaces.json` | ~50 KB | at startup | 189 Xam* control registry | -| `dist/data/search-index.json` | ~5 MB | at startup | compact index (`n,s,a,p,m` keys): name, summary, member names | +| `dist/data/namespaces.json` | ~50 KB | at startup | 183 Xam* control registry | +| `dist/data/search-index.json` | ~5 MB | at startup | compact index (`n,s,a,p,m` keys): name, summary, member names; `f`: full name of a type whose name others share | | `dist/data/docs-index.json` | ~1 MB | at startup | topic slugs, titles, control names, summaries | -| `dist/data/theme-index.json` | small | at startup | theme/style files with parsed `targetTypes` | +| `dist/data/theme-index.json` | small | at startup | theme/style files with their `targetTypes` (parsed `TargetType=`, plus the controls each ThemeManager theme maps to the file) | | `dist/data/build-info.json` | tiny | at startup | what the data was built from; printed to stderr | | `dist/data/api/{Type}.json` | 5–15 KB each | on demand | full member details | +| `dist/data/api/alternates/{Full.Name}.json` | same | on demand | the other types of a shared short name, opened by full name | | `dist/data/docs/{slug}.json` | varies | on demand | full topic text + XAML snippets | | `dist/data/theme-resources/**/*.xaml` | varies | on demand | raw theme resource dictionaries | diff --git a/README.md b/README.md index 6371fe9..95be62a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # wpf-mcp -MCP server for **Infragistics Ultimate UI for WPF** — component registry, XAML namespace lookup, full API reference with property types and enum values, keyword search across 5,300+ public types, and named-theme setup with verified recolor steps. +MCP server for **Infragistics Ultimate UI for WPF** — component registry, XAML namespace lookup, full API reference with property types, enum values and method signatures, keyword search across 5,100+ public types, and named-theme setup with verified recolor steps. Distributed as a NuGet `dotnet tool`, [`Infragistics.Wpf.Mcp`](https://www.nuget.org/packages/Infragistics.Wpf.Mcp) — currently in **beta** (`0.1.0-beta.1`). From the first stable release it will also be listed in the [MCP Registry](https://registry.modelcontextprotocol.io) as `io.github.Infragistics-Developer-Tools/wpf-mcp`. @@ -10,15 +10,15 @@ All tools are read-only (`readOnlyHint: true`, `openWorldHint: false`) — none | Tool | Description | |---|---| -| `list_wpf_components` | List all 189 Xam* controls with canonical XAML namespace URIs, NuGet packages, and descriptions. Always call this first before writing XAML. | +| `list_wpf_components` | List all 183 Xam* controls with canonical XAML namespace URIs, NuGet packages, and descriptions. Always call this first before writing XAML. | | `get_wpf_project_scaffold` | Generate ready-to-run `dotnet new` + `dotnet add package` + `dotnet restore` commands and xmlns declarations for a new WPF project. Pass component names resolved via `list_wpf_components`. | -| `search_wpf_api` | Search across all 5,300+ public types by keyword — matches type names, summaries, and member names. Use when you don't know the exact type name. | -| `get_wpf_api_reference` | Full API reference for any type: properties with types and enum values, events, methods, and inherited members grouped by base class. Large types are compacted to stay under ~25K characters (events and methods first, then inherited members, are listed by name only); the optional `member` filter (case-insensitive name substring, e.g. `"YAxis"`) returns full details for just the members you need. | +| `search_wpf_api` | Search across all 5,100+ public types by keyword — matches type names, summaries, and member names. Use when you don't know the exact type name. | +| `get_wpf_api_reference` | Full API reference for any type: properties with types and enum values, constructors and methods with the C# signature of every overload, events, and inherited members grouped by base class. Obsolete members are left out; looking one up returns its replacement. Large types are compacted to stay under ~25K characters (events and methods first, then inherited members, are listed by name only); the optional `member` filter (case-insensitive name substring, e.g. `"YAxis"`) returns full details, including parameter descriptions, for just the members you need. | | `setup_wpf_theme` | List available named themes and the raw XAML files backing them, covering both theming mechanisms (legacy embedded-BAML `Theme="..."` and newer `ThemeManager`). Returns ready-to-paste apply steps. Call first for any theming task. | | `get_wpf_theme_resource` | Retrieve the full raw XAML of one theme/style resource-dictionary file (real `Style`/`ControlTemplate`/brush definitions) to copy and override. Large files come in pages that concatenate to the exact file; `find` returns single styles, templates or brushes in full. Use the exact `path` from `setup_wpf_theme`. | | `get_wpf_theme_palette` | Return a newer-family (ThemeManager) theme's color/brush palette — exact resource keys and values — plus verified steps to recolor that theme (control-level overrides or an extended theme) without creating a new one. | -| `search_wpf_docs` | Search 2,600+ how-to documentation topics by keyword and/or control name — layouts, styling, data binding, filtering/sorting/grouping, exporting, performance, known issues, etc. | -| `get_wpf_doc` | Full text (including XAML samples) of one documentation topic by slug. Use the `slug` from a `search_wpf_docs` result. | +| `search_wpf_docs` | Search 2,300+ how-to documentation topics by keyword and/or control name — layouts, styling, data binding, filtering/sorting/grouping, exporting, performance, known issues, etc. A control name also finds the topics that cover it under a family (e.g. `XamMaskedInput` under xamInputs). | +| `get_wpf_doc` | Full text of one documentation topic by slug, with its XAML, C# and VB samples; topics over ~24K characters come in pages. Use the `slug` from a `search_wpf_docs` result. | ## Installation diff --git a/scripts/build-api.ts b/scripts/build-api.ts index 648d7d6..e5b9027 100644 --- a/scripts/build-api.ts +++ b/scripts/build-api.ts @@ -2,7 +2,8 @@ /** * build-api.ts * - * Generates src/data/api/{TypeName}.json (one file per public Infragistics type) + * Generates src/data/api/{TypeName}.json (one file per public Infragistics type short name), + * src/data/api/alternates/{Full.Name}.json (the other types of a shared short name), * and regenerates src/data/namespaces.json (Xam* controls only, used by list_wpf_components) * * Prerequisites: nuget/packages/ must be populated first. @@ -19,6 +20,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); const PACKAGES_DIR = join(ROOT, 'nuget', 'packages'); const API_OUT_DIR = join(ROOT, 'src', 'data', 'api'); +// Pages of the types that lost their short name to another type, named after their lookup (full name). A +// subfolder, so a short-name lookup's case-insensitive scan of api/ never sees them. +const ALT_OUT_DIR = join(API_OUT_DIR, 'alternates'); const NS_OUT_FILE = join(ROOT, 'src', 'data', 'namespaces.json'); // ── Type name exclusion list ────────────────────────────────────────────────── @@ -32,6 +36,17 @@ const EXCLUDE_CONTAINS = ['Internal', '<>', 'AnonymousType']; // become customer-facing later, so this is a plain omission, not a "deprecated" label. const EXCLUDE_PACKAGES = ['infragistics.wpf.controls.grids.xamxgrid.trial']; // compared lowercase +// Controls whose docs topics open with a retirement banner (variables in docs-common/DocsConfig.xml): XamGrid "has been +// retired … we recommend migrating to [XamDataGrid]" (XamGridHeader, 128 topics); the xamInputs "being planned for +// retirement … we recommend that you use the xamEditor controls instead" (XamInputHeader, XamMaskedInputHeader, +// XamNumericInputHeader, XamCurrencyInputHeader, XamDateTimeInputHeader). Their types never take a short name another +// type has: "SelectionType" is XamDataGrid's, "DaySection" XamMaskedEditor's. +const RETIRED_ASSEMBLIES = new Set([ + 'Infragistics.WPF.Controls.Grids.XamGrid', + 'Infragistics.WPF.Controls.Editors.XamMaskedInput', // XamMaskedInput, XamNumericInput, XamCurrencyInput + 'Infragistics.WPF.Controls.Editors.XamDateTimeInput', +]); + function shouldExclude(typeName: string): boolean { return ( EXCLUDE_SUFFIXES.some(s => typeName.endsWith(s)) || @@ -39,6 +54,20 @@ function shouldExclude(typeName: string): boolean { ); } +// Which of the types of a shared short name takes it: the higher rank wins, then the more documented one. A retired +// control's type ranks lowest; a legacy Infragistics.Windows. type below a newer copy of it, the same name in +// Infragistics.Controls. or Infragistics. (Editors: the newer XamComboEditor, XamCalendar's parts over +// XamMonthCalendar's; Themes: the ThemeManager of the newer themes). Areas must match: XamPivotGrid's FieldChooser +// (Infragistics.Controls.Grids) is not a newer XamDataGrid one (Infragistics.Windows.DataPresenter). +const nsArea = (ns: string) => ns.replace(/^Infragistics\.(?:Windows\.|Controls\.)?/, '').split('.')[0]; +const isLegacy = (ns: string) => ns.startsWith('Infragistics.Windows.'); +function familyRank(e: ApiEntry, group: ApiEntry[]): number { + if (RETIRED_ASSEMBLIES.has(e.assembly)) return 0; + const newerCopy = isLegacy(e.dotnetNamespace) && group.some(o => + !isLegacy(o.dotnetNamespace) && !RETIRED_ASSEMBLIES.has(o.assembly) && nsArea(o.dotnetNamespace) === nsArea(e.dotnetNamespace)); + return newerCopy ? 1 : 2; +} + /** * Newest-first ordering for NuGet version folder names. * A plain string sort ranks "26.1.9" above "26.1.21", so it picks the older package @@ -61,11 +90,27 @@ function byVersionDesc(a: string, b: string): number { } // ── XML parsing ─────────────────────────────────────────────────────────────── +// The name a reference reads as: "M:Ns.Type.Save(System.Boolean)" → "Save", "T:Ns.Aggregator`1" → "Aggregator". +// Namespaces ("N:") and references the compiler couldn't resolve ("!:Field.Label") are kept whole. +function crefName(ref: string): string { + const id = ref.replace(/^[A-Za-z!]+:/, ''); + if (/^[N!]:/.test(ref)) return id; + return id.replace(/\(.*$/, '').replace(/~.*$/, '').split('.').pop()!.replace(/`+\d+$/, ''); +} + +// XML doc markup → plain text. References and // become the name or keyword +// they point to (dropping them left gaps like "over the specified ."); other tags keep their text. function stripXml(text: string): string { return text - .replace(//g, (_, ref) => ref.split('.').pop() ?? ref) + .replace(/<(?:see|seealso)\s+cref="([^"]+)"\s*\/>/g, (_, ref) => crefName(ref)) + .replace(/<(?:paramref|typeparamref)\s+name="([^"]+)"\s*\/>/g, '$1') + .replace(//g, '$1') .replace(/<[^>]+>/g, ' ') + .replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&') .replace(/\s+/g, ' ') + // A removed tag leaves a space before the punctuation after it: "is null." → "is null." Only punctuation + // that ends a word, so "the .NET Framework" and "a value of .5" keep theirs. + .replace(/\s+([.,;:!?)])(?=\s|$)/g, '$1').replace(/\(\s+/g, '(') .trim(); } @@ -74,7 +119,8 @@ function extractTag(xml: string, tag: string): string { return m ? stripXml(m[1]) : ''; } -interface RawMember { kind: string; fullName: string; name: string; summary: string; remarks: string } +interface ParamDoc { name: string; summary: string } +interface RawMember { kind: string; fullName: string; name: string; summary: string; remarks: string; params?: ParamDoc[] } function parseAllMembers(xml: string): RawMember[] { // Split on '); const endIdx = chunk.indexOf(''); const body = gtIdx >= 0 && endIdx > gtIdx ? chunk.substring(gtIdx + 1, endIdx) : ''; - results.push({ kind, fullName, name: shortName, summary: extractTag(body, 'summary'), remarks: extractTag(body, 'remarks') }); + // Parameter descriptions tell overloads apart when they share a summary ("outputFormat: … (XPS only)"). + const params = kind === 'M' + ? [...body.matchAll(/([\s\S]*?)<\/param>/g)] + .map(p => ({ name: p[1], summary: stripXml(p[2]) })).filter(p => p.summary) + : []; + results.push({ + kind, fullName, name: shortName, summary: extractTag(body, 'summary'), remarks: extractTag(body, 'remarks'), + ...(params.length ? { params } : {}), + }); } return results; } @@ -139,6 +193,9 @@ function discoverXmlFiles(): XmlSource[] { for (const f of readdirSync(libDir)) { if (!f.endsWith('.xml')) continue; + // Only the docs of a DLL the package ships: Infragistics.WPF.Spreadsheet.Charts.Trial also carries XamSpreadsheet's + // XML (without its DLL) beside its own ChartAdapter DLL, which made it the NuGet package of XamSpreadsheet's types. + if (!existsSync(join(libDir, `${f.slice(0, -'.xml'.length)}.dll`))) continue; const xmlPath = join(libDir, f); if (!seen.has(xmlPath)) { seen.add(xmlPath); @@ -170,12 +227,54 @@ export interface ApiMemberEntry { declaredOn?: string; /** A static ResourceKey/RoutedCommand field (used via {x:Static}), listed with the properties. */ isField?: boolean; + /** C# signature of a method, constructor or indexer: "void Export(OutputFormat outputFormat, string fileName)". */ + signature?: string; + /** The XML docs' parameter descriptions (methods and constructors). */ + parameters?: ParamDoc[]; + /** A protected (or protected internal) property or event: usable only from a subclass. Methods say so in their signature. */ + isProtected?: boolean; +} + +/** One value of an enum, in declaration order. */ +export interface EnumValueEntry { + name: string; + /** Its number (the enum's underlying type, as a JSON number). */ + value: number; + summary: string; + /** Set when the value is [Obsolete]: its message, which usually names the replacement. */ + obsolete?: string; +} + +/** A member marked [Obsolete]: left out of the member lists, kept so a lookup by name gets the replacement. */ +export interface ObsoleteMember { + name: string; + kind: 'property' | 'event' | 'method' | 'constructor'; + /** The [Obsolete] message, which usually names the replacement ("Please use the IsHorizontalZoomEnabled property."). */ + message: string; + signature?: string; + declaredOn?: string; } +/** Another type with the page's short name, or the same class listed under another package (sameType). */ export interface AlternateType { fullName: string; nugetPackage: string; + /** Properties + events + methods, inherited ones included: counted as on the type's own page. */ memberCount: number; + assembly: string; + /** The same class as the page's (same assembly and full name), whose XML docs another package ships too: no page of its own. */ + sameType?: boolean; + /** What get_wpf_api_reference takes to open its page: the full name, plus ", Assembly" when another type of the name has that full name too. */ + lookup?: string; + /** The type get_wpf_api_reference opens for the short name (listed on the other types' pages). */ + isDefault?: boolean; + xamlNamespace?: string; + defaultPrefix?: string; + /** The first sentence of its summary. */ + summary?: string; + /** Enums: how many values, and the first few names. */ + valueCount?: number; + valueNames?: string[]; } export interface ApiEntry { @@ -193,9 +292,21 @@ export interface ApiEntry { baseType?: string; /** Set only when other Infragistics types share this short name. */ alternates?: AlternateType[]; + /** Set only on the page of a type that lost its short name (api/alternates/): what get_wpf_api_reference takes to open it. */ + lookup?: string; properties: ApiMemberEntry[]; events: ApiMemberEntry[]; methods: ApiMemberEntry[]; + /** Public constructors that take parameters (a parameterless one goes without saying). Only when there are any. */ + constructors?: ApiMemberEntry[]; + /** Only when there are any. */ + obsolete?: ObsoleteMember[]; + /** Set when the type itself is [Obsolete]: its message, which usually names the replacement. */ + obsoleteMessage?: string; + /** Enums only: their values, in declaration order (those hidden from IntelliSense left out). */ + values?: EnumValueEntry[]; + /** Enums only, when [Flags]: values combine. */ + isFlags?: boolean; } export interface RegistryEntry { @@ -206,6 +317,8 @@ export interface RegistryEntry { nugetPackage: string; assembly: string; description: string; + /** Other types of this name, opened by full name (not the same class under another package). */ + alternates?: AlternateType[]; } interface TypeInfoEntry { @@ -219,6 +332,12 @@ interface TypeInfoEntry { /** XAML namespace of one type, resolved by the type extractor (see "Phase 3" in type-extractor/Program.cs). */ interface XamlTypeInfo { xmlns: string; prefix: string; ns: string; xamlName?: string } +/** What reflection says about one member, keyed by "Assembly|" (see "__members" in type-extractor/Program.cs). */ +interface MemberMeta { sig?: string; params?: string[]; overrides?: string; obsolete?: string; hidden?: boolean; protected?: boolean } + +/** One enum's values in declaration order, keyed by "Assembly|Namespace.Type" (see "__enums" in type-extractor/Program.cs). */ +interface EnumInfo { flags?: boolean; values: { name: string; value: number }[] } + function loadTypeInfo(): Record { const path = join(ROOT, 'nuget', 'type-info.json'); assertBuildStep(existsSync(path), @@ -253,26 +372,57 @@ function loadTypeInfo(): Record { /** * The extractor's maps keyed by "Assembly|Namespace.Type" (nested: "…|Namespace.Outer.Inner"), so types that share - * a short name can't overwrite each other: each type's XAML namespace and prefix, its Infragistics base type, and - * the types and members ("…|Namespace.Type.Member") that are not public. + * a short name can't overwrite each other: each type's XAML namespace and prefix, its Infragistics base type, the + * types and members ("…|Namespace.Type.Member") that are not public, signatures and [Obsolete] / hidden / protected + * flags by doc ID ("…|M:Namespace.Type.Load(System.String)") for the types in memberTypes, the doc IDs of their + * internal / private methods and constructors, and every enum's values. */ function loadExtractorMaps(): { xaml: Record; baseTypes: Record; nonPublic: Set; nonPublicMembers: Set; + members: Record; nonPublicMethods: Set; memberTypes: Set; enums: Record; } { const parsed = JSON.parse(readFileSync(join(ROOT, 'nuget', 'type-info.json'), 'utf-8')); const xaml = parsed.__xaml as Record | undefined; const baseTypes = parsed.__baseTypes as Record | undefined; const nonPublic = parsed.__nonPublicTypes as string[] | undefined; const nonPublicMembers = parsed.__nonPublicMembers as string[] | undefined; + const members = parsed.__members as Record | undefined; + const nonPublicMethods = parsed.__nonPublicMethods as string[] | undefined; + const memberTypes = parsed.__memberTypes as string[] | undefined; + const enums = parsed.__enums as Record | undefined; assertBuildStep(!!xaml && Object.keys(xaml).length > 0 && !!baseTypes && Object.keys(baseTypes).length > 0 && - !!nonPublic?.length && !!nonPublicMembers?.length, - `nuget/type-info.json has no "__xaml" / "__baseTypes" / "__nonPublicTypes" / "__nonPublicMembers" section — it was ` + - `written by an older type extractor, so every type would get a guessed xmlns, no inherited members, and internal ` + - `classes and members would be indexed as usable. Run: npm run generate:types` + !!nonPublic?.length && !!nonPublicMembers?.length && !!members && Object.keys(members).length > 0 && + !!nonPublicMethods?.length && !!memberTypes?.length && !!enums && Object.keys(enums).length > 0, + `nuget/type-info.json has no "__xaml" / "__baseTypes" / "__nonPublicTypes" / "__nonPublicMembers" / "__members" / ` + + `"__nonPublicMethods" / "__memberTypes" / "__enums" section — it was written by an older type extractor, so every type ` + + `would get a guessed xmlns, no inherited members, internal classes and members would be indexed as usable, methods ` + + `would have no signatures and enums no values. Run: npm run generate:types` + ); + // Pages hold the numbers as JSON numbers, which the C# server reads as long: fail rather than round one. + const unsafe = Object.entries(enums!).flatMap(([k, e]) => e.values.filter(v => !Number.isSafeInteger(v.value)).map(v => `${k}.${v.name}`)); + assertBuildStep(unsafe.length === 0, + `${unsafe.length} enum value(s) in nuget/type-info.json "__enums" are not safe JSON integers (e.g. ${unsafe.slice(0, 3).join(', ')}) — ` + + `a ulong enum over 2^53? Write those values as strings (extractor, build-api, server/Data/Models.cs EnumValue).` ); - return { xaml: xaml!, baseTypes: baseTypes!, nonPublic: new Set(nonPublic), nonPublicMembers: new Set(nonPublicMembers) }; + return { + xaml: xaml!, baseTypes: baseTypes!, nonPublic: new Set(nonPublic), nonPublicMembers: new Set(nonPublicMembers), + members: members!, nonPublicMethods: new Set(nonPublicMethods), memberTypes: new Set(memberTypes), enums: enums!, + }; } +// A method's overload, as the tail of its doc ID ("Export(Infragistics.Windows.Reporting.OutputFormat,System.String)"): +// an override has the same one as the base method, a sibling overload doesn't. Except an override of a generic base +// type's method ("InsertItem(System.Int32,Ns.Column)" for "InsertItem(System.Int32,`0)"), which also gets the tail of +// the method it overrides from the extractor. Kept off the entries, so not written. +const overloadKeys = new WeakMap(); +const overriddenKeys = new WeakMap(); +const overloadKey = (m: ApiMemberEntry) => overloadKeys.get(m) ?? m.name; +const methodKeys = (m: ApiMemberEntry) => [overloadKey(m), ...(overriddenKeys.has(m) ? [overriddenKeys.get(m)!] : [])]; + +// Members a type re-declares as [Obsolete] or hidden from IntelliSense ("property|Formula", "method|Clear()"): not +// listed as current, and the base type's version isn't inherited either (TemplateColumn hides Column.ValueConverter). +const redeclared = new WeakMap(); + /** * Second pass: enrich every entry in-memory with inherited Infragistics members. * This walks the full base-type chain generically for ALL indexed types (properties, @@ -295,7 +445,17 @@ function enrichWithInheritance( const knownProps = new Set(entry.properties.map(p => p.name)); const knownEvents = new Set(entry.events.map(e => e.name)); - const knownMethods = new Set(entry.methods.map(m => m.name)); + // By overload, not name: a type that declares one Export overload still inherits the base type's other ones. + const knownMethods = new Set(entry.methods.flatMap(methodKeys)); + const knownObsolete = new Set((entry.obsolete ?? []).map(o => `${o.kind}|${o.signature ?? o.name}`)); + // A member re-declared [Obsolete] or hidden on a type hides every further base type's version of it. + const block = (e: ApiEntry) => { + for (const k of redeclared.get(e) ?? []) { + const at = k.indexOf('|'), kind = k.slice(0, at), key = k.slice(at + 1); + (kind === 'property' ? knownProps : kind === 'event' ? knownEvents : knownMethods).add(key); + } + }; + block(entry); const visited = new Set([fullKey(entry)]); let added = false; let current = fullKey(entry); @@ -322,11 +482,22 @@ function enrichWithInheritance( knownEvents.add(e.name); added = true; } - for (const m of baseEntry.methods.filter(m => !m.declaredOn && !knownMethods.has(m.name))) { - entry.methods.push({ ...m, declaredOn: base }); - knownMethods.add(m.name); + for (const m of baseEntry.methods.filter(m => !m.declaredOn && !methodKeys(m).some(k => knownMethods.has(k)))) { + const copy = { ...m, declaredOn: base }; + overloadKeys.set(copy, overloadKey(m)); + if (overriddenKeys.has(m)) overriddenKeys.set(copy, overriddenKeys.get(m)!); + entry.methods.push(copy); + for (const k of methodKeys(m)) knownMethods.add(k); added = true; } + // Obsolete members are inherited too, so looking one up on a subtype still gets its replacement. + for (const o of (baseEntry.obsolete ?? []).filter(o => !o.declaredOn && o.kind !== 'constructor' && + !knownObsolete.has(`${o.kind}|${o.signature ?? o.name}`))) { + if ((o.kind === 'property' && knownProps.has(o.name)) || (o.kind === 'event' && knownEvents.has(o.name))) continue; + (entry.obsolete ??= []).push({ ...o, declaredOn: base }); + knownObsolete.add(`${o.kind}|${o.signature ?? o.name}`); + } + block(baseEntry); current = baseKey; } @@ -341,8 +512,13 @@ async function generate() { mkdirSync(API_OUT_DIR, { recursive: true }); const typeInfo = loadTypeInfo(); - const { xaml: xamlTypes, baseTypes, nonPublic, nonPublicMembers } = loadExtractorMaps(); - let skippedNonPublic = 0, skippedNonPublicMembers = 0; + const { xaml: xamlTypes, baseTypes, nonPublic, nonPublicMembers, members: memberMeta, nonPublicMethods, memberTypes, enums } = loadExtractorMaps(); + let skippedNonPublic = 0, skippedNonPublicMembers = 0, skippedObsolete = 0, skippedHidden = 0; + let hiddenValues = 0; + const hiddenTypes: string[] = []; + let methodsKept = 0, methodsSigned = 0; + const unmatchedMethods: string[] = []; + const uncoveredTypes: string[] = []; // Prefix per xmlns, for the few types the extractor has no entry for (generics, DLLs left out of XAML resolution). const prefixByXmlns = new Map(Object.values(xamlTypes).map(x => [x.xmlns, x.prefix])); const xamlFallbacks: string[] = []; @@ -360,9 +536,12 @@ async function generate() { // same path concurrently and interleave into invalid JSON. loadApiDoc() also resolves // names case-insensitively, so a case-variant twin is unreachable regardless. const keyByLower = new Map(); - const memberCounts = new Map(); - const contestCounts = new Map(); - const alternates = new Map(); + // Every type of each lowercased short name, in discovery order; the contest is decided once all are known, since + // a type's rank depends on the others (familyRank). + const candidates = new Map(); + let seq = 0; + // The types that lost a short-name contest, by lowercased name: each gets a page of its own under api/alternates/. + const losers = new Map(); for (let fileIdx = 0; fileIdx < sources.length; fileIdx++) { const { xmlPath, packageId } = sources[fileIdx]; @@ -379,7 +558,8 @@ async function generate() { const members = parseAllMembers(xml); // Build lookup maps once per file — O(n) instead of O(n × types) - const typeMembers = new Map(); + type Bucket = { props: RawMember[]; fields: RawMember[]; events: RawMember[]; methods: RawMember[]; ctors: RawMember[] }; + const typeMembers = new Map(); const typeList: RawMember[] = []; for (const m of members) { if (m.kind === 'T') { typeList.push(m); continue; } @@ -391,12 +571,13 @@ async function generate() { const lastDot = memberPath.lastIndexOf('.'); if (lastDot < 0) continue; const typeFqn = memberPath.substring(0, lastDot); - if (!typeMembers.has(typeFqn)) typeMembers.set(typeFqn, { props: [], fields: [], events: [], methods: [] }); + if (!typeMembers.has(typeFqn)) typeMembers.set(typeFqn, { props: [], fields: [], events: [], methods: [], ctors: [] }); const bucket = typeMembers.get(typeFqn)!; if (m.kind === 'P') bucket.props.push(m); else if (m.kind === 'F') bucket.fields.push(m); // kept below only if the extractor marked it isField else if (m.kind === 'E') bucket.events.push(m); - else if (m.kind === 'M' && !m.name.startsWith('#')) bucket.methods.push(m); + else if (m.kind === 'M' && m.name === '#ctor') bucket.ctors.push(m); + else if (m.kind === 'M' && !m.name.startsWith('#')) bucket.methods.push(m); // not static constructors (#cctor) } for (const type of typeList) { @@ -409,7 +590,7 @@ async function generate() { // For a nested type this is "Namespace.Outer", which keeps the C# full name right; xamlName carries "Outer+Inner". const dotnetNs = type.fullName.substring(0, type.fullName.lastIndexOf('.')); - const bucket = typeMembers.get(type.fullName) ?? { props: [], fields: [], events: [], methods: [] }; + const bucket = typeMembers.get(type.fullName) ?? { props: [], fields: [], events: [], methods: [], ctors: [] }; let xaml = xamlTypes[`${assemblyName}|${type.fullName}`]; if (!xaml) { @@ -420,21 +601,99 @@ async function generate() { xamlFallbacks.push(type.fullName); } + // Signature and flags by doc ID. For a type the extractor covered, every public / protected method and + // constructor has an entry; a type it couldn't read keeps its members as the XML lists them, name-only. + const covered = memberTypes.has(`${assemblyName}|${type.fullName}`); + if (!covered) uncoveredTypes.push(type.fullName); + const meta = (m: RawMember): MemberMeta | undefined => memberMeta[`${assemblyName}|${m.kind}:${m.fullName}`]; + + // Protected (and protected internal) properties and events look like public ones in the XML docs. + const isProtected = (m: RawMember) => meta(m)?.protected ? { isProtected: true } : {}; const withTypeInfo = (m: RawMember) => { const ti = typeInfo[`${assemblyName}|${type.fullName}.${m.name}`]; const extra = (ti && typeof ti === 'object') ? ti : {}; - return { name: m.name, summary: m.summary, ...extra }; + const signature = meta(m)?.sig; // indexers: "double this[int index]" + return { name: m.name, summary: m.summary, ...extra, ...(signature ? { signature } : {}), ...isProtected(m) }; + }; + // Operators read as C# ("operator ==", "implicit operator WorkbookColorInfo"), generic methods without the + // doc ID's "``2" (the signature has the type parameters). + const methodName = (m: RawMember) => + meta(m)?.sig?.match(/((?:implicit |explicit )?operator [^(]+)\(/)?.[1].trim() ?? m.name.replace(/``\d+$/, ''); + const withSignature = (m: RawMember, name: string): ApiMemberEntry => { + const signature = meta(m)?.sig; + // The XML docs sometimes describe a parameter that was renamed or removed ("func" for funct): left out. + const real = signature ? new Set(meta(m)?.params ?? []) : undefined; + const parameters = m.params?.filter(p => !real || real.has(p.name)); + const entry: ApiMemberEntry = { + name, summary: m.summary, ...(signature ? { signature } : {}), ...(parameters?.length ? { parameters } : {}), + }; + overloadKeys.set(entry, m.fullName.slice(type.fullName.length + 1)); + if (meta(m)?.overrides) overriddenKeys.set(entry, meta(m)!.overrides!); + return entry; }; // The XML docs also document members no app can call: internal / private ones (ValueEditor.InternalIsInitialized), // and explicit interface implementations, whose doc names have '#' for '.' ("System#Collections#IList#Add"). const usable = (m: RawMember) => { if (m.summary.toLowerCase() === 'internal') return false; - if (m.name.includes('#') || nonPublicMembers.has(`${assemblyName}|${type.fullName}.${m.name.replace(/``\d+$/, '')}`)) { + if ((m.name.includes('#') && m.name !== '#ctor') || + nonPublicMembers.has(`${assemblyName}|${type.fullName}.${m.name.replace(/``\d+$/, '')}`) || + // Checked per overload: one public Load overload doesn't make an internal one usable. + (m.kind === 'M' && nonPublicMethods.has(`${assemblyName}|M:${m.fullName}`))) { skippedNonPublicMembers++; return false; } return true; }; + // [Obsolete] members go to their own list; members hidden from IntelliSense ("For internal use") are left out. + const current = (m: RawMember) => { + const flags = meta(m); + if (flags?.obsolete !== undefined) { skippedObsolete++; return false; } + if (flags?.hidden) { skippedHidden++; return false; } + return true; + }; + // Constructors: public ones that take parameters. A parameterless one goes without saying, and XAML needs it anyway. + const isUsefulCtor = (m: RawMember) => m.fullName.includes('(') && !!meta(m)?.sig && !meta(m)!.sig!.startsWith('protected '); + const ctorName = typeName.replace(/`\d+$/, ''); // "AverageResult", as the signature spells it + const props = bucket.props.filter(usable); + // Only the ResourceKey / RoutedCommand fields the extractor marked: the rest aren't used from XAML. + const fields = bucket.fields.filter(usable).filter(m => (withTypeInfo(m) as ApiMemberEntry).isField === true); + const events = bucket.events.filter(usable); + const rawMethods = bucket.methods.filter(usable); + const usableCtors = bucket.ctors.filter(usable); + const rawCtors = usableCtors.filter(isUsefulCtor); + + const obsolete: ObsoleteMember[] = [ + ...[...props, ...fields].map(m => ({ m, kind: 'property' as const, name: m.name })), + ...events.map(m => ({ m, kind: 'event' as const, name: m.name })), + ...rawMethods.map(m => ({ m, kind: 'method' as const, name: methodName(m) })), + ...rawCtors.map(m => ({ m, kind: 'constructor' as const, name: ctorName })), + ].filter(({ m }) => meta(m)?.obsolete !== undefined).map(({ m, kind, name }) => ({ + name, kind, message: meta(m)!.obsolete!, ...(meta(m)!.sig ? { signature: meta(m)!.sig } : {}), + })); + const methods = rawMethods.filter(current).map(m => withSignature(m, methodName(m))); + const constructors = rawCtors.filter(current).map(m => withSignature(m, ctorName)); + // A method or constructor of a type the extractor read that has neither a signature nor a non-public entry: the + // doc ID it generated didn't match the XML's. Methods stay listed by name, and both count against the signature + // gate below, so a mismatch fails the build instead of quietly dropping them. + const unmatched = covered ? [...rawMethods, ...usableCtors].filter(m => !meta(m)) : []; + unmatchedMethods.push(...unmatched.map(m => m.fullName)); + methodsKept += methods.length + constructors.length + unmatched.filter(m => m.name === '#ctor').length; + methodsSigned += [...methods, ...constructors].filter(m => m.signature).length; + + // An enum's values come from the extractor, in declaration order and with their numbers; the XML's "F:" entries + // only add summaries. Values hidden from IntelliSense are left out, [Obsolete] ones stay, marked (old code still + // uses them; obsolete wins over hidden, as for members). + const enumInfo = enums[`${assemblyName}|${type.fullName}`]; + let values: EnumValueEntry[] | undefined; + if (enumInfo) { + const summaries = new Map(bucket.fields.map(f => [f.name, f.summary])); + values = enumInfo.values.flatMap(v => { + const flags = memberMeta[`${assemblyName}|F:${type.fullName}.${v.name}`]; + if (flags?.hidden && flags.obsolete === undefined) { hiddenValues++; return []; } + return [{ name: v.name, value: v.value, summary: summaries.get(v.name) ?? '', + ...(flags?.obsolete !== undefined ? { obsolete: flags.obsolete } : {}) }]; + }); + } const entry: ApiEntry = { component: typeName, @@ -447,96 +706,191 @@ async function generate() { summary: type.summary, remarks: type.remarks, // Static key/command fields are listed with the properties: both are used the same way in XAML. - properties: [ - ...bucket.props.filter(usable).map(withTypeInfo), - ...bucket.fields.filter(usable).map(withTypeInfo).filter(f => (f as ApiMemberEntry).isField === true), - ], - events: bucket.events - .filter(usable) - .map(m => ({ name: m.name, summary: m.summary })), - methods: bucket.methods - .filter(usable) - .map(m => ({ name: m.name, summary: m.summary })), + properties: [...props, ...fields].filter(current).map(withTypeInfo), + events: events.filter(current).map(m => ({ name: m.name, summary: m.summary, ...isProtected(m) })), + methods, + ...(constructors.length ? { constructors } : {}), + ...(obsolete.length ? { obsolete } : {}), + // An [Obsolete] type keeps its page (old code and docs use UnboundField), which then leads with the replacement. + ...(memberMeta[`${assemblyName}|T:${type.fullName}`]?.obsolete !== undefined + ? { obsoleteMessage: memberMeta[`${assemblyName}|T:${type.fullName}`].obsolete } : {}), + ...(values ? { values } : {}), + ...(enumInfo?.flags ? { isFlags: true } : {}), }; + const flagged = (m: RawMember) => meta(m)?.obsolete !== undefined || !!meta(m)?.hidden; + const hiddenOrObsolete = [ + ...[...props, ...fields].filter(flagged).map(m => `property|${m.name}`), + ...events.filter(flagged).map(m => `event|${m.name}`), + ...rawMethods.filter(flagged).flatMap(m => [m.fullName.slice(type.fullName.length + 1), meta(m)?.overrides] + .filter((k): k is string => !!k).map(k => `method|${k}`)), + ]; + if (hiddenOrObsolete.length) redeclared.set(entry, hiddenOrObsolete); allTypes.set(`${assemblyName}|${type.fullName}`, entry); - - const memberCount = entry.properties.length + entry.events.length + entry.methods.length; - // Short-name contests are still decided by the count from before internal / private members were left out: - // with the new count, Cell, FieldChooser and RecordFilterCollection switched from the DataPresenter types to the - // XamGrid / XamPivotGrid / DataManager ones. + // A type hidden from IntelliSense (XamSparklineView, SeriesRenderManager) is public only for Infragistics' own + // controls: no page and no list_wpf_components entry, but still a base its subtypes inherit members from. + // An obsolete one keeps its page for the replacement note. + const typeFlags = memberMeta[`${assemblyName}|T:${type.fullName}`]; + if (typeFlags?.hidden && typeFlags.obsolete === undefined) { hiddenTypes.push(type.fullName); continue; } + + // Short-name contests between types of one rank are still decided by the count from before internal / private (and + // obsolete / hidden) members were left out: with the new count, FieldChooser and RecordFilterCollection switched + // from the DataPresenter types to the XamPivotGrid / DataManager ones. const documented = (ms: RawMember[]) => ms.filter(m => m.summary.toLowerCase() !== 'internal').length; - const contestCount = documented(bucket.props) + entry.properties.filter(p => (p as ApiMemberEntry).isField).length + - documented(bucket.events) + documented(bucket.methods); - const candidate: AlternateType = { - fullName: type.fullName, - nugetPackage: packageId, - memberCount, - }; + const contestCount = documented(bucket.props) + fields.length + documented(bucket.events) + documented(bucket.methods); const lower = typeName.toLowerCase(); - const incumbentKey = keyByLower.get(lower); - - if (incumbentKey === undefined) { - entries.set(typeName, entry); - keyByLower.set(lower, typeName); - memberCounts.set(lower, memberCount); - contestCounts.set(lower, contestCount); - } else { - // Two different types share this short name. Discovery order is just - // package-alphabetical, which surfaces stub types over the real control - // (e.g. a 20-member XamComboEditor beating the 125-member one), so keep - // whichever documents more members and record the other as an alternate. - const incumbent = entries.get(incumbentKey)!; - if (!alternates.has(lower)) alternates.set(lower, []); - - if (contestCount > contestCounts.get(lower)!) { - alternates.get(lower)!.push({ - fullName: `${incumbent.dotnetNamespace}.${incumbent.component}`, - nugetPackage: incumbent.nugetPackage, - memberCount: memberCounts.get(lower)!, - }); - entries.delete(incumbentKey); - entries.set(typeName, entry); - keyByLower.set(lower, typeName); - memberCounts.set(lower, memberCount); - contestCounts.set(lower, contestCount); - } else { - alternates.get(lower)!.push(candidate); - } - } + if (!candidates.has(lower)) candidates.set(lower, []); + candidates.get(lower)!.push({ entry, count: contestCount, seq: seq++ }); } console.log(`${Date.now() - t0}ms (${members.length} members)`); } + // Short-name contests, replayed in discovery order: a type takes the name from the one holding it if it ranks higher + // (familyRank), or as high and documents more members. Discovery order is just package-alphabetical, which surfaces + // stub types over the real control (e.g. a 20-member XamComboEditor beating the 125-member one), hence the count. + // Winners keep the place they would have taken in a single pass (where each was found), so do the losers' groups. + const resolved: { lower: string; winner: { entry: ApiEntry; seq: number }; lost: ApiEntry[]; clash: number }[] = []; + const byFamily: string[] = []; + for (const [lower, cands] of candidates) { + const group = cands.map(c => c.entry); + const contest = (rank: (e: ApiEntry) => number) => { + let incumbent = cands[0]; + const lost: ApiEntry[] = []; + for (const c of cands.slice(1)) { + const r = rank(c.entry), ri = rank(incumbent.entry); + if (r > ri || (r === ri && c.count > incumbent.count)) { lost.push(incumbent.entry); incumbent = c; } + else lost.push(c.entry); + } + return { incumbent, lost }; + }; + const { incumbent, lost } = contest(e => familyRank(e, group)); + if (cands.length > 1 && contest(() => 0).incumbent !== incumbent) byFamily.push(incumbent.entry.component); + resolved.push({ lower, winner: incumbent, lost, clash: cands[1]?.seq ?? Infinity }); + } + for (const { lower, winner } of [...resolved].sort((a, b) => a.winner.seq - b.winner.seq)) { + entries.set(winner.entry.component, winner.entry); + keyByLower.set(lower, winner.entry.component); + } + for (const { lower, lost } of resolved.filter(r => r.lost.length).sort((a, b) => a.clash - b.clash)) losers.set(lower, lost); + console.log(`Shared short names: ${losers.size}; ${byFamily.length} go to a type other than the most documented one, ` + + `of the family Infragistics recommends (retired XamGrid / xamInputs ranked last, newer copies of legacy ` + + `Infragistics.Windows types first): ${byFamily.slice(0, 8).join(', ')}…`); + console.log(`Left out ${skippedNonPublic} non-public (internal / private nested) type(s) documented in the XML, and ` + `${skippedNonPublicMembers} member(s) of public types that are internal / private or explicit interface implementations.`); + console.log(`Left out ${hiddenTypes.length} type(s) hidden from IntelliSense (kept as base types): ${hiddenTypes.slice(0, 5).join(', ')}…`); + console.log(`Members: ${skippedObsolete} [Obsolete] moved to "obsolete", ${skippedHidden} hidden from IntelliSense left out; ` + + `${methodsSigned}/${methodsKept} methods and constructors have a signature (${uncoveredTypes.length} type(s) the extractor couldn't read` + + `${uncoveredTypes.length ? `: ${uncoveredTypes.slice(0, 5).join(', ')}` : ''}).`); + console.log(`${unmatchedMethods.length} method(s) / constructor(s) of types the extractor read matched none of its doc IDs` + + `${unmatchedMethods.length ? ` (methods kept by name): ${unmatchedMethods.slice(0, 5).join(', ')}` : ''}.`); + // Every method of a type the extractor read has a signature or a non-public entry, and the rest count as unsigned, + // so a low share means the doc IDs it generated stopped matching the XML's: agents would be back to method names + // without parameters. + assertBuildStep(methodsKept > 0 && methodsSigned / methodsKept >= 0.95, + `Only ${methodsSigned} of ${methodsKept} methods and constructors got a signature from nuget/type-info.json "__members". ` + + `The extractor's doc IDs (MethodDocId / ParamId in scripts/type-extractor/Program.cs) no longer match the XML docs' ` + + ` IDs, or type-info.json is stale. Run: npm run generate:types` + ); const genericFallbacks = xamlFallbacks.filter(n => n.includes('`')).length; console.log(`XAML namespaces: ${xamlFallbacks.length} type(s) fell back to clr-namespace syntax ` + `(${genericFallbacks} generic, ${xamlFallbacks.length - genericFallbacks} other${xamlFallbacks.length > genericFallbacks ? `: ${xamlFallbacks.filter(n => !n.includes('`')).slice(0, 8).join(', ')}` : ''})`); - for (const [lower, alts] of alternates) { - const winner = entries.get(keyByLower.get(lower)!); - if (winner) winner.alternates = alts.sort((a, b) => b.memberCount - a.memberCount); - } - // Enrich in-memory, then write everything at once console.log(`Enriching inheritance...`); const enriched = enrichWithInheritance(entries, baseTypes, allTypes); - const escaped = [...entries.values()].filter(e => /\\\\u00[0-9A-Fa-f]{2}/.test(JSON.stringify(e))); + // A type that lost its short name gets a page of its own, built the same way (inherited members included), which + // get_wpf_api_reference opens by full name; each type of a shared name lists the others with that lookup. Except the + // same class listed twice (same assembly and full name, its DLL in two packages; none since discoverXmlFiles() skips + // XML docs without their DLL), which its page only notes. + const fullName = (e: ApiEntry) => `${e.dotnetNamespace}.${e.component}`; + const groups: { types: ApiEntry[]; twins: Map }[] = []; + const alternatePages = new Map(); // by "Assembly|Namespace.Type" + let sameTypeNotes = 0; + for (const [lower, lost] of losers) { + const types = [entries.get(keyByLower.get(lower)!)!]; // the winner first + const twins = new Map(); + for (const e of lost) { + const twin = types.find(t => t.assembly === e.assembly && fullName(t) === fullName(e)); + if (twin) { twins.set(twin, [...(twins.get(twin) ?? []), e]); sameTypeNotes++; } + else { types.push(e); alternatePages.set(`${e.assembly}|${fullName(e)}`, e); } + } + groups.push({ types, twins }); + } + // After the pages': a page's inherited members come from its bases' own (declaredOn-less) members either way. + const enrichedAlternates = enrichWithInheritance(alternatePages, baseTypes, allTypes); + + const memberCount = (e: ApiEntry) => e.properties.length + e.events.length + e.methods.length; + const firstSentence = (s: string) => { + const sentence = /^.*?[.!?](?=\s|$)/.exec(s)?.[0] ?? s; + return sentence.length > 200 ? `${sentence.slice(0, 199)}…` : sentence; + }; + for (const { types, twins } of groups) { + // The full name, plus the assembly when two types of the name share that too (Caret is in both the XamRichTextEditor + // and the XamSyntaxEditor assembly). A bare full name opens the first type that has it, the winner before the rest. + const lookups = new Map(types.map(t => [t, + types.filter(o => fullName(o).toLowerCase() === fullName(t).toLowerCase()).length > 1 ? `${fullName(t)}, ${t.assembly}` : fullName(t)])); + const describe = (t: ApiEntry): AlternateType => ({ + fullName: fullName(t), nugetPackage: t.nugetPackage, memberCount: memberCount(t), assembly: t.assembly, + lookup: lookups.get(t)!, ...(t === types[0] ? { isDefault: true } : {}), + xamlNamespace: t.xamlNamespace, defaultPrefix: t.defaultPrefix, + ...(t.summary ? { summary: firstSentence(t.summary) } : {}), + ...(t.values ? { valueCount: t.values.length, valueNames: t.values.slice(0, 8).map(v => v.name) } : {}), + }); + for (const t of types) { + // The default type first (on the others' pages), then by member count, as before. + const others = types.filter(o => o !== t).map(describe) + .sort((a, b) => Number(!!b.isDefault) - Number(!!a.isDefault) || b.memberCount - a.memberCount); + const sameType = (twins.get(t) ?? []).map(d => ({ + fullName: fullName(d), nugetPackage: d.nugetPackage, memberCount: memberCount(d), assembly: d.assembly, sameType: true, + })); + if (others.length + sameType.length) t.alternates = [...others, ...sameType]; + if (t !== types[0]) t.lookup = lookups.get(t); + } + } + const altLookups = [...alternatePages.values()].map(e => e.lookup!); + assertBuildStep(new Set(altLookups.map(l => l.toLowerCase())).size === altLookups.length, + `Two alternate pages have lookups that differ only in letter case, so their api/alternates/ files would collide on a ` + + `case-insensitive filesystem — the lookups in generate() must stay unique case-insensitively.` + ); + const tooLong = altLookups.filter(l => l.length > 128); + assertBuildStep(tooLong.length === 0, + `${tooLong.length} alternate lookup(s) are longer than the 128 characters get_wpf_api_reference's "component" takes ` + + `(e.g. ${tooLong.slice(0, 2).join('; ')}): raise that limit in server/Tools/ComponentTools.cs.` + ); + console.log(`Alternates: ${alternatePages.size} type(s) that lost their short name get a page in api/alternates/ ` + + `(${enrichedAlternates} with inherited members); ${sameTypeNotes} listed twice (same assembly and full name) are noted instead.`); + + const enumPages = [...entries.values()].filter(e => e.values); + console.log(`Enums: ${enumPages.length} pages (${enumPages.filter(e => e.isFlags).length} [Flags]) list ` + + `${enumPages.reduce((n, e) => n + e.values!.length, 0)} values (${hiddenValues} hidden from IntelliSense left out); ` + + `${[...entries.values()].reduce((n, e) => n + [...e.properties, ...e.events].filter(m => m.isProtected).length, 0)} ` + + `protected properties / events across the pages, inherited copies included.`); + assertBuildStep(enumPages.length > 0, + `No API page has enum values, though nuget/type-info.json has ${Object.keys(enums).length} in "__enums" — its keys no ` + + `longer match the XML docs' type names. Every enum page would say it has no members.` + ); + + const escaped = [...entries.values(), ...alternatePages.values()].filter(e => /\\\\u00[0-9A-Fa-f]{2}/.test(JSON.stringify(e))); assertBuildStep(escaped.length === 0, `${escaped.length} API entr${escaped.length === 1 ? 'y' : 'ies'} (e.g. ${escaped.slice(0, 3).map(e => e.component).join(', ')}) contain ` + `JSON escapes such as \\u003C as literal text — a type-info.json string was used without decode(), so agents would ` + `read "Action\\u003CToolCommand\\u003E" instead of "Action".` ); - console.log(`Writing ${entries.size} API files...`); - await Promise.all( - [...entries.entries()].map(([typeName, entry]) => + console.log(`Writing ${entries.size} API files and ${alternatePages.size} alternate pages...`); + mkdirSync(ALT_OUT_DIR, { recursive: true }); + await Promise.all([ + ...[...entries.entries()].map(([typeName, entry]) => fsPromises.writeFile(join(API_OUT_DIR, `${typeName}.json`), JSON.stringify(entry), 'utf-8') - ) - ); + ), + ...[...alternatePages.values()].map(entry => + fsPromises.writeFile(join(ALT_OUT_DIR, `${entry.lookup}.json`), JSON.stringify(entry), 'utf-8') + ), + ]); // Built from the resolved winners, not during discovery, so a type that loses a - // short-name collision can't leave a stale registry entry behind. + // short-name collision can't leave a stale registry entry behind. It names the + // others, though (the legacy Infragistics.Windows.Editors.XamComboEditor). const registry: RegistryEntry[] = [...entries.values()] .filter(e => e.component.startsWith('Xam')) .map(e => ({ @@ -547,6 +901,7 @@ async function generate() { nugetPackage: e.nugetPackage, assembly: e.assembly, description: e.summary, + ...(e.alternates?.some(a => a.lookup) ? { alternates: e.alternates.filter(a => a.lookup) } : {}), })); assertBuildStep( @@ -565,17 +920,23 @@ async function generate() { registry.sort((a, b) => a.component.localeCompare(b.component)); writeFileSync(NS_OUT_FILE, JSON.stringify(registry, null, 2), 'utf-8'); - // Search index — lightweight flat array, loaded once at MCP startup - const searchIndex = [...entries.values()].map(e => ({ + // Search index — lightweight flat array, loaded once at MCP startup. The types that lost their short name come last, + // so they don't reorder the others' ties, with "f": what opens their page; the type that kept the name has its full + // name there too. Only the short name is matched: namespace words ("Editors") would rank every shared name up. + const searchIndex = [...entries.values(), ...alternatePages.values()].map(e => ({ n: e.component, s: e.summary, a: e.assembly, p: e.nugetPackage, - m: [ + // Overloads share a name, listed once. Obsolete names stay searchable: old code and docs still use them, and the + // type's page answers a lookup with the replacement. + m: [...new Set([ ...e.properties.map(x => x.name), ...e.events.map(x => x.name), ...e.methods.map(x => x.name), - ], + ...(e.obsolete ?? []).filter(o => o.kind !== 'constructor').map(o => o.name), + ])], + ...(e.lookup ? { f: e.lookup } : e.alternates?.some(a => a.lookup) ? { f: fullName(e) } : {}), })); writeFileSync( join(ROOT, 'src', 'data', 'search-index.json'), @@ -584,7 +945,7 @@ async function generate() { ); console.log(`Done.`); - console.log(` API files: ${entries.size} → src/data/api/`); + console.log(` API files: ${entries.size} → src/data/api/, ${alternatePages.size} → src/data/api/alternates/`); console.log(` Enriched: ${enriched} types with inherited Infragistics properties`); console.log(` Registry: ${registry.length} Xam* controls → src/data/namespaces.json`); console.log(` Search index: ${searchIndex.length} types → src/data/search-index.json`); diff --git a/scripts/build-docs.ts b/scripts/build-docs.ts index c0915bb..246a78a 100644 --- a/scripts/build-docs.ts +++ b/scripts/build-docs.ts @@ -14,7 +14,7 @@ */ import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, rmSync, statSync } from 'fs'; -import { join, dirname, basename } from 'path'; +import { join, dirname, basename, resolve, relative } from 'path'; import { fileURLToPath } from 'url'; import { ensureSubmodules } from './ensure-submodules.js'; import { assertBuildStep } from './build-guard.js'; @@ -26,16 +26,39 @@ const ROOT = join(__dirname, '..'); const DOCS_WPF_DIR = join(ROOT, 'docs', 'docs-wpf', 'topics', 'en'); const DOCS_COMMON_DIR = join(ROOT, 'docs', 'docs-common', 'topics', 'en'); const DOCS_CONFIG_FILE = join(ROOT, 'docs', 'docs-common', 'DocsConfig.xml'); +const DOCS_DIR = join(ROOT, 'docs'); const OUT_DIR = join(ROOT, 'src', 'data', 'docs'); const INDEX_FILE = join(ROOT, 'src', 'data', 'docs-index.json'); -// WPF-relevant subfolders from docs-common. -// Excluded: data-grid (web / Blazor grid — WPF's XamDataGrid is in docs-wpf) -const COMMON_INCLUDE = new Set([ - 'barcodes', 'bullet-graph', 'category-chart', 'data-chart', 'data-pie-chart', - 'document-engine', 'donut-chart', 'excel-engine', 'financial-chart', 'funnel-chart', - 'geographic-map', 'linear-gauge', 'pie-chart', 'productivity-tools', 'radial-gauge', - 'scheduler', 'shape-chart', 'sparkline', 'spreadsheet', 'surface-chart', 'win-web-chart', +// WPF-relevant subfolders from docs-common, each with the DocsConfig.xml variable naming the control it covers. +// Excluded: data-grid (web / Blazor grid — WPF's XamDataGrid is in docs-wpf), scheduler (the Xamarin.Forms +// XamScheduler, which the WPF packages don't have — WPF's XamSchedule family is in docs-wpf; the WPF table of contents, +// toc/master-wpf.toc, never links these topics, and as XamScheduler topics they matched "xamSchedule" searches by +// substring) and win-web-chart (the Windows Forms / +// ASP.NET UltraChart: DocsConfig.xml defines its {WawChartName} for those two products only, and its 359 topics +// never mention a WPF control). +// A topic whose metadata names no control gets its folder's (none of the 18 shape-chart topics names one, nor 19 of +// the 32 financial-chart ones), so search_wpf_docs' control filter ranks it with its siblings rather than below the +// release notes that only mention the control; a folder alias it names ("financial-chart") becomes that control too. excel-engine's {ExcelEngineName} is the "Infragistics Excel Engine" +// its other topics name. document-engine has no variable, and every one of its topics names its engine already; +// productivity-tools covers three tools (Control Configurator, App Map, Toolbox), so no one name fits. +const COMMON_FOLDERS = new Map([ + ['barcodes', 'BarcodesName'], ['bullet-graph', 'BulletGraphName'], ['category-chart', 'CategoryChartName'], + ['data-chart', 'DataChartName'], ['data-pie-chart', 'DataPieChartName'], ['document-engine', null], + ['donut-chart', 'DoughnutChartName'], ['excel-engine', 'ExcelEngineName'], ['financial-chart', 'FinancialChartName'], + ['funnel-chart', 'FunnelChartName'], ['geographic-map', 'GeoMapName'], ['linear-gauge', 'LinearGaugeName'], + ['pie-chart', 'PieChartName'], ['productivity-tools', null], ['radial-gauge', 'RadialGaugeName'], + ['shape-chart', 'ShapeChartName'], ['sparkline', 'SparklineName'], ['spreadsheet', 'SpreadsheetName'], + ['surface-chart', 'SurfaceChartName'], +]); + +// docs-common topics of a WPF folder that are not WPF's, though no buildFlags say so: productivity-tools.adoc and +// getting-started-with-control-configurators.adoc link them only inside ifdef::xamarin[] blocks, toc/master-wpf.toc +// doesn't list them, and their text is about Xamarin.Forms (App Map lays out a Xamarin.Forms app, the Toolbox drags +// Xamarin.Forms controls onto a page; WPF's data grid configurator is using-control-configurator-with-data-grid). +const COMMON_NOT_WPF = new Set([ + 'productivity-tools/getting-started-with-app-map.adoc', 'productivity-tools/getting-started-with-toolbox.adoc', + 'productivity-tools/using-control-configurator-with-xamarin-data-grid.adoc', ]); // ── DocsConfig variable substitution (WPF product) ──────────────────────────── @@ -104,6 +127,54 @@ function loadWpfVariables(): Map { const WPF_VARS = loadWpfVariables(); +const missingFolderVars = [...COMMON_FOLDERS.values()].filter(v => v && !WPF_VARS.has(v)); +assertBuildStep(!existsSync(DOCS_CONFIG_FILE) || missingFolderVars.length === 0, + `DocsConfig.xml has no wpf value for ${missingFolderVars.join(', ')} (COMMON_FOLDERS), so that folder's topics ` + + `that name no control would get none, and search_wpf_docs' control filter would rank them below passing mentions.` +); + +// The attributes a WPF docs build sets: DocsConfig.xml's . +function loadWpfBuildFlags(): Set { + if (!existsSync(DOCS_CONFIG_FILE)) return new Set(['xaml', 'wpf']); // no docs-common (see loadWpfVariables): its value + const flags = /]*?\sBuildFlags="([^"]*)"/.exec(readFileSync(DOCS_CONFIG_FILE, 'utf-8'))?.[1]; + assertBuildStep(!!flags, + `DocsConfig.xml has no , so there is no way to tell which ifdef:: blocks ` + + `belong to WPF: every topic would keep the Windows Forms, Xamarin and Android variants too.` + ); + return new Set(flags!.split(',').map(f => f.trim()).filter(Boolean)); +} + +const WPF_FLAGS = loadWpfBuildFlags(); + +/** + * Keep only what a WPF build of the docs shows. docs-common writes each topic once for every platform and wraps the + * platform-specific parts in `ifdef::wpf,win-forms[]` … `endif::…[]` (a comma means any of them); docs-wpf has a few + * for Silverlight. That is the only conditional form the sources use. Blocks nest. + */ +/** + * Inline wording per platform: `pick:[wpf,win-forms="text"]` (12,800 uses, one platform list each). The WPF one stays + * as plain text; the rest ("In Java:", Xamarin wording) is dropped, as a WPF build does. The text runs to the closing + * `"]` on the same line: it may contain `]` (links) and, in code samples, doubled quotes (`wpf=""C:\Book1.xls""`); + * a few sources leave out the `=`. + */ +function resolvePicks(raw: string): string { + return raw.replace(/pick:\[([\w,-]+)=?"([^\n]*?)"\]/g, (_, platforms: string, text: string) => + platforms.split(',').some(p => WPF_FLAGS.has(p.trim().toLowerCase())) ? text : ''); +} + +function applyConditionals(raw: string): { text: string; dropped: number } { + const keep: boolean[] = []; + const out: string[] = []; + let dropped = 0; + for (const line of raw.split('\n')) { + const open = /^ifdef::([^\[]*)\[\]\s*$/.exec(line); + if (open) { keep.push(open[1].split(',').some(a => WPF_FLAGS.has(a.trim().toLowerCase()))); continue; } // one source writes ifdef::XAML[] + if (/^endif::[^\[]*\[\]\s*$/.test(line)) { keep.pop(); continue; } // a stray endif (3 in the sources) is a no-op + if (keep.every(Boolean)) out.push(line); else dropped += line.length + 1; + } + return { text: out.join('\n'), dropped }; +} + /** * Replace {VarName} placeholders using the resolved WPF variable table. * @@ -122,6 +193,15 @@ interface AdocMetadata { name: string; controlName?: string[]; tags?: string[]; + /** Platforms the whole topic is built for ("WPF", "sl", "WINFORMS", "ANDROID") and output formats ("docx-online"). */ + buildFlags?: string[]; +} + +// A topic built only for other platforms (WinForms, Android, Silverlight, Windows Phone): its buildFlags name platforms +// but none of WPF's. "docx-…" flags are output formats, not platforms, so a topic with only those stays. +function isForOtherPlatforms(meta: AdocMetadata | null): boolean { + const platforms = (meta?.buildFlags ?? []).map(f => f.trim().toLowerCase()).filter(f => f && !f.startsWith('docx-')); + return platforms.length > 0 && !platforms.some(f => WPF_FLAGS.has(f)); } export interface DocIndexEntry { @@ -131,10 +211,12 @@ export interface DocIndexEntry { tags: string[]; // e.g. ["How Do I", "Getting Started"] source: 'wpf' | 'common'; summary: string; // first ~220 chars of plain body text + /** Xam* names the topic mentions, lowercased → count, e.g. { "xammaskedinput": 4 }. Only when there are any. */ + mentions?: Record; } export interface DocEntry extends DocIndexEntry { - body: string; // full stripped body (code blocks as plain text) + body: string; // the topic as Markdown: # headings, code in ``` fences xamlSnippets: string[]; // extracted XAML-only code blocks } @@ -143,6 +225,18 @@ export interface DocEntry extends DocIndexEntry { // Matches a localized sibling such as "datagrid-column-pinning.ja-JP". const LOCALIZED_FILE = /\.[a-z]{2}-[A-Z]{2}$/; +/** The control a docs-common folder covers: the wpf value of its COMMON_FOLDERS variable, if it has one. */ +function folderControlOf(folder: string | undefined): string | null { + const variable = folder ? COMMON_FOLDERS.get(folder) : null; + return variable ? WPF_VARS.get(variable)! : null; +} + +/** A metadata control name that is only the docs-common folder's name for its control: "financial-chart" or "FinancialChart" for XamFinancialChart. */ +function isFolderAlias(name: string, folder: string, control: string): boolean { + const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, ''); + return norm(name) === norm(folder) || `xam${norm(name)}` === norm(control); +} + /** Extract the JSON metadata block: |metadata| { ... } |metadata| */ function parseMetadata(raw: string): AdocMetadata | null { const m = raw.match(/\|metadata\|([\s\S]*?)\|metadata\|/); @@ -157,15 +251,21 @@ function parseMetadata(raw: string): AdocMetadata | null { /** * Extract the document title (first `= Heading` line outside the metadata comment block). * Strips the //// comment wrapper first so = chars inside JSON are ignored. + * Without one, the first heading is the title when every other heading is below it: nuget-packages and + * wpf-about-trial-assemblies open with `== Title` over `===` sections. A `== Topic Overview` or `== In This Topic` + * followed by other `==` sections is a section, not a title. */ function extractTitle(raw: string): string { // Remove //// comment blocks so we don't match = inside JSON const withoutComments = raw.replace(/\/\/\/\/[\s\S]*?\/\/\/\//g, ''); + const headings: { level: number; text: string }[] = []; for (const line of withoutComments.split('\n')) { - const m = line.match(/^= (.+)/); - if (m) return m[1].trim(); + const m = line.match(/^(={1,6}) (.+)/); + if (m?.[1] === '=') return m[2].trim(); + if (m) headings.push({ level: m[1].length, text: m[2].trim() }); } - return ''; + const [first, ...rest] = headings; + return first && rest.every(h => h.level > first.level) ? first.text : ''; } /** @@ -198,11 +298,28 @@ function extractXamlSnippets(raw: string): string[] { return snippets; } +// AsciiDoc source languages → Markdown fence languages. +const FENCE_LANG: Record = { csharp: 'csharp', cs: 'csharp', 'c#': 'csharp', vb: 'vb', xaml: 'xml', xml: 'xml', js: 'js', javascript: 'js' }; + /** - * Strip AsciiDoc markup and return plain searchable text. - * Code block content is kept as plain text (no fences). + * AsciiDoc → Markdown: headings as #, code blocks in ``` fences, other markup stripped to its text. + * Code blocks (an optional [source,lang] line, then ---- … ---- or a .... literal block) are set aside first, so the + * prose clean-up can't touch them: it deletes {Name} tokens and _underscore_ pairs, which turned + * XAxisLabel="{}{Name}" into XAxisLabel="{}" and mangled C# fields. */ function stripAdoc(raw: string): string { + const code: string[] = []; + const prose = raw.replace(/\r\n?/g, '\n').replace( + /^(?:\[source(?:\s*,\s*([^\],]*))?[^\]\n]*\][ \t]*\n)?(-{4,}|\.{4,})[ \t]*\n([\s\S]*?)\n\2[ \t]*$/gm, + (_, lang: string | undefined, _fence, body: string) => { + const key = (lang ?? '').trim().toLowerCase(); + code.push('```' + (FENCE_LANG[key] ?? key) + '\n' + body + '\n```'); + return `\u0000${code.length - 1}\u0000`; + }); + return stripProse(prose).replace(/\u0000(\d+)\u0000/g, (_, i) => code[Number(i)]); +} + +function stripProse(raw: string): string { return raw // ── Remove structural blocks we don't want ─────────────────────────────── // //// comment blocks (metadata lives here) @@ -214,8 +331,6 @@ function stripAdoc(raw: string): string { .replace(/^\/\/ .+$/gm, '') // image:: macros .replace(/image::[^\[]*\[[^\]]*\]/g, '') - // include:: directives - .replace(/^include::.+$/gm, '') // ── Strip inline markup, keep visible text ─────────────────────────────── // link:url[text] / xref:url[text] / link:{var}url[text] → text @@ -231,8 +346,11 @@ function stripAdoc(raw: string): string { // ── Remove formatting-only markers ─────────────────────────────────────── // Block type annotations: [source, xaml], [NOTE], [TIP], etc. .replace(/^\[[^\]]+\]\s*$/gm, '') - // AsciiDoc heading markers (= / == / === …) — keep heading text - .replace(/^={1,6} /gm, '') + // Block delimiters (==== example blocks, ===== …) are not text: left in, "==== " became an empty "####" heading below, + // and a bare "====" under a line makes it a Markdown heading + .replace(/^={4,}[ \t]*$/gm, '') + // AsciiDoc heading markers (= / == / === …) → Markdown (# / ## / ### …): get_wpf_doc pages at them + .replace(/^(={1,6}) /gm, (_, level: string) => '#'.repeat(level.length) + ' ') // Table delimiters .replace(/^\|====.*$/gm, '') .replace(/^\|[-]+.*$/gm, '') @@ -249,43 +367,118 @@ function stripAdoc(raw: string): string { .trim(); } +// ── include:: directives ────────────────────────────────────────────────────── + +// All but a handful of the sources start with a UTF-8 byte-order mark, which readFileSync keeps: it hid the `= Title` +// on line 1, and with it 86 topics that have no metadata name (all 18 shape-chart ones, for instance). +function readSource(filePath: string): string { + return readFileSync(filePath, 'utf-8').replace(/^\uFEFF/, ''); +} + +const INCLUDE_LINE = /^include::([^\[\n]+)\[([^\]\n]*)\][ \t]*\r?$/; +// The files include:: lines pasted, and the include:: lines that pasted nothing (file:line → why), checked at the end. +const includedFiles = new Set(); +const brokenIncludes = new Map(); +const unsupportedIncludes = new Set(); + +/** + * Paste each `include::file.adoc[]` line's file in its place, as Asciidoctor does when the docs site is built: the path + * is relative to the file the line is in, and the pasted file's own include:: lines are resolved in turn. 67 docs-wpf + * topics are written this way, and the line used to be deleted: xamdatagrid-crossfieldrecordfiltering is a title, + * Purpose and Required Background, then include::shared-crossfieldrecordfiltering.adoc[] with the whole explanation + * and its samples. Every include in the sources has empty brackets; an attribute (leveloffset, tag, lines…) would + * change what is pasted, so one is refused at the end rather than ignored. A missing file or a cycle pastes nothing. + * A file with a title of its own that includes a whole topic (xamdatapresenter-field-grouping: its title, then + * include::xamdatagrid-field-grouping.adoc[]) is that topic under its own title: the pasted title would be a second H1. + */ +function expandIncludes(raw: string, filePath: string, stack: string[] = [filePath], titled = /^= \S/m.test(raw)): string { + return raw.split('\n').map((line, i) => { + const m = INCLUDE_LINE.exec(line); + if (!m) return line; + const where = `${relative(DOCS_DIR, filePath).replace(/\\/g, '/')}:${i + 1}`; + if (m[2].trim()) { unsupportedIncludes.add(`${where} ${line.trim()}`); return ''; } + const target = resolve(dirname(filePath), m[1].trim()); + if (stack.includes(target)) { brokenIncludes.set(where, `include::${m[1]}[] is a cycle: that file is being pasted already`); return ''; } + if (!existsSync(target)) { brokenIncludes.set(where, `include::${m[1]}[]: no such file`); return ''; } + includedFiles.add(target); + const pasted = readSource(target); + return expandIncludes(titled ? pasted.replace(/^= \S.*$/m, '') : pasted, target, [...stack, target], titled || /^= \S/m.test(pasted)); + }).join('\n'); +} + // ── File processor ──────────────────────────────────────────────────────────── -function processFile(filePath: string, source: 'wpf' | 'common'): DocEntry | null { - let raw: string; +/** `aliasOf`: the one file this file includes, when it has no text of its own (see the alias check in Main). */ +interface ProcessedTopic { entry: DocEntry; aliasOf?: string } + +function processFile({ path: filePath, source, folder }: FileSource): ProcessedTopic | null { + let own: string; try { - raw = readFileSync(filePath, 'utf-8'); + own = readSource(filePath); } catch { return null; } // Resolve {PlaceholderName} tokens (shared docs-common topics) into real WPF // control names before any parsing, so metadata/title/body all see the - // resolved text (e.g. "XamCategoryChart" instead of "{CategoryChartName}"). - raw = resolveVars(raw); - - const meta = parseMetadata(raw); + // resolved text (e.g. "XamCategoryChart" instead of "{CategoryChartName}"). The metadata is the file's own, read + // before include:: pastes other files in: most of those have a metadata block too, only a comment where it's pasted. + const meta = parseMetadata(resolveVars(own)); + let raw = resolveVars(expandIncludes(own, filePath)); + const conditional = applyConditionals(raw); + raw = resolvePicks(conditional.text); + droppedChars += conditional.dropped; + if (conditional.dropped > 0) topicsTrimmed++; + + if (isForOtherPlatforms(meta)) { otherPlatformTopics++; return null; } const title = extractTitle(raw); - // Some real topics (the whole financial-chart family, several data-chart ones) carry no - // `name` in their metadata block. Fall back to the filename rather than dropping them, + // Some real topics (19 of the 32 financial-chart ones, all of shape-chart, the changelog and release notes) carry no + // `name` in their metadata block, or no metadata at all. Fall back to the filename rather than dropping them, // but only when the file has a real heading — untitled files are fragments and data // dumps — and never for a localized copy, whose slug would duplicate the English topic. const fileSlug = basename(filePath, '.adoc'); - if (!meta?.name && (!title || LOCALIZED_FILE.test(fileSlug))) return null; + if (!meta?.name && (!title || LOCALIZED_FILE.test(fileSlug))) { if (!title) untitled.push(filePath); return null; } const slug = meta?.name?.trim() || fileSlug; - const controlNames = (meta?.controlName ?? []).map(s => s.trim()).filter(Boolean); + // A docs-common topic that names no control gets its folder's. An unresolved {Placeholder} names none + // (data-pie-chart-data-binding's {DataDataPieChartName}, a typo), and the folder's own alias ("financial-chart", + // "FinancialChart" in 13 financial-chart topics) stands for its control: an alias only half-matches the control + // filter, so those topics ranked below the ones that name no control. + const folderControl = folderControlOf(folder); + const named = (meta?.controlName ?? []).map(s => s.trim()).filter(s => s && !/\{[A-Za-z0-9_.-]+\}/.test(s)) + .map(s => folderControl && isFolderAlias(s, folder!, folderControl) ? folderControl : s); + const controlNames = named.length || !folderControl ? [...new Set(named)] : [folderControl]; const tags = (meta?.tags ?? []).map(s => s.trim()).filter(Boolean); const xamlSnippets = extractXamlSnippets(raw); const body = stripAdoc(raw); - // Summary: first ~220 printable chars of body - const summary = body.replace(/\s+/g, ' ').slice(0, 220).trim(); + // Summary: first ~220 printable chars of the body, without the Markdown heading and fence markers + const summary = body.replace(/^```.*$/gm, '').replace(/^#{1,6} /gm, '').replace(/\s+/g, ' ').slice(0, 220).trim(); if (!title && !summary) return null; // skip empty/broken files - return { slug, title, controlNames, tags, source, summary, body, xamlSnippets }; + // The Xam* names the topic mentions (xamDataGrid, XamMaskedInput; not "xaml" or "Xamarin"): search_wpf_docs' + // control filter also matches them, since many topics are tagged with a family (xamInputs, xamDataPresenter) + // rather than with each control they cover. + const mentions: Record = {}; + for (const m of body.matchAll(/\b[Xx]am([A-Z][A-Za-z0-9]*)\b/g)) { + const name = `xam${m[1]}`.toLowerCase(); + mentions[name] = (mentions[name] ?? 0) + 1; + } + + // A file whose only line of its own is one include:: (xamdatagrid-external-filtering-copy) is the included file again. + const [onlyLine, ...moreLines] = own.replace(/\/\/\/\/[\s\S]*?\/\/\/\//g, '').split('\n').filter(l => l.trim()); + const included = onlyLine && !moreLines.length ? INCLUDE_LINE.exec(onlyLine)?.[1] : undefined; + + return { + entry: { + slug, title, controlNames, tags, source, summary, + ...(Object.keys(mentions).length ? { mentions } : {}), + body, xamlSnippets, + }, + ...(included ? { aliasOf: resolve(dirname(filePath), included.trim()) } : {}), + }; } // ── File discovery ──────────────────────────────────────────────────────────── @@ -304,7 +497,11 @@ function collectAdoc(dir: string): string[] { return results; } -interface FileSource { path: string; source: 'wpf' | 'common' } +/** `folder`: the docs-common folder (COMMON_FOLDERS key) the file is in. */ +interface FileSource { path: string; source: 'wpf' | 'common'; folder?: string } + +/** The COMMON_NOT_WPF files found, left out. */ +const notWpf: string[] = []; function discoverFiles(): FileSource[] { const files: FileSource[] = []; @@ -323,11 +520,12 @@ function discoverFiles(): FileSource[] { if (existsSync(DOCS_COMMON_DIR)) { for (const folder of readdirSync(DOCS_COMMON_DIR)) { - if (!COMMON_INCLUDE.has(folder)) continue; + if (!COMMON_FOLDERS.has(folder)) continue; const folderPath = join(DOCS_COMMON_DIR, folder); if (!statSync(folderPath).isDirectory()) continue; for (const f of collectAdoc(folderPath)) { - files.push({ path: f, source: 'common' }); + const rel = relative(DOCS_COMMON_DIR, f).replace(/\\/g, '/'); + if (COMMON_NOT_WPF.has(rel)) notWpf.push(rel); else files.push({ path: f, source: 'common', folder }); } } } else { @@ -348,22 +546,83 @@ console.log(`Discovered ${files.length} .adoc files (wpf + common)`); const index: DocIndexEntry[] = []; let skipped = 0; -const slugSeen = new Set(); - -for (const { path, source } of files) { - const entry = processFile(path, source); +let droppedChars = 0, topicsTrimmed = 0, otherPlatformTopics = 0; +const untitled: string[] = []; +// docs-common folder → its .adoc files and the topics written from them +const folderCounts = new Map(); +for (const { folder } of files) { + if (folder) folderCounts.set(folder, { files: (folderCounts.get(folder)?.files ?? 0) + 1, topics: 0 }); +} - if (!entry) { skipped++; continue; } +let topics: (ProcessedTopic & { file: FileSource })[] = []; +for (const file of files) { + const topic = processFile(file); + if (topic) topics.push({ ...topic, file }); else skipped++; +} - // Deduplicate: docs-wpf takes precedence over docs-common for the same slug - if (slugSeen.has(entry.slug)) { - if (source === 'common') { skipped++; continue; } - // Replace existing common entry with wpf entry (update index + overwrite file) - const idx = index.findIndex(e => e.slug === entry.slug); - if (idx !== -1) index.splice(idx, 1); +// A file that is only an include:: of another topic is that topic's second place in the table of contents (the 4 +// xamdatagrid-external-*-copy files, xamdatapresenter-conftreeview-expind for xamtreegrid-conf-expansion-indicators): +// written too, it would be the same text under a second slug, listed twice in every search that finds it. +const topicFiles = new Set(topics.map(t => t.file.path)); +const aliases = topics.filter(t => t.aliasOf && topicFiles.has(t.aliasOf)); +topics = topics.filter(t => !aliases.includes(t)); +skipped += aliases.length; + +const fileName = (t: { file: FileSource }) => basename(t.file.path, '.adoc'); +const where = (t: { file: FileSource }) => relative(DOCS_DIR, t.file.path).replace(/\\/g, '/'); + +// The docs site's address for a page is its file name, and where docs-wpf and docs-common both have a file, WPF's +// docs site serves the docs-common one: bulletgraph.html is the shared xamBulletGraph overview, not docs-wpf's +// bulletgraph.adoc (only its title, as are 8 of these 10), and resources-sample-bubble-data.html the shared +// BubbleDataSource the chart topics bind to, not docs-wpf's XML. docs-wpf's used to win. +const sharedFiles = new Set(topics.filter(t => t.file.source === 'common').map(t => fileName(t).toLowerCase())); +const shadowed = topics.filter(t => t.file.source === 'wpf' && sharedFiles.has(fileName(t).toLowerCase())); +topics = topics.filter(t => !shadowed.includes(t)); +skipped += shadowed.length; + +// A topic's slug is its metadata name, and a few files claim another's: six data-pie-chart files, its overview among +// them, are named "piechart-selection" (the pie chart's selection topic), whats-new-in-2017-volume-2 +// "whats-new-in-2017-volume-1". The file named after it keeps the name and the others take their own file names. The +// first file read used to keep it (in docs-wpf, the last) and the rest were dropped unseen: piechart-selection was the +// data pie chart's Animation. +const claims = new Map(); +for (const t of topics) claims.set(t.entry.slug.toLowerCase(), [...(claims.get(t.entry.slug.toLowerCase()) ?? []), t]); +const renamed: string[] = []; +const sameAsNamed: typeof topics = []; +for (const [name, group] of claims) { + if (group.length < 2) continue; + const named = group.find(t => fileName(t).toLowerCase() === name); + for (const t of group.filter(t => fileName(t).toLowerCase() !== name)) { + // home-page.adoc is wpf-netadvantage-win-client-wpf.adoc byte for byte: the same topic again, not another one. + if (named && t.entry.title === named.entry.title && t.entry.body === named.entry.body) { sameAsNamed.push(t); continue; } + renamed.push(`${t.entry.slug} → ${fileName(t)} (${where(t)})`); + t.entry.slug = fileName(t); + // Its metadata is a copy of the other file's, controlName included: the seven data-pie-chart files that claim + // piechart-selection and piechart-others-category name {PieChartName}, but are about XamDataPieChart, their folder's. + const control = folderControlOf(t.file.folder); + if (control) t.entry.controlNames = [control]; } - slugSeen.add(entry.slug); +} +topics = topics.filter(t => !sameAsNamed.includes(t)); +skipped += sameAsNamed.length; + +// Two topics left with one slug (two docs-common folders with a file of that name, or a file name that is another +// topic's metadata name) would overwrite each other's .json file; slugs differing only in letter case collide on a +// case-insensitive filesystem the same way. +const bySlug = new Map(); +for (const t of topics) bySlug.set(t.entry.slug.toLowerCase(), [...(bySlug.get(t.entry.slug.toLowerCase()) ?? []), where(t)]); +const collisions = [...bySlug].filter(([, paths]) => paths.length > 1); +assertBuildStep(collisions.length === 0, + `Topics that would share a slug: ${collisions.map(([slug, paths]) => `${slug} (${paths.join(', ')})`).join('; ')}. ` + + `A file named after a metadata name keeps it and the others take their own file names, so these need a new rule.` +); +// A file that other topics include and that has a metadata name but no title (configuring-template-field, +// shared-asynchronouspagingdatasource, the "xamdiagram … summary chart" tables) is not a page of its own: its text is +// in the topics that include it, and it was a third copy of xamdatagrid-X and xamdatapresenter-X in searches. It is +// still written, so get_wpf_doc opens its slug as before, but its index entry has nothing for search_wpf_docs to match. +let unsearchable = 0; +for (const { entry, file } of topics) { const { body, xamlSnippets, ...indexEntry } = entry; // Write full content file @@ -372,7 +631,10 @@ for (const { path, source } of files) { JSON.stringify(entry, null, 2), ); - index.push(indexEntry); + const fragment = !entry.title && includedFiles.has(file.path); + if (fragment) unsearchable++; + index.push(fragment ? { slug: entry.slug, title: '', controlNames: [], tags: [], source: entry.source, summary: '' } : indexEntry); + if (file.folder) folderCounts.get(file.folder)!.topics++; } // Sort index alphabetically by slug for deterministic output @@ -387,14 +649,81 @@ assertBuildStep(index.length > 0, `Needs a code fix in build-docs.ts, not a submodule re-init.` ); -assertBuildStep( - new Set(index.map(e => e.slug.toLowerCase())).size === index.length, - `Two topics have slugs differing only in letter case. Their .json files collide on a ` + - `case-insensitive filesystem, so one would silently overwrite the other.` +// An include:: that pasted nothing: a missing file is a broken link in the sources (logged for the docs team), but more +// than a few means the paths no longer resolve and topics like xamdatagrid-crossfieldrecordfiltering are stubs again. +for (const [line, why] of brokenIncludes) console.warn(`⚠ ${line}: ${why}`); +assertBuildStep(brokenIncludes.size <= 3, + `${brokenIncludes.size} include:: lines pasted nothing (${[...brokenIncludes.keys()].slice(0, 3).join(', ')}): ` + + `expandIncludes no longer finds the files they name, so the topics written with them lose that text.` +); +assertBuildStep(unsupportedIncludes.size === 0, + `include:: lines with attributes, which expandIncludes doesn't apply: ${[...unsupportedIncludes].join('; ')}. ` + + `Each changes what is pasted (leveloffset the heading levels, tag/tags/lines a part of the file): implement it.` +); +// An include:: line left in a written topic means INCLUDE_LINE no longer matches the sources' form. +const unexpanded = topics.filter(t => /^include::/m.test(t.entry.body)).map(t => t.entry.slug); +assertBuildStep(unexpanded.length === 0, + `${unexpanded.length} topics still contain include:: lines (e.g. ${unexpanded.slice(0, 3).join(', ')}): ` + + `INCLUDE_LINE no longer matches their form, so the text they include is missing.` +); + +// A topic without a metadata name is kept only with a title. The byte-order mark nearly every source starts with once +// hid the title of 86 of them, and they were left out unseen: all of shape-chart, 19 of 32 financial-chart topics, the +// changelog. include:: fragments (shared-crossfieldrecordfiltering opens with a section, not a title) are left out +// too: their text is in the topics that include them. +const fragments = untitled.filter(f => includedFiles.has(f)); +const untitledTopics = untitled.filter(f => !includedFiles.has(f)); +assertBuildStep(untitledTopics.length <= 5, + `${untitledTopics.length} topics that have no metadata name were left out because extractTitle found no title in them — the ` + + `title detection no longer matches the sources (a byte-order mark or a new heading form), so whole families of ` + + `topics (shape-chart, financial-chart, the release notes) would be missing from search_wpf_docs.` +); + +// Every docs-common folder keeps a fair share of its topics: a folder renamed upstream would drop out of COMMON_FOLDERS +// unseen, and the lost titles above had taken shape-chart to 0 of 18 topics. +const thinFolders = !existsSync(DOCS_COMMON_DIR) ? [] : [...COMMON_FOLDERS.keys()] + .map(f => ({ f, ...(folderCounts.get(f) ?? { files: 0, topics: 0 }) })) + .filter(c => c.topics === 0 || c.topics < c.files / 3); +assertBuildStep(thinFolders.length === 0, + `docs-common folders with too few topics written: ${thinFolders.map(c => `${c.f} (${c.topics} of ${c.files} files)`).join(', ')}. ` + + `Either the folder is gone from docs/docs-common/topics/en (update COMMON_FOLDERS) or processFile drops its topics.` +); +// A COMMON_NOT_WPF file renamed or moved upstream would be a WPF topic again, unseen. +const notWpfMissing = [...COMMON_NOT_WPF].filter(f => !notWpf.includes(f)); +assertBuildStep(!existsSync(DOCS_COMMON_DIR) || notWpfMissing.length === 0, + `COMMON_NOT_WPF names files docs/docs-common/topics/en no longer has: ${notWpfMissing.join(', ')}. Find where they went ` + + `and update the list, or the Xamarin.Forms topics are back in search_wpf_docs.` +); + +// A pick:[…] left in a written topic means resolvePicks' pattern no longer matches the sources' form. +const unresolvedPicks = index.filter(e => readFileSync(join(OUT_DIR, `${e.slug}.json`), 'utf-8').includes('pick:[')).map(e => e.slug); +assertBuildStep(unresolvedPicks.length <= 5, + `${unresolvedPicks.length} topics still contain pick:[platform="…"] macros (e.g. ${unresolvedPicks.slice(0, 3).join(', ')}): ` + + `resolvePicks no longer matches their form, so agents would read every platform's wording, Java and Xamarin included.` +); + +// Fences are what get_wpf_doc's pages keep code apart by; none means the code-block pattern no longer matches. +const fenced = index.reduce((n, e) => n + (readFileSync(join(OUT_DIR, `${e.slug}.json`), 'utf-8').match(/```/g)?.length ?? 0) / 2, 0); +assertBuildStep(fenced > 1000, + `Only ${fenced} code blocks came out as \`\`\` fences across ${index.length} topics — stripAdoc's code-block pattern ` + + `([source,…] + ---- … ----) no longer matches the AsciiDoc sources, so code would reach agents as plain prose again.` ); console.log(`\nDone.`); console.log(` Topics written : ${index.length}`); -console.log(` Skipped : ${skipped} (no metadata or empty)`); +console.log(` Code blocks : ${fenced}`); +console.log(` Other platforms: ${otherPlatformTopics} topics built only for them left out; ${topicsTrimmed} topics lost ` + + `${droppedChars} chars of non-WPF ifdef:: blocks (WPF flags: ${[...WPF_FLAGS].join(', ')}); ${notWpf.length} docs-common ` + + `files that aren't WPF's left out (${notWpf.join(', ')})`); +console.log(` Includes : ${includedFiles.size} files pasted by include::, ${fragments.length} of them fragments that ` + + `aren't topics and ${unsearchable} fragments with a metadata name written but not searchable; ${aliases.length} files ` + + `that only include another topic left out (${aliases.map(fileName).join(', ')})`); +console.log(` Shared names : ${renamed.length} topics took their file name as slug, the name being another file's, and ` + + `${sameAsNamed.length} left out as that file's copy (${sameAsNamed.map(where).join(', ')}):`); +for (const r of renamed) console.log(` ${r}`); +console.log(` Same file : ${shadowed.length} docs-wpf files left out for the docs-common file of the same name: ` + + shadowed.map(t => `${where(t)} (${t.entry.slug})`).join(', ')); +console.log(` Skipped : ${skipped} (other platforms, ${untitled.length} untitled without a metadata name, ` + + `include-only files, docs-wpf files a docs-common one replaces, copies of a named file, empty)`); console.log(` Index : ${INDEX_FILE}`); console.log(` Full content : ${OUT_DIR}/ (${index.length} files)`); diff --git a/scripts/build-themes.ts b/scripts/build-themes.ts index 678c141..4c40f9c 100644 --- a/scripts/build-themes.ts +++ b/scripts/build-themes.ts @@ -9,10 +9,11 @@ * * Sources (docs/wpf-resources submodule — https://github.com/Infragistics/wpf-resources): * Themes/{ThemeName}/*.xaml - * Per-theme resource dictionaries for the newer "Infragistics.Controls.*" family - * (charts, gauges, etc.) — applied at runtime via Infragistics.Themes.ThemeManager, - * not embedded in the component assemblies. One folder per named theme - * (MetroDark, RoyalDark, Office2013, ...), containing both per-control files + * Per-theme resource dictionaries applied at runtime via Infragistics.Themes.ThemeManager + * (ApplicationTheme = new MetroDarkTheme()), not embedded in the component assemblies: + * the "Infragistics.Controls.*" controls (charts, gauges, etc.) and most "Infragistics.Windows.*" + * ones too (XamDataGrid, the xamEditors, XamDockManager, XamRibbon, ...). One folder per named + * theme (MetroDark, RoyalDark, Office2013, ...), containing both per-control files * (e.g. "MetroDark.xamDataChart.xaml") and shared/core files (e.g. * "MetroDark.WPF.xaml", "MetroDark.Theme.Colors.xaml"). * @@ -24,6 +25,11 @@ * for the out-of-the-box look. The raw XAML here is useful for *customizing*: * copy the relevant Style/ControlTemplate and override just the parts you need. * + * nuget/type-info.json "__themeMappings" (the type extractor, from the theme DLLs) + * Which controls each ThemeManager theme styles, and with which of its files. Many of those + * files name no TargetType for the control (RoyalDark.xamDataPresenter.xaml only merges + * ), so without it XamDataGrid looked legacy-only. + * * Deliberately excluded: Dictionaries/ (spell-check word lists) and ResourceStrings/ * (localization .resx) — not relevant to visual theming. Only *.xaml files are copied * (binary .cur cursor files and Images/ subfolders are skipped). @@ -53,7 +59,7 @@ const INDEX_FILE = join(OUT_DATA_DIR, 'theme-index.json'); interface ThemeResourceFile { path: string; // relative path used as the get_wpf_theme_resource `path` input, e.g. "Themes/MetroDark/MetroDark.xamDataChart.xaml" file: string; // filename only, e.g. "MetroDark.xamDataChart.xaml" - targetTypes: string[]; // distinct control type names actually styled inside this file (parsed from TargetType="...") + targetTypes: string[]; // distinct control type names this file styles: parsed from TargetType="...", plus for Themes/ files the controls the theme's ThemeManager maps to it } interface NewerThemeEntry { @@ -121,16 +127,46 @@ function toResourceFiles(srcDir: string, prefix: string, files: string[]): Theme })); } -// ── Newer "Infragistics.Controls.*" ThemeManager themes ────────────────────── +// ── Newer ThemeManager themes ───────────────────────────────────────────────── + +// Theme name → { control full name → file name }, from the theme DLLs (see "Phase 4" in type-extractor/Program.cs). +const TYPE_INFO = join(ROOT, 'nuget', 'type-info.json'); +assertBuildStep(existsSync(TYPE_INFO), + `nuget/type-info.json not found. Without its "__themeMappings", setup_wpf_theme would show XamDataGrid, the ` + + `xamEditors, XamTreeGrid, ... as legacy-only (Theme="..."), though ThemeManager.ApplicationTheme styles them. ` + + `Run: npm run generate:types` +); +const themeMappings = JSON.parse(readFileSync(TYPE_INFO, 'utf-8')).__themeMappings as Record> | undefined; +assertBuildStep(!!themeMappings && Object.keys(themeMappings).length > 0, + `nuget/type-info.json has no "__themeMappings" section — it was written by an older type extractor. Run: npm run generate:types` +); const newerThemes: NewerThemeEntry[] = []; +const unmatched: string[] = []; +let mapped = 0; for (const themeName of listSubdirs(THEMES_SRC)) { const srcDir = join(THEMES_SRC, themeName); const files = listXamlFiles(srcDir); if (!files.length) continue; copyXaml(srcDir, join(OUT_RESOURCES_DIR, 'Themes', themeName), files); - newerThemes.push({ theme: themeName, files: toResourceFiles(srcDir, `Themes/${themeName}`, files) }); + const entry = { theme: themeName, files: toResourceFiles(srcDir, `Themes/${themeName}`, files) }; + // The DLL names the files with other casing ("Themes\RoyalDark.XamDataPresenter.xaml", "Ig.xamBarcode.xaml"). + const themeKey = Object.keys(themeMappings!).find(k => k.toLowerCase() === themeName.toLowerCase()); + for (const [control, fileName] of Object.entries(themeKey ? themeMappings![themeKey] : {})) { + const file = entry.files.find(f => f.file.toLowerCase() === fileName.toLowerCase()); + if (!file) { unmatched.push(`${themeName}: ${control} → ${fileName}`); continue; } + const name = control.split(/[.+]/).pop()!; + if (!file.targetTypes.includes(name)) file.targetTypes = [...file.targetTypes, name].sort(); + mapped++; + } + newerThemes.push(entry); } +const missingThemes = Object.keys(themeMappings!).filter(k => !newerThemes.some(t => t.theme.toLowerCase() === k.toLowerCase())); +assertBuildStep(unmatched.length === 0 && missingThemes.length === 0, + `The theme DLLs map controls to ${unmatched.length} file(s) and ${missingThemes.length} theme(s) that ` + + `docs/wpf-resources/Themes/ doesn't have (${[...missingThemes, ...unmatched].slice(0, 5).join('; ')}) — the submodule ` + + `and the NuGet theme packages are out of step. Bump them together.` +); // ── Legacy "Infragistics.Windows.*" per-component embedded themes ──────────── @@ -155,4 +191,5 @@ assertBuildStep(totalFiles > 0, `Needs a code fix in build-themes.ts, not a submodule re-init.` ); -console.log(`theme-index.json: ${newerThemes.length} newer themes, ${legacyStyles.length} legacy style folders, ${totalFiles} XAML files total.`); +console.log(`theme-index.json: ${newerThemes.length} newer themes, ${legacyStyles.length} legacy style folders, ${totalFiles} XAML files total; ` + + `${mapped} ThemeManager control → file mappings applied.`); diff --git a/scripts/smoke-test.ts b/scripts/smoke-test.ts index 0a1494a..4565c16 100644 --- a/scripts/smoke-test.ts +++ b/scripts/smoke-test.ts @@ -100,6 +100,10 @@ async function main(): Promise { r = await call('list_wpf_components', { filter: 'Funnel' }); text = textOf(r); check('internal classes are not listed as controls', !r.isError && text.includes('XamFunnelChart') && !text.includes('XamFunnelController') && !text.includes('XamFunnelConnector'), text.slice(0, 300)); + // Types hidden from IntelliSense are Infragistics' own plumbing (the sparkline's drawing part), not controls to use. + r = await call('list_wpf_components', { filter: 'Sparkline' }); + text = textOf(r); + check('types hidden from IntelliSense are not listed as controls', !r.isError && text.includes('XamSparkline') && !text.includes('XamSparklineView'), text.slice(0, 300)); console.log('get_wpf_api_reference'); r = await call('get_wpf_api_reference', { component: 'XamDataGrid', kind: 'all' }); @@ -113,7 +117,9 @@ async function main(): Promise { r = await call('get_wpf_api_reference', { component: 'DataPresenterRoyalDark' }); text = textOf(r); check('theme class gets the Themes xmlns and its inherited static styles', - !r.isError && text.includes('xmlns:igThemes="http://infragistics.com/Themes"') && text.includes('**CellValuePresenter** `Style`') && text.includes('(inherited from `DataPresenterResourceSet`1`)'), + !r.isError && text.includes('xmlns:igThemes="http://infragistics.com/Themes"') && text.includes('**CellValuePresenter** `Style`') && + // C# fences a name with a backtick in double backticks, so the Markdown code span holds; the frozen TS server doesn't. + text.includes(IS_DOTNET ? '(inherited from `` DataPresenterResourceSet`1 ``)' : '(inherited from `DataPresenterResourceSet`1`)'), text.slice(0, 400)); // Static key/command fields are listed (they used to be dropped: DataPresenterCommands showed 0 of 71 commands). r = await call('get_wpf_api_reference', { component: 'DataPresenterCommands', kind: 'properties' }); @@ -128,6 +134,8 @@ async function main(): Promise { // Internal classes have no page, and a name an internal class used to win now documents the public class. r = await call('get_wpf_api_reference', { component: 'ActivePaneManager' }); check('internal class has no API page', r.isError === true, textOf(r).slice(0, 200)); + r = await call('get_wpf_api_reference', { component: 'XamSparklineView' }); + check('type hidden from IntelliSense has no API page', r.isError === true, textOf(r).slice(0, 200)); r = await call('get_wpf_api_reference', { component: 'DashboardTileGroupDescription' }); text = textOf(r); check('public type wins its name over an internal one', @@ -183,6 +191,189 @@ async function main(): Promise { check('member filter accepts 128 characters', !r.isError && textOf(r).includes('No members whose name contains'), textOf(r).slice(0, 200)); r = await call('get_wpf_api_reference', { component: 'XamCategoryChart', member: 'x'.repeat(129) }); check('member over 128 characters → isError naming the limit', r.isError === true && textOf(r).includes('max 128 characters'), textOf(r).slice(0, 200)); + // Methods carry their C# signature, one line per overload; with `member`, also the parameter descriptions, + // which tell overloads with the same summary apart ("XPS only" is what an agent needs before trying PDF). + r = await call('get_wpf_api_reference', { component: 'Report', member: 'Export' }); + text = textOf(r); + check('overloads show their signatures and parameter descriptions', + !r.isError && text.includes('- `void Export(OutputFormat outputFormat, string fileName)`:') && text.includes(' - `outputFormat`: The output document type (XPS only).'), + text.slice(0, 400)); + // Constructors that take parameters are listed; [Obsolete] ones only come back as a notice. + r = await call('get_wpf_api_reference', { component: 'Workbook', member: 'Workbook' }); + text = textOf(r); + check('constructors are listed, obsolete ones as a notice', + !r.isError && text.includes('### Constructors\n- `Workbook(WorkbookFormat format)`:') && text.includes('⚠ OBSOLETE constructor `Workbook(WorkbookPaletteMode paletteMode)`'), + text.slice(0, 400)); + // An [Obsolete] member isn't listed, but looking it up gets the replacement (8 docs topics still set HorizontalZoomable). + r = await call('get_wpf_api_reference', { component: 'XamDataChart', member: 'HorizontalZoomable' }); + text = textOf(r); + check('obsolete member lookup names the replacement', + !r.isError && text.includes('⚠ OBSOLETE property `XamDataChart.HorizontalZoomable`: Please use the IsHorizontalZoomEnabled property.') && !text.includes('**HorizontalZoomable**'), + text.slice(0, 400)); + // An [Obsolete] type keeps its page but leads with the replacement (3 docs topics still use UnboundField). + r = await call('get_wpf_api_reference', { component: 'UnboundField', kind: 'events' }); + check('obsolete type page leads with the replacement', !r.isError && textOf(r).includes('⚠ **OBSOLETE type:** Use Field'), textOf(r).slice(0, 300)); + // Obsolete member names stay searchable, so an agent following old docs still finds the type and its note. + r = await call('search_wpf_api', { query: 'HorizontalZoomable', limit: 3 }); + check('obsolete member names stay searchable', !r.isError && textOf(r).includes('XamDataChart'), textOf(r).slice(0, 300)); + // Compacted lists name an overloaded method once, with its count. + r = await call('get_wpf_api_reference', { component: 'XamDataGrid' }); + check('compacted lists show an overloaded method once with its count', !r.isError && textOf(r).includes('`LoadCustomizations` (8)'), textOf(r).slice(0, 200)); + // An override of a generic base type's method replaces it: CollectionBase.Insert(int, T) isn't listed again. + r = await call('get_wpf_api_reference', { component: 'ColumnBaseCollection', member: 'Insert' }); + text = textOf(r); + check('an override of a generic base method is listed once', + !r.isError && text.includes('- `void Insert(int index, ColumnBase item)`:') && !text.includes('void Insert(int index, T item)'), + text.slice(0, 400)); + // A member a subtype re-declares hidden from IntelliSense isn't inherited from the base either. + r = await call('get_wpf_api_reference', { component: 'TemplateColumn', member: 'ValueConverter' }); + check('a member hidden on the subtype is not inherited from the base', + !r.isError && textOf(r).includes('No members whose name contains "ValueConverter"'), textOf(r).slice(0, 400)); + // Nullable properties keep their "?" (CurrentViewDateRange used to read `DateRange`). + r = await call('get_wpf_api_reference', { component: 'XamOutlookCalendarView', member: 'CurrentViewDateRange' }); + check('nullable property types end in "?"', !r.isError && textOf(r).includes('- **CurrentViewDateRange** `DateRange?`:'), textOf(r).slice(-400)); + // Protected properties and events look public in the XML docs: they get their type and a marker (an agent used + // XamGrid.CurrentEditCell from outside the grid and got CS0122). Public ones aren't marked. + r = await call('get_wpf_api_reference', { component: 'XamGrid', member: 'Cell' }); + text = textOf(r); + check('protected properties show their type, marked protected', + !r.isError && text.includes('- **CurrentEditCell** `Cell` _(protected)_:') && text.includes('- **ActiveCell** `CellBase`:'), text.slice(0, 400)); + r = await call('get_wpf_api_reference', { component: 'XamDataTree', kind: 'events', member: 'NodeDelet' }); + check('protected events are marked protected', !r.isError && textOf(r).includes('- **NodeDeleting** _(protected)_:'), textOf(r).slice(-400)); + // A list of names keeps them apart too, after "protected:". + r = await call('get_wpf_api_reference', { component: 'XamDataChart' }); + check('compacted lists name protected members after "protected:"', + !r.isError && /^`CommandCompleted`, [^\n]*; protected: `IsHighlightingInitialized`, /m.test(textOf(r)), textOf(r).slice(0, 300)); + // A method name goes there when all its overloads are protected (InvalidateScrollPanel, 3 of them); one with a public + // overload stays public, its count unchanged (Dispose(): public, Dispose(bool): protected). + r = await call('get_wpf_api_reference', { component: 'XamGrid' }); + const xamGridMethods = /^### Methods\n(.*)$/m.exec(textOf(r))?.[1] ?? ''; + check('compacted method lists name protected-only methods after "protected:"', + !r.isError && /^`OnApplyTemplate`, [^;]*`Dispose` \(2\); protected: `OnCreateAutomationPeer`, [^;]*`InvalidateScrollPanel` \(3\)/.test(xamGridMethods), + xamGridMethods.slice(0, 300)); + // Protected ones go to names before public ones lose detail: in full, their types and markers take this kind "properties" + // response to 25.3K, over 25K, and with it every inherited property down to its name (23.0K with them by name). + r = await call('get_wpf_api_reference', { component: 'GeographicHighDensityScatterSeries', kind: 'properties' }); + text = textOf(r); + check('protected properties go to names before public ones', + !r.isError && text.length <= 25_000 && text.includes('so protected properties and events are listed by name only (public members keep full detail)') && + text.includes('- **DataLegendGroup** `String`:') && text.includes('\n- protected: `CustomClipRect`, '), + `${text.length} chars`); + // Enums list their values (894 enum pages said "No members defined directly on this type"), with any kind. + r = await call('get_wpf_api_reference', { component: 'OutputFormat', kind: 'properties' }); + text = textOf(r); + check('an enum lists its values', !r.isError && text.includes('### Values\n- **XPS** = 0: Exports to XPS format') && !text.includes('No properties members'), text.slice(-400)); + r = await call('get_wpf_api_reference', { component: 'ValueConstraintFlags' }); + text = textOf(r); + check('a [Flags] enum says its values combine', + !r.isError && text.includes('_[Flags] enum: values combine — `ValueConstraintFlags.Enumeration | ValueConstraintFlags.FixedValue` in C#') && text.includes('- **All** = 2147483647:'), + text.slice(-600)); + // Without two single-bit values of its own (None, Office2007UI, All) there is no example, not a made-up `A | B`. + r = await call('get_wpf_api_reference', { component: 'LicenseOptions' }); + text = textOf(r); + check('a [Flags] enum with one single-bit value has no placeholder example', + !r.isError && text.includes('_[Flags] enum: values combine with `|` in C#, comma-separated in a XAML attribute._') && !/LicenseOptions\.\w+ \| /.test(text), + text.slice(-400)); + // An [Obsolete] value stays, with its replacement; `member` filters values. + r = await call('get_wpf_api_reference', { component: 'MapFillMode', member: 'chloropleth' }); + text = textOf(r); + check('an obsolete enum value names its replacement', + !r.isError && text.includes('_Showing 1 value whose name contains "chloropleth"._') && text.includes('This is a synonym for Choropleth. ⚠ OBSOLETE: Please use Choropleth instead.'), + text.slice(-400)); + // Types that share a short name each have a page, opened by full name. The name alone opens the type of the family + // Infragistics recommends, never a retired control's: "SelectionType" was XamGrid's (None, Single, Multiple), and an + // agent on XamDataGrid wrote SelectionType.Multiple, which its SelectionType doesn't have. + r = await call('get_wpf_api_reference', { component: 'SelectionType' }); + text = textOf(r); + check('a shared name is not a retired control\'s type, and lists the others with the call that opens each', + !r.isError && text.includes('**Full type name:** `Infragistics.Windows.Controls.SelectionType`') && text.includes('- **Extended** = 3:') && + text.includes('- `get_wpf_api_reference("Infragistics.Controls.Grids.SelectionType")` — 3 values (`None`, `Single`, `Multiple`), NuGet `Infragistics.WPF.Controls.Grids.XamGrid.Trial`'), + text.slice(0, 1200)); + r = await call('get_wpf_api_reference', { component: 'Infragistics.Controls.Grids.SelectionType' }); + text = textOf(r); + check('a full name opens a type that lost its short name', + !r.isError && text.includes('**Full type name:** `Infragistics.Controls.Grids.SelectionType`') && text.includes('- **Multiple** = 2:') && + text.includes('`get_wpf_api_reference("Infragistics.Windows.Controls.SelectionType")` (what "SelectionType" alone returns)'), + text.slice(0, 1200)); + r = await call('get_wpf_api_reference', { component: 'infragistics.windows.controls.selectiontype' }); + check('a full name opens the type that kept the name, case-insensitively', + !r.isError && textOf(r).includes('**Full type name:** `Infragistics.Windows.Controls.SelectionType`') && textOf(r).includes('- **Extended** = 3:') && + !textOf(r).includes('(what "SelectionType" alone returns)'), textOf(r).slice(0, 400)); + r = await call('get_wpf_api_reference', { component: 'Infragistics.Windows.Editors.SelectionType' }); + check('an unknown full name of a known short name → isError listing the real ones', + r.isError === true && textOf(r).includes('Type "Infragistics.Windows.Editors.SelectionType" not found') && + textOf(r).includes('- `get_wpf_api_reference("Infragistics.Controls.Grids.SelectionType")` — NuGet'), textOf(r).slice(0, 400)); + // Newer copies of legacy Infragistics.Windows types take their name: the ThemeManager of the newer themes (what + // setup_wpf_theme applies, ApplicationTheme), the newer XamComboEditor (IsEditable, AllowFiltering, AutoComplete). + r = await call('get_wpf_api_reference', { component: 'ThemeManager' }); + text = textOf(r); + check('"ThemeManager" is the newer themes\' ThemeManager', + !r.isError && text.includes('**Full type name:** `Infragistics.Themes.ThemeManager`') && text.includes('- **ApplicationTheme** `ThemeBase`:') && + text.includes('- `get_wpf_api_reference("Infragistics.Windows.Themes.ThemeManager")` — 17 members'), + text.slice(0, 1200)); + r = await call('get_wpf_api_reference', { component: 'XamComboEditor', kind: 'properties', member: 'IsEditable' }); + text = textOf(r); + check('"XamComboEditor" is the newer control, the legacy one listed by full name', + !r.isError && text.includes('**Full type name:** `Infragistics.Controls.Editors.XamComboEditor`') && text.includes('xmlns:ig="http://schemas.infragistics.com/xaml"') && + text.includes('- **IsEditable** `Boolean`:') && text.includes('- `get_wpf_api_reference("Infragistics.Windows.Editors.XamComboEditor")` — 142 members, NuGet `Infragistics.WPF.Editors.Trial`'), + text.slice(0, 1200)); + // A generic name is a valid code span: double-fenced, as its backtick would end a single-fenced one. + r = await call('get_wpf_api_reference', { component: 'SelectedCollectionBase`1', kind: 'events' }); + check('a generic full name renders as one code span', + !r.isError && textOf(r).includes('This page documents `` Infragistics.Controls.Menus.SelectedCollectionBase`1 `` (38 members), which'), textOf(r).slice(0, 1200)); + // A nested type's full name, as reflection writes it (Outer+Inner) or as C# does (Outer.Inner). + r = await call('get_wpf_api_reference', { component: 'Infragistics.Olap.Xmla.XmlaDataSource+DataSourceState' }); + check('a nested type that lost its short name opens by Outer+Inner', + !r.isError && textOf(r).includes('**Full type name:** `Infragistics.Olap.Xmla.XmlaDataSource.DataSourceState`') && textOf(r).includes(':XmlaDataSource+DataSourceState` (nested class)'), + textOf(r).slice(0, 400)); + // Guard (passes on the code before full names too): a full name whose short name has no page is a plain not-found. + r = await call('get_wpf_api_reference', { component: 'Infragistics.Controls.Grids.NoSuchTypeXyz' }); + check('an unknown full name → isError', r.isError === true && textOf(r).includes('not found'), textOf(r).slice(0, 200)); + // A type's page is built like any other: the legacy XamComboEditor inherits most of its API from ValueEditor. + r = await call('get_wpf_api_reference', { component: 'Infragistics.Windows.Editors.XamComboEditor', kind: 'properties', member: 'Value' }); + text = textOf(r); + check('a type that lost its short name has its inherited members', + !r.isError && text.includes('xmlns:igEditors="http://infragistics.com/Editors"') && text.includes('### Properties (inherited from `ValueEditor`)') && text.includes('- **Value** `Object`:') && + text.includes('`get_wpf_api_reference("Infragistics.Controls.Editors.XamComboEditor")` (what "XamComboEditor" alone returns)'), + text.slice(0, 600)); + // Two assemblies have an Infragistics.Controls.Editors.Caret: the assembly picks one. + r = await call('get_wpf_api_reference', { component: 'Infragistics.Controls.Editors.Caret, Infragistics.WPF.Controls.Editors.XamRichTextEditor' }); + check('a full name two assemblies share opens the one its assembly names', + !r.isError && textOf(r).includes('**Assembly:** `Infragistics.WPF.Controls.Editors.XamRichTextEditor`'), textOf(r).slice(0, 400)); + // XamSpreadsheet's package is the one with its DLL: Infragistics.WPF.Spreadsheet.Charts.Trial also ships its XML docs + // (beside its own ChartAdapter DLL), and an agent installed that one from the scaffold. + r = await call('get_wpf_api_reference', { component: 'XamSpreadsheet', kind: 'events' }); + check('XamSpreadsheet\'s NuGet package is the one with its DLL', + !r.isError && textOf(r).includes('**NuGet:** `Infragistics.WPF.Spreadsheet.Trial`') && !textOf(r).includes('Spreadsheet.Charts') && !textOf(r).includes('is ambiguous'), + textOf(r).slice(0, 600)); + // Search finds it by its members (agents searched these three looking for the newer XamComboEditor), and says how to open it. + r = await call('search_wpf_api', { query: 'AutoComplete IsEditable AllowFiltering', limit: 5 }); + check('search finds the newer XamComboEditor, with its full name', + !r.isError && textOf(r).includes('- **Several types are named "XamComboEditor":** open this one with get_wpf_api_reference("Infragistics.Controls.Editors.XamComboEditor")'), + textOf(r).slice(0, 600)); + // A query that is a shared name lists its types first among equal scores: the Excel Axis used to rank 42nd. + r = await call('search_wpf_api', { query: 'Axis' }); + check('search for a shared name lists the types of that name first among ties', + !r.isError && textOf(r).includes('open this one with get_wpf_api_reference("Infragistics.Controls.Charts.Axis")') && + textOf(r).includes('open this one with get_wpf_api_reference("Infragistics.Documents.Excel.Charts.Axis")'), + textOf(r).slice(0, 600)); + r = await call('list_wpf_components', { filter: 'XamComboEditor' }); + check('list_wpf_components lists the newer XamComboEditor and names the legacy one', + !r.isError && textOf(r).includes('### XamComboEditor\n- **xmlns:** `xmlns:ig="http://schemas.infragistics.com/xaml"`\n- **NuGet:** `Infragistics.WPF.Controls.Editors.XamComboEditor.Trial`') && + textOf(r).includes('- ⚠️ Not the only XamComboEditor: `Infragistics.Windows.Editors.XamComboEditor` is another'), textOf(r).slice(0, 800)); + // The scaffold uses the same control, says there is another, and takes the other's full name. + r = await call('get_wpf_project_scaffold', { components: ['XamComboEditor', 'XamSpreadsheet'], projectName: 'SmokeApp' }); + text = textOf(r); + check('scaffold: the newer XamComboEditor, the other named; XamSpreadsheet\'s own package', + !r.isError && text.includes('dotnet add package Infragistics.WPF.Controls.Editors.XamComboEditor.Trial\n') && text.includes('xmlns:ig="http://schemas.infragistics.com/xaml"') && + text.includes('This is `Infragistics.Controls.Editors.XamComboEditor`. Another XamComboEditor exists: `Infragistics.Windows.Editors.XamComboEditor` (NuGet') && + text.includes('dotnet add package Infragistics.WPF.Spreadsheet.Trial\n') && !text.includes('Spreadsheet.Charts'), + text.slice(0, 1200)); + r = await call('get_wpf_project_scaffold', { components: ['Infragistics.Windows.Editors.XamComboEditor'], projectName: 'SmokeApp' }); + text = textOf(r); + check('scaffold takes the full name of the other control of a name', + !r.isError && text.includes('dotnet add package Infragistics.WPF.Editors.Trial\n') && text.includes('xmlns:igEditors="http://infragistics.com/Editors"') && !text.includes('not found'), + text.slice(0, 1200)); } r = await call('get_wpf_api_reference', { component: 'xamdatagrid', kind: 'events' }); check('case-insensitive lookup + kind filter', !r.isError && textOf(r).includes('# XamDataGrid')); @@ -222,12 +413,124 @@ async function main(): Promise { } r = await call('search_wpf_docs', { query: 'column series', limit: 3 }); check('docs-common topics resolved ({CategoryChartName} → real name)', !r.isError && /XamCategoryChart|XamDataChart/.test(textOf(r)), textOf(r).slice(0, 300)); + // The Windows Forms / ASP.NET UltraChart docs (docs-common's win-web-chart, {WawChartName}) are not WPF. + r = await call('search_wpf_docs', { query: 'bar chart specific properties export pdf', limit: 50 }); + check('no Windows Forms / ASP.NET chart topics', !textOf(r).includes('{WawChartName}'), textOf(r).slice(0, 300)); if (IS_DOTNET) { // C# only: the TS server strips hyphens but not spaces from control names. r = await call('search_wpf_docs', { control: 'ThemeManager', limit: 50 }); text = textOf(r); check('control "ThemeManager" matches topics tagged "IG Theme Manager"', !r.isError && text.includes('IG Theme Manager'), text.slice(0, 300)); + // Topics tagged with a family (xamInputs, xamDataPresenter) are found by the controls they name. + r = await call('search_wpf_docs', { control: 'XamMaskedInput', limit: 3 }); + check('control filter finds a family-tagged control', !r.isError && textOf(r).includes('**Controls:** xamInputs'), textOf(r).slice(0, 300)); + r = await call('search_wpf_docs', { control: 'XamDataGrid', query: 'field layout', limit: 10 }); + check('XamDataGrid also finds its xamDataPresenter topics', !r.isError && textOf(r).includes('**Controls:** xamDataPresenter'), textOf(r).slice(0, 300)); + // Topics without a metadata name are kept by their title, which the sources' byte-order mark used to hide: all + // of shape-chart and most of financial-chart were missing. Those that name no control get their folder's. + r = await call('search_wpf_docs', { control: 'XamFinancialChart', limit: 10 }); + check('XamFinancialChart finds its getting-started topic', !r.isError && textOf(r).includes('`financial-chart-gettingstarted`'), textOf(r).slice(0, 300)); + // A folder alias ("financial-chart") stands for the folder's control: 10 of those 13 topics ranked 23rd to 32nd, + // below the ones that name no control (the other 3: 4th, 5th, 10th). An unresolved {Placeholder} (a typo in the + // source) names no control. + r = await call('search_wpf_docs', { control: 'XamFinancialChart', limit: 15 }); + text = textOf(r); + check('topics naming the folder alias rank with their siblings', + !r.isError && text.includes('`financial-chart-chart-tooltips`') && !text.includes('**Controls:** financial-chart'), text.slice(0, 300)); + r = await call('search_wpf_docs', { control: 'XamDataPieChart', limit: 10 }); + check('a topic naming an unresolved placeholder gets its folder\'s control', + !r.isError && /`data-pie-chart-data-binding`\n- \*\*Matched on:\*\*[^\n]*\n- \*\*Controls:\*\* XamDataPieChart\n/.test(textOf(r)), textOf(r).slice(0, 300)); + r = await call('search_wpf_docs', { control: 'XamShapeChart', limit: 15 }); + const shapeSlugs = [...textOf(r).matchAll(/\*\*Topic slug:\*\* `([^`]+)`/g)].map(m => m[1]); + check('XamShapeChart topics rank above release notes that mention it', shapeSlugs.length === 15 && shapeSlugs.every(s => s.startsWith('shapechart-')), shapeSlugs.join(', ')); + // docs-common's scheduler folder is the Xamarin XamScheduler, not part of the WPF docs; as XamScheduler topics they matched + // "xamSchedule" by substring (8 of the top 10 for this query). WPF's XamSchedule family is in docs-wpf. + r = await call('search_wpf_docs', { query: 'xamSchedule appointments', limit: 10 }); + text = textOf(r); + check('"xamSchedule" finds the XamSchedule topics, not the Xamarin scheduler ones', + !r.isError && text.includes('`xamschedule-using-activities-appointments`') && !text.includes('**Topic slug:** `scheduler'), text.slice(0, 300)); + r = await call('search_wpf_docs', { control: 'xamSchedule', limit: 50 }); + check('control "xamSchedule" lists no Xamarin scheduler topics', + !r.isError && textOf(r).includes('`xamschedule-getting-started`') && !textOf(r).includes('**Topic slug:** `scheduler'), textOf(r).slice(0, 300)); + // No WPF topic says "scheduler" (XamScheduler is the Xamarin control): the empty result points to the XamSchedule family. + r = await call('search_wpf_docs', { query: 'scheduler' }); + check('"scheduler" points to control "xamSchedule"', r.isError === true && textOf(r).includes('search_wpf_docs(control: "xamSchedule")'), textOf(r).slice(0, 300)); + r = await call('search_wpf_docs', { query: 'TaskScheduler' }); + check('"TaskScheduler" gets no XamScheduler hint', !textOf(r).includes('XamScheduler'), textOf(r).slice(0, 300)); + // docs-common's productivity-tools folder also has Xamarin.Forms topics, which only its ifdef::xamarin[] blocks link. + r = await call('get_wpf_doc', { topic: 'using-control-configurator-with-xamarin-data-grid' }); + check('the Xamarin.Forms data grid configurator topic is left out', r.isError === true, textOf(r).slice(0, 200)); + // include:: pastes the included file: this topic was its title, Purpose and Required Background (504 characters). + r = await call('get_wpf_doc', { topic: 'xamdatagrid-crossfieldrecordfiltering' }); + text = textOf(r); + check('include:: content is pasted in', !r.isError && text.includes('## Specifying Filters Programmatically') && text.includes('RecordFilterScope'), text.slice(-200)); + // A file that is its own title and an include:: of a whole topic is that topic under its own title, not under both. + r = await call('get_wpf_doc', { topic: 'xamdatapresenter-field-grouping' }); + check('a whole topic pasted under a title of its own keeps one title', + !r.isError && textOf(r).startsWith('## Field Grouping (xamDataPresenter)\n') && !textOf(r).includes('# Field Grouping (xamDataGrid)'), textOf(r).slice(0, 300)); + // A topic published for two controls (xamdatagrid-X and xamdatapresenter-X paste one include:: file) is listed once, + // as the copy for `control`, naming the other: both copies took places and pushed xamdatagrid-exporting-data out. + r = await call('search_wpf_docs', { query: 'export DataPresenter control to Excel', control: 'xamDataGrid', limit: 6 }); + text = textOf(r); + check('the same text is listed once, as the copy for `control`', + !r.isError && text.includes('**Topic slug:** `xamdatagrid-configuring-custom-tab-navigation`') && + text.includes('**Same text also under:** `xamdatapresenter-configuring-custom-tab-navigation`') && + !text.includes('**Topic slug:** `xamdatapresenter-configuring-custom-tab-navigation`') && text.includes('`xamdatagrid-exporting-data`'), + text.slice(0, 300)); + r = await call('search_wpf_docs', { query: 'record filtering filter record', control: 'xamDataPresenter', limit: 8 }); + text = textOf(r); + check('with control "xamDataPresenter", its own copy is the one listed', + !r.isError && text.includes('**Topic slug:** `xamdatapresenter-crossfieldrecordfiltering`') && !text.includes('**Topic slug:** `xamdatagrid-crossfieldrecordfiltering`'), + text.slice(0, 300)); + // The included file itself still opens by its metadata name, but isn't listed as a third copy. + r = await call('search_wpf_docs', { control: 'XamCurrencyEditor', limit: 15 }); + check('an include:: fragment is not listed', !r.isError && !textOf(r).includes('**Topic slug:** `configuring-specific-editor-fields`'), textOf(r).slice(0, 300)); + r = await call('get_wpf_doc', { topic: 'configuring-specific-editor-fields' }); + check('the include:: fragment still opens by its slug', !r.isError && textOf(r).includes('CurrencyField'), textOf(r).slice(0, 200)); + // A metadata name several files claim belongs to the file named after it; the rest keep their own file names. + r = await call('get_wpf_doc', { topic: 'piechart-selection' }); + check('"piechart-selection" is the pie chart selection topic', !r.isError && textOf(r).startsWith('## Selection\n') && textOf(r).includes('selection feature in the XamPieChart'), textOf(r).slice(0, 300)); + r = await call('get_wpf_doc', { topic: 'data-pie-chart' }); + check('the data pie chart overview, also named "piechart-selection", has its own slug', !r.isError && textOf(r).startsWith('## XamDataPieChart\n'), textOf(r).slice(0, 300)); + r = await call('get_wpf_doc', { topic: 'whats-new-in-2017-volume-1' }); + check('"whats-new-in-2017-volume-1" is volume 1, not volume 2 that also claims the name', !r.isError && textOf(r).startsWith('## What\'s New in 2017 Volume 1\n'), textOf(r).slice(0, 300)); + // home-page.adoc is wpf-netadvantage-win-client-wpf.adoc byte for byte, metadata name included: not another topic. + r = await call('get_wpf_doc', { topic: 'home-page' }); + check('a copy of the file that keeps a name is left out', r.isError === true, textOf(r).slice(0, 200)); + // The data pie chart files that claim piechart-selection copied its {PieChartName} too: they get their folder's control. + r = await call('get_wpf_doc', { topic: 'data-pie-chart-animation' }); + check('a docs-common topic that took its file name is tagged with its folder\'s control', !r.isError && textOf(r).includes('**Controls:** XamDataPieChart\n'), textOf(r).slice(0, 300)); + // docs-wpf's bulletgraph.adoc is only its title; the docs site serves docs-common's overview of the same name. + r = await call('get_wpf_doc', { topic: 'bulletgraph' }); + check('a docs-common topic replaces the docs-wpf stub of the same name', !r.isError && textOf(r).includes('In This Group of Topics'), textOf(r).slice(0, 300)); + // ifdef::XAML[] is WPF content too (build flags are matched case-insensitively). + r = await call('get_wpf_doc', { topic: 'shapechart-series-requirements' }); + check('recovered topic opens, its ifdef::XAML[] note kept', !r.isError && textOf(r).includes('Points column must be a list of lists'), textOf(r).slice(0, 300)); + // A topic with no `= Title` takes its one top-level `== ` heading. + r = await call('get_wpf_doc', { topic: 'nuget-packages' }); + check('topic titled with == keeps its title', !r.isError && textOf(r).startsWith('## Infragistics NuGet Packages\n'), textOf(r).slice(0, 200)); + // Whole topics up to ~24K characters (this one, 22K, used to be cut at 6,000), with their code fenced. + r = await call('get_wpf_doc', { topic: 'implementing-undo-redo-crud-operations-in-xamgrid-control' }); + text = textOf(r); + check('long topic comes back whole', !r.isError && !text.includes('truncated') && text.trimEnd().endsWith('Undo/Redo Framework™.') && text.includes('```csharp'), text.slice(-200)); + // Code keeps its {…} tokens: the body used to read XAxisLabel="{}". + r = await call('get_wpf_doc', { topic: 'categorychart-binding-to-data' }); + check('code keeps {Name}-style tokens', !r.isError && textOf(r).includes('XAxisLabel="{}{Name}"'), textOf(r).slice(0, 200)); + // Longer topics come in pages, each with balanced code fences. + r = await call('get_wpf_doc', { topic: 'resources-customtheme.xamtilemanager', page: 2 }); + text = textOf(r); + check('long topic pages with balanced fences', !r.isError && /comes in \d+ pages/.test(text) && text.includes('this is page 2') && (text.match(/^```/gm)?.length ?? 1) % 2 === 0, text.slice(0, 300)); + r = await call('get_wpf_doc', { topic: 'resources-customtheme.xamtilemanager', page: 99 }); + check('page out of range → isError', r.isError === true && textOf(r).includes('out of range'), textOf(r).slice(0, 200)); + // [Range(1, 100)] isn't enforced by the SDK: page 0 is out of range too, for long and one-page topics alike. + r = await call('get_wpf_doc', { topic: 'resources-customtheme.xamtilemanager', page: 0 }); + check('page 0 → out-of-range isError', r.isError === true && textOf(r).includes('page 0 is out of range'), textOf(r).slice(0, 200)); + r = await call('get_wpf_doc', { topic: 'adding-an-assembly-to-an-expression-blend-project', page: 0 }); + check('page 0 of a one-page topic → out-of-range isError', r.isError === true && textOf(r).includes('page 0 is out of range'), textOf(r).slice(0, 200)); } + // AsciiDoc block delimiters are dropped: "==== " became an empty "####" heading (99 in 50 topics; this one had 7). + r = await call('get_wpf_doc', { topic: 'excelengine-password-support' }); + check('no empty headings or ==== block delimiters in a topic', !r.isError && !/^#{1,6}[ \t]*$|^={4,}[ \t]*$/m.test(textOf(r)), textOf(r).slice(0, 300)); r = await call('get_wpf_doc', { topic: 'no-such-topic-slug' }); check('unknown slug → isError', r.isError === true); @@ -244,6 +547,13 @@ async function main(): Promise { r = await call('setup_wpf_theme', { component: 'DataChart', theme: 'IG' }); text = textOf(r); check('IG apply block uses the IgTheme class', !r.isError && text.includes('new Infragistics.Themes.IgTheme()') && !text.includes('IGTheme()'), text.slice(0, 600)); + // ThemeManager.ApplicationTheme styles XamDataGrid too (the theme DLL maps it to .xamDataPresenter.xaml, + // which names no TargetType); before, agents got only Theme="..." and used the legacy ThemeManager. + r = await call('setup_wpf_theme', { component: 'XamDataGrid', theme: 'RoyalDark' }); + text = textOf(r); + check('XamDataGrid gets the ThemeManager route, before Theme=', !r.isError && + text.includes('Themes/RoyalDark/RoyalDark.xamDataPresenter.xaml') && text.includes('ApplicationTheme = new Infragistics.Themes.RoyalDarkTheme()') && + text.indexOf('## Newer family') < text.indexOf('## Legacy family') && text.includes('`XamDataGrid` is in both families'), text.slice(0, 600)); } console.log('get_wpf_theme_resource'); diff --git a/scripts/type-extractor/Program.cs b/scripts/type-extractor/Program.cs index fc995a3..458a703 100644 --- a/scripts/type-extractor/Program.cs +++ b/scripts/type-extractor/Program.cs @@ -10,15 +10,27 @@ * Output format (flat object): * "__baseTypes" : { "Assembly|Namespace.Type" : "BaseAssembly|BaseNamespace.BaseType" } * (only for Infragistics bases; a generic base as its definition, "…ResourceSet`1") - * "Assembly|Namespace.Type.PropName" : { typeName, isNullable, isEnum, enumValues? } (instance and static) + * "Assembly|Namespace.Type.PropName" : { typeName, isNullable, isEnum, enumValues? } (public and protected, instance and static) * "Assembly|Namespace.Type.FieldName" : { typeName, isNullable, isEnum, isField: true } (static ResourceKey/RoutedCommand fields) * "__xaml" : { "Assembly|Namespace.Type" (nested: "…|Namespace.Outer.Inner") : { xmlns, prefix, ns, xamlName? } } * "__nonPublicTypes" : [ "Assembly|Namespace.Type", … ] (internal / private nested types, to leave out) * "__nonPublicMembers" : [ "Assembly|Namespace.Type.Member", … ] (internal / private members, to leave out) + * "__members" : { "Assembly|" : { sig?, params?, overrides?, obsolete?, hidden?, protected? } } + * (every public / protected method and constructor, with its C# signature and parameter + * names; properties, events, fields and the type itself only when they are indexers, + * protected (properties and events) or marked obsolete / hidden) + * "__nonPublicMethods" : [ "Assembly|", … ] (internal / private methods and constructors) + * "__memberTypes" : [ "Assembly|Namespace.Type", … ] (types whose members "__members" covers) + * "__enums" : { "Assembly|Namespace.Type" : { flags?, values: [ { name, value }, … ] } } + * (every public enum, nested ones too, its values in declaration order) + * "__themeMappings" : { "" : { "Namespace.Type" : ".xamDataPresenter.xaml" } } + * (the controls each ThemeManager theme styles and the file in its Themes/ folder for each) */ using System; +using System.Collections; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Reflection; @@ -130,6 +142,10 @@ var output = new SortedDictionary(StringComparer.Ordinal); var baseTypes = new SortedDictionary(StringComparer.Ordinal); var nonPublicMembers = new SortedSet(StringComparer.Ordinal); +var members = new SortedDictionary>(StringComparer.Ordinal); +var nonPublicMethods = new SortedSet(StringComparer.Ordinal); +var memberTypes = new SortedSet(StringComparer.Ordinal); +var enums = new SortedDictionary>(StringComparer.Ordinal); foreach (var asm in loaded) { foreach (var t in SafeGetTypes(asm)) { @@ -163,6 +179,87 @@ void Note(string name, bool ok) { foreach (var (name, ok) in usable) if (!ok) nonPublicMembers.Add($"{typeKey}.{name}"); } catch { /* skip inaccessible type: its members stay */ } + // ── Signatures and flags, keyed by XML doc ID ─────────────────────── + // The XML docs name a member by its doc ID ("M:Namespace.Type.Load(System.String,System.Boolean)"), which + // also tells overloads apart. Reflection adds what the ID can't: return type, parameter names, out vs ref, + // defaults. Every public / protected method and constructor gets an entry, and every internal / private one is + // listed in "__nonPublicMethods", so build-api can tell an unusable overload from one whose ID didn't match; + // properties, events and fields only when they are indexers, protected or marked [Obsolete] / hidden from + // IntelliSense. Methods say "protected" in their signature; a property or event gets the flag instead, because + // the XML docs list protected ones with the public (XamGrid.CurrentEditCell, a CS0122 from outside the grid). + // A type that throws here stays out of "__memberTypes", and build-api keeps its members as before. + try { + const BindingFlags declared = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | + BindingFlags.Static | BindingFlags.DeclaredOnly; + var asmName = asm.GetName().Name; + var typeId = DocTypeId(t); + void Add(string docId, MemberInfo m, string? sig, bool isProtected = false) { + try { + var entry = MemberEntry(m, sig); + if (isProtected) entry["protected"] = true; + if (entry.Count > 0) members[$"{asmName}|{docId}"] = entry; + } catch { /* unreadable attributes or signature: the member stays name-only */ } + } + var methods = new List<(string Id, MethodBase M, string Sig)>(); + foreach (var m in t.GetMethods(declared).Cast().Concat(t.GetConstructors(declared))) { + if (m is ConstructorInfo && m.IsStatic) continue; + // Property / event accessors and explicit interface implementations ("IList.Add") aren't called by name. + if (m is MethodInfo && (m.Name.Contains('.') || (m.IsSpecialName && !m.Name.StartsWith("op_", StringComparison.Ordinal)))) continue; + if (!IsUsable(m)) { + try { nonPublicMethods.Add($"{asmName}|{MethodDocId(typeId, m)}"); } catch { /* unreadable: stays unmatched */ } + continue; + } + try { methods.Add((MethodDocId(typeId, m), m, MethodSignature(m))); } catch { /* unreadable signature: name-only */ } + } + // Overloads that differ only in a type's namespace (WorkbookColorInfo(System.Drawing.Color) and + // (System.Windows.Media.Color)) would read the same: those spell their types in full. + var ambiguous = methods.GroupBy(x => x.Sig).Where(g => g.Count() > 1).SelectMany(g => g).Select(x => x.Id).ToHashSet(); + foreach (var (id, m, sig) in methods) { + string full; + try { full = ambiguous.Contains(id) ? MethodSignature(m, qualified: true) : sig; } catch { full = sig; } + Add(id, m, full); + if (!members.TryGetValue($"{asmName}|{id}", out var entry)) continue; + // The names build-api matches the XML's descriptions against, read here rather than parsed + // back out of the signature. + var ps = m.GetParameters(); + if (ps.Length > 0) entry["params"] = ps.Select((p, i) => p.Name ?? $"arg{i}").ToArray(); + try { + if (m is MethodInfo mi && OverriddenTail(mi, id[(typeId.Length + 3)..]) is { } overridden) entry["overrides"] = overridden; + } catch { /* unreadable base method: inherited by overload as before */ } + } + foreach (var p in t.GetProperties(declared)) { + if (!(IsUsable(p.GetMethod) || IsUsable(p.SetMethod)) || p.Name.Contains('.')) continue; + var index = p.GetIndexParameters(); + var id = $"P:{typeId}.{p.Name}" + (index.Length > 0 ? $"({string.Join(",", index.Select(x => ParamId(x.ParameterType)))})" : ""); + // Protected when no accessor is public: a public getter with a protected setter is still readable. + Add(id, p, index.Length > 0 ? IndexerSignature(p, index) : null, + isProtected: !(p.GetMethod?.IsPublic == true || p.SetMethod?.IsPublic == true)); + } + foreach (var e in t.GetEvents(declared)) + if (IsUsable(e.AddMethod) && !e.Name.Contains('.')) Add($"E:{typeId}.{e.Name}", e, null, isProtected: !e.AddMethod!.IsPublic); + foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)) + Add($"F:{typeId}.{f.Name}", f, null); + Add($"T:{typeId}", t, null); // the type itself, when it is [Obsolete] (UnboundField) or hidden + memberTypes.Add(typeKey); + } catch { /* skip inaccessible type: build-api keeps its members name-only */ } + + // ── Enum values, in declaration order ──────────────────────────────── + // The XML docs give each value's summary ("F:Namespace.Enum.Value") but not the order, the numbers or [Flags], + // which tells an agent that values combine. Nested enums too: they get pages like other nested types. + // Their [Obsolete] / hidden flags are the "F:" entries in "__members" above. + if (t.IsEnum) { + try { + var values = t.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Where(f => f.IsLiteral).OrderBy(f => f.MetadataToken) + .Select(f => new Dictionary { ["name"] = f.Name, ["value"] = f.GetRawConstantValue()! }) + .ToArray(); + var info = new Dictionary(); + if (t.IsDefined(typeof(FlagsAttribute), false)) info["flags"] = true; + info["values"] = values; + enums[typeKey] = info; + } catch { /* unreadable enum: its page lists no values */ } + } + if (!topLevel) continue; // ── Base type (only emit when the base is also an Infragistics type) ── @@ -179,16 +276,23 @@ void Note(string name, bool ok) { // ── Properties (DeclaredOnly so we don't duplicate inherited ones) ──── // Static ones too: theme classes expose their styles as static properties (DataPresenterResourceSet`1.CellValuePresenter). + // Protected ones too, which the XML docs list with the public ones (XamGrid.CurrentEditCell, a Cell); they come + // second, so a public indexer keeps a name it shares with a protected overload. try { - var props = t.GetProperties( - BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly); + const BindingFlags props = BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; - foreach (var prop in props) { + foreach (var prop in t.GetProperties(BindingFlags.Public | props)) { try { var entry = BuildPropertyEntry(prop.PropertyType); output[$"{typeKey}.{prop.Name}"] = entry; } catch { /* skip unparseable property */ } } + foreach (var prop in t.GetProperties(BindingFlags.NonPublic | props)) { + if (!(IsUsable(prop.GetMethod) || IsUsable(prop.SetMethod)) || prop.Name.Contains('.')) continue; + try { + if (!output.ContainsKey($"{typeKey}.{prop.Name}")) output[$"{typeKey}.{prop.Name}"] = BuildPropertyEntry(prop.PropertyType); + } catch { /* skip unparseable property */ } + } } catch { /* skip inaccessible type */ } // ── Static key and command fields, used in XAML via {x:Static} ──────── @@ -209,7 +313,18 @@ void Note(string name, bool ok) { output["__baseTypes"] = baseTypes; output["__nonPublicMembers"] = nonPublicMembers; +output["__members"] = members; +output["__nonPublicMethods"] = nonPublicMethods; +output["__memberTypes"] = memberTypes; +output["__enums"] = enums; Console.Error.WriteLine($"[TypeExtractor] phase 2: {output.Count} entries, {baseTypes.Count} base types, {nonPublicMembers.Count} non-public members"); +Console.Error.WriteLine($"[TypeExtractor] phase 2: {members.Count(m => m.Value.ContainsKey("sig"))} signatures, " + + $"{members.Count(m => m.Value.ContainsKey("overrides"))} overrides of a generic base's method, " + + $"{members.Count(m => m.Value.ContainsKey("obsolete"))} obsolete, {members.Count(m => m.Value.ContainsKey("hidden"))} hidden, " + + $"{members.Count(m => m.Value.ContainsKey("protected"))} protected properties / events, " + + $"{nonPublicMethods.Count} non-public methods, {memberTypes.Count} types covered"); +Console.Error.WriteLine($"[TypeExtractor] phase 2: {enums.Count} enums ({enums.Count(e => e.Value.ContainsKey("flags"))} [Flags]), " + + $"{enums.Sum(e => ((Dictionary[])e.Value["values"]).Length)} values"); // ── Non-public types ───────────────────────────────────────────────────────── // The XML docs also document internal and private nested classes (ActivePaneManager, XamFunnelController, …), @@ -349,6 +464,56 @@ string ClrPrefix(string xmlns) { output["__xaml"] = xamlOut; Console.Error.WriteLine($"[TypeExtractor] phase 3: {xamlOut.Count} XAML namespaces ({xamlTypes.Count(x => x.IsClr)} clr-namespace, {unresolved} unresolvable)"); +// ── Phase 4: the controls each ThemeManager theme styles ───────────────────── +// Infragistics.Themes.ThemeManager.ApplicationTheme = new RoyalDarkTheme() styles every control in the theme's +// Mappings (control full name → pack URI of a file in the theme's Themes/ folder), Infragistics.Windows controls such +// as XamDataGrid included. Many of those files name no TargetType for the control — RoyalDark.xamDataPresenter.xaml +// only merges — so the theme's own map is the only source. Mappings is protected +// and filled by ThemeBase's private Initialize(), which ThemeManager calls when the theme is applied. +const string ThemeAssemblyPrefix = "Infragistics.WPF.Themes."; +var themeBase = loaded.Select(a => a.GetType("Infragistics.Themes.ThemeBase")).FirstOrDefault(t => t != null); +var themeInit = themeBase?.GetMethod("Initialize", BindingFlags.Instance | BindingFlags.NonPublic, Type.EmptyTypes); +var themeMappings = themeBase?.GetProperty("Mappings", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); +if (themeBase == null || themeInit == null || themeMappings == null) { + Console.Error.WriteLine("[TypeExtractor] ERROR: Infragistics.Themes.ThemeBase, its Initialize() or its Mappings not found — " + + "the ThemeManager API changed; Phase 4 in scripts/type-extractor/Program.cs needs updating."); + return 1; +} +var themeMaps = new SortedDictionary>(StringComparer.Ordinal); +var notTypes = new SortedSet(StringComparer.Ordinal); +foreach (var asm in loaded.Where(a => a.GetName().Name!.StartsWith(ThemeAssemblyPrefix, StringComparison.OrdinalIgnoreCase))) { + foreach (var t in SafeGetTypes(asm).Where(t => !t.IsAbstract && themeBase.IsAssignableFrom(t))) { + IDictionary map; + try { + var theme = Activator.CreateInstance(t)!; + themeInit.Invoke(theme, null); + map = (IDictionary)themeMappings.GetValue(theme)!; + } catch (Exception ex) { + Console.Error.WriteLine($"[TypeExtractor] ERROR: could not read {t.FullName}'s Mappings: {(ex.InnerException ?? ex).Message}"); + return 1; + } + // Keyed by theme name, which is also its folder in wpf-resources' Themes/ (Infragistics.WPF.Themes.RoyalDark → RoyalDark). + var files = new SortedDictionary(StringComparer.Ordinal); + foreach (DictionaryEntry e in map) { + var control = (string)e.Key; + // Not every key is a type: Infragistics.Themes.MsControls names the file for the standard WPF controls. + if (!knownTypes.ContainsKey(control)) { notTypes.Add(control); continue; } + var uri = (string)e.Value!; + files[control] = uri[(uri.LastIndexOfAny(['/', '\\']) + 1)..]; + } + themeMaps[asm.GetName().Name![ThemeAssemblyPrefix.Length..]] = files; + } +} +if (themeMaps.Count == 0 || themeMaps.Values.Any(m => m.Count == 0)) { + Console.Error.WriteLine($"[TypeExtractor] ERROR: {themeMaps.Count} ThemeManager themes found, " + + $"{themeMaps.Count(m => m.Value.Count == 0)} of them mapping no control — are the Infragistics.WPF.Themes.*.Trial packages " + + "still in nuget/WpfDocs.csproj? Without them setup_wpf_theme shows XamDataGrid & co. as legacy-only."); + return 1; +} +output["__themeMappings"] = themeMaps; +Console.Error.WriteLine($"[TypeExtractor] phase 4: {themeMaps.Count} ThemeManager themes, {themeMaps.Sum(m => m.Value.Count)} control → file " + + $"mappings ({themeMaps.Values.SelectMany(m => m.Keys).Distinct().Count()} controls; keys that are no type, left out: {string.Join(", ", notTypes)})"); + // ── Write JSON ──────────────────────────────────────────────────────────────── Directory.CreateDirectory(Path.GetDirectoryName(outputFile)!); @@ -412,6 +577,154 @@ static bool IsInfragisticsDll(string filename) { // Callable from an app: public, or protected (from a subclass). Not internal, private or private protected. static bool IsUsable(MethodBase? m) => m != null && (m.IsPublic || m.IsFamily || m.IsFamilyOrAssembly); +// ── XML doc IDs (the "name" attribute of each in the XML docs) ────── + +// A type in declaration form: Namespace.Outer`1.Inner +static string DocTypeId(Type t) => + t.IsNested ? $"{DocTypeId(t.DeclaringType!)}.{t.Name}" : (string.IsNullOrEmpty(t.Namespace) ? "" : t.Namespace + ".") + t.Name; + +static string MethodDocId(string typeId, MethodBase m) { + var id = $"M:{typeId}.{(m is ConstructorInfo ? "#ctor" : m.Name)}"; + if (m.IsGenericMethodDefinition) id += $"``{m.GetGenericArguments().Length}"; + var ps = m.GetParameters(); + if (ps.Length > 0) id += $"({string.Join(",", ps.Select(p => ParamId(p.ParameterType)))})"; + if (m.Name is "op_Implicit" or "op_Explicit") id += "~" + ParamId(((MethodInfo)m).ReturnType); + return id; +} + +// For an override of a generic base type's method, the doc ID tail of the method it overrides: the base declares +// "InsertItem(System.Int32,`0)", the override "InsertItem(System.Int32,Ns.Column)", so build-api can't tell from the +// IDs alone that one replaces the other. Null when the tails are the same or the method overrides nothing. +static string? OverriddenTail(MethodInfo m, string ownTail) { + var root = m.GetBaseDefinition(); + if (root.DeclaringType is not { } rootType || rootType == m.DeclaringType) return null; + if (rootType.IsGenericType && !rootType.IsGenericTypeDefinition) { + // The ID is written against the type definition (`0), not the closed base (CollectionBase). + rootType = rootType.GetGenericTypeDefinition(); + root = rootType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | + BindingFlags.DeclaredOnly).First(x => x.MetadataToken == root.MetadataToken && x.Module == root.Module); + } + var rootTypeId = DocTypeId(rootType); + var rootTail = MethodDocId(rootTypeId, root)[(rootTypeId.Length + 3)..]; + return rootTail == ownTail ? null : rootTail; +} + +// A type inside a doc ID's parameter list: generic types as Name{Arg,…} (also a generic type naming itself, which +// reflection returns as its definition), `N / ``N for the type's / method's generic parameters, @ by-ref, * pointer, +// [] and [0:,0:] arrays. +static string ParamId(Type t) { + if (t.IsByRef) return ParamId(t.GetElementType()!) + "@"; + if (t.IsPointer) return ParamId(t.GetElementType()!) + "*"; + if (t.IsArray) return ParamId(t.GetElementType()!) + (t.IsSZArray ? "[]" : $"[{string.Join(",", Enumerable.Repeat("0:", t.GetArrayRank()))}]"); + if (t.IsGenericParameter) return (t.DeclaringMethod != null ? "``" : "`") + t.GenericParameterPosition; + if (!t.IsGenericType) return DocTypeId(t); + // Each nesting level names its own share of the arguments: Outer{`0}.Inner{`1}. + var args = t.GetGenericArguments(); + var chain = new List(); + for (var x = t.IsGenericTypeDefinition ? t : t.GetGenericTypeDefinition(); x != null; x = x.IsNested ? x.DeclaringType : null) chain.Insert(0, x); + var id = string.IsNullOrEmpty(chain[0].Namespace) ? "" : chain[0].Namespace + "."; + var used = 0; + for (var i = 0; i < chain.Count; i++) { + var name = chain[i].Name; + var tick = name.IndexOf('`'); + var own = tick < 0 ? 0 : int.Parse(name[(tick + 1)..], CultureInfo.InvariantCulture); + id += (i > 0 ? "." : "") + (tick < 0 ? name : name[..tick]); + if (own > 0) { id += "{" + string.Join(",", args.Skip(used).Take(own).Select(ParamId)) + "}"; used += own; } + } + return id; +} + +// ── C# signatures, as an app would write the call ──────────────────────────── + +static Dictionary MemberEntry(MemberInfo m, string? sig) { + var entry = new Dictionary(); + if (sig != null) entry["sig"] = sig; + // Read as attribute data: constructing the attributes could fail for a type whose dependencies don't load. + foreach (var a in m.GetCustomAttributesData()) { + if (a.AttributeType.FullName == "System.ObsoleteAttribute") + entry["obsolete"] = a.ConstructorArguments.Count > 0 ? a.ConstructorArguments[0].Value as string ?? "" : ""; + else if (a.AttributeType.FullName == "System.ComponentModel.EditorBrowsableAttribute" && + a.ConstructorArguments.Count > 0 && Convert.ToInt32(a.ConstructorArguments[0].Value, CultureInfo.InvariantCulture) == 1) + entry["hidden"] = true; // EditorBrowsableState.Never + } + return entry; +} + +static string MethodSignature(MethodBase m, bool qualified = false) { + var modifiers = (m.IsPublic ? "" : "protected ") + (m.IsStatic ? "static " : ""); + var ps = FormatParameters(m.GetParameters(), m.IsDefined(typeof(System.Runtime.CompilerServices.ExtensionAttribute), false), qualified); + if (m is ConstructorInfo) return $"{modifiers}{m.DeclaringType!.Name.Split('`')[0]}({ps})"; + var mi = (MethodInfo)m; + var ret = TypeDisplayName(mi.ReturnType, qualified); + if (mi.Name is "op_Implicit" or "op_Explicit") return $"{modifiers}{(mi.Name == "op_Implicit" ? "implicit" : "explicit")} operator {ret}({ps})"; + if (OperatorSymbol(mi.Name) is { } symbol) return $"{modifiers}{ret} operator {symbol}({ps})"; + var generic = mi.IsGenericMethodDefinition ? $"<{string.Join(", ", mi.GetGenericArguments().Select(a => a.Name))}>" : ""; + return $"{modifiers}{ret} {mi.Name}{generic}({ps})"; +} + +static string IndexerSignature(PropertyInfo p, ParameterInfo[] index) => + $"{(p.GetMethod?.IsPublic == true || p.SetMethod?.IsPublic == true ? "" : "protected ")}{TypeDisplayName(p.PropertyType)} this[{FormatParameters(index, false)}]"; + +static string FormatParameters(ParameterInfo[] ps, bool isExtension, bool qualified = false) => string.Join(", ", ps.Select((p, i) => { + var modifier = i == 0 && isExtension ? "this " : ""; + if (p.ParameterType.IsByRef) modifier += p.IsOut ? "out " : p.IsIn ? "in " : "ref "; + else if (p.IsDefined(typeof(ParamArrayAttribute), false)) modifier += "params "; + var text = $"{modifier}{TypeDisplayName(p.ParameterType, qualified)} {p.Name ?? $"arg{i}"}"; + string? value = null; + try { if (p.HasDefaultValue) value = FormatDefault(p); } catch { /* an unreadable default is left out */ } + return value == null ? text : $"{text} = {value}"; +})); + +static string? FormatDefault(ParameterInfo p) { + var v = p.DefaultValue; + var type = Nullable.GetUnderlyingType(p.ParameterType) ?? p.ParameterType; + if (v == null) return p.ParameterType.IsValueType && Nullable.GetUnderlyingType(p.ParameterType) == null ? "default" : "null"; + if (type.IsEnum) { + var name = Enum.GetName(type, v); + return name != null ? $"{TypeDisplayName(type)}.{name}" : $"({TypeDisplayName(type)}){Convert.ToInt64(v, CultureInfo.InvariantCulture)}"; + } + return v switch { + string s => $"\"{s.Replace("\\", "\\\\").Replace("\"", "\\\"")}\"", + bool b => b ? "true" : "false", + char c => $"'{c}'", + IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), + _ => v.ToString(), + }; +} + +// C# spelling of a type: keywords for built-ins, T? for Nullable, Outer.Inner for nested types, no namespaces +// unless qualified. +static string TypeDisplayName(Type t, bool qualified = false) { + if (t.IsByRef) return TypeDisplayName(t.GetElementType()!, qualified); + if (t.IsPointer) return TypeDisplayName(t.GetElementType()!, qualified) + "*"; + if (t.IsArray) return $"{TypeDisplayName(t.GetElementType()!, qualified)}[{new string(',', t.GetArrayRank() - 1)}]"; + if (Nullable.GetUnderlyingType(t) is { } underlying) return TypeDisplayName(underlying, qualified) + "?"; + if (t.IsGenericParameter) return t.Name; + var keyword = t.FullName switch { + "System.String" => "string", "System.Boolean" => "bool", "System.Int32" => "int", "System.Int64" => "long", + "System.Int16" => "short", "System.Byte" => "byte", "System.SByte" => "sbyte", "System.UInt16" => "ushort", + "System.UInt32" => "uint", "System.UInt64" => "ulong", "System.Double" => "double", "System.Single" => "float", + "System.Decimal" => "decimal", "System.Char" => "char", "System.Object" => "object", "System.Void" => "void", + _ => null, + }; + if (keyword != null) return keyword; + static string Plain(Type x) => (x.IsNested ? Plain(x.DeclaringType!) + "." : "") + x.Name.Split('`')[0]; + var tick = t.Name.IndexOf('`'); + var own = tick < 0 ? 0 : int.Parse(t.Name[(tick + 1)..], CultureInfo.InvariantCulture); + var args = own > 0 ? $"<{string.Join(", ", t.GetGenericArguments().TakeLast(own).Select(a => TypeDisplayName(a, qualified)))}>" : ""; + return (qualified && !string.IsNullOrEmpty(t.Namespace) ? t.Namespace + "." : "") + Plain(t) + args; +} + +static string? OperatorSymbol(string name) => name switch { + "op_Addition" => "+", "op_Subtraction" => "-", "op_Multiply" => "*", "op_Division" => "/", "op_Modulus" => "%", + "op_Equality" => "==", "op_Inequality" => "!=", "op_LessThan" => "<", "op_GreaterThan" => ">", + "op_LessThanOrEqual" => "<=", "op_GreaterThanOrEqual" => ">=", "op_UnaryNegation" => "-", "op_UnaryPlus" => "+", + "op_LogicalNot" => "!", "op_OnesComplement" => "~", "op_Increment" => "++", "op_Decrement" => "--", + "op_True" => "true", "op_False" => "false", "op_BitwiseAnd" => "&", "op_BitwiseOr" => "|", "op_ExclusiveOr" => "^", + "op_LeftShift" => "<<", "op_RightShift" => ">>", + _ => null, +}; + static bool IsInfragisticsPublic(Type t) => t.IsPublic && t.FullName != null && diff --git a/scripts/validate-package.ts b/scripts/validate-package.ts index caa1d6b..15bc290 100644 --- a/scripts/validate-package.ts +++ b/scripts/validate-package.ts @@ -31,7 +31,7 @@ const TOOL_DIR = 'tools/net10.0/any/'; const MIN_COMPONENTS = 150; const MIN_SEARCH_ENTRIES = 5000; const MIN_SEARCH_BYTES = 2 * 1024 * 1024; -const MIN_API_FILES = 5000; // ~5,340 public types on 26.1.21 (internal classes are not indexed) +const MIN_API_FILES = 5000; // ~5,190 public type names + ~185 api/alternates/ pages on 26.1.21 (internal classes and ones hidden from IntelliSense are not indexed) const MIN_DOC_TOPICS = 2000; const MIN_NEWER_THEMES = 5; const MIN_LEGACY_FOLDERS = 20; diff --git a/server/Data/DataStore.cs b/server/Data/DataStore.cs index fe8de2f..decf616 100644 --- a/server/Data/DataStore.cs +++ b/server/Data/DataStore.cs @@ -9,6 +9,7 @@ namespace Infragistics.Wpf.Mcp.Data; public sealed class DataStore { private readonly string _apiDir; + private readonly string _altDir; private readonly string _docsDir; private readonly string _themeDir; @@ -22,6 +23,7 @@ public DataStore(string root) { root = Path.GetFullPath(root); _apiDir = Path.Combine(root, "api"); + _altDir = Path.Combine(_apiDir, "alternates"); _docsDir = Path.Combine(root, "docs"); _themeDir = Path.Combine(root, "theme-resources"); @@ -37,6 +39,38 @@ public DataStore(string root) public ApiEntry? LoadApiDoc(string component) => LoadByName(_apiDir, component, WpfJsonContext.Default.ApiEntry); + /// A type by short name ("XamComboEditor": the page that name opens, as LoadApiDoc) or by full name + /// ("Infragistics.Controls.Editors.XamComboEditor"; nested types "Ns.Outer.Inner" or "Ns.Outer+Inner"; plus + /// ", Assembly" for a full name two assemblies have). A full name also opens the types that lost their short name + /// to another (api/alternates/), found through the short name's page, which lists them. When there is no such + /// type, `byShortName` is that page, if any, for the not-found message. + public ApiEntry? LoadApiType(string name, out ApiEntry? byShortName) + { + byShortName = null; + // A short name, looked up exactly as before (no type's short name has a dot or a "+"). + if (!name.Contains('.') && !name.Contains('+')) return LoadApiDoc(name); + + // "Ns.Type, Assembly"; anything after the assembly (", Version=…") is ignored, though a whole assembly-qualified + // name rarely fits the 128 characters get_wpf_api_reference takes. + var parts = name.Split(','); + var fullName = parts[0].Trim().Replace('+', '.'); + var assembly = parts.Length > 1 ? parts[1].Trim() : null; + var shortName = fullName[(fullName.LastIndexOf('.') + 1)..]; + if (shortName.Length == 0 || LoadApiDoc(shortName) is not { } page) return null; + byShortName = page; + + // The page's own type first (a full name two assemblies share opens it), then the others; exact case first. + foreach (var comparison in new[] { StringComparison.Ordinal, StringComparison.OrdinalIgnoreCase }) + { + bool Matches(string typeFullName, string? typeAssembly) => + string.Equals(typeFullName, fullName, comparison) && (assembly is null || string.Equals(typeAssembly, assembly, comparison)); + if (Matches($"{page.DotnetNamespace}.{page.Component}", page.Assembly)) return page; + if (page.Alternates?.FirstOrDefault(a => a.Lookup is not null && Matches(a.FullName, a.Assembly)) is { } other) + return LoadByName(_altDir, other.Lookup!, WpfJsonContext.Default.ApiEntry); + } + return null; + } + public DocEntry? LoadDoc(string slug) => LoadByName(_docsDir, slug, WpfJsonContext.Default.DocEntry); diff --git a/server/Data/Models.cs b/server/Data/Models.cs index 016d93f..c4c4aa7 100644 --- a/server/Data/Models.cs +++ b/server/Data/Models.cs @@ -12,7 +12,8 @@ public sealed record ComponentEntry( string DotnetNamespace, string NugetPackage, string Assembly, - string Description); + string Description, + AlternateType[]? Alternates = null); // other types of this name (the legacy XamComboEditor), opened by full name public sealed record ApiMemberEntry( string Name, @@ -22,9 +23,42 @@ public sealed record ApiMemberEntry( bool? IsEnum, string[]? EnumValues, string? DeclaredOn, - bool? IsField = null); // a static ResourceKey/RoutedCommand field, listed with the properties + bool? IsField = null, // a static ResourceKey/RoutedCommand field, listed with the properties + string? Signature = null, // methods, constructors and indexers: "void Export(OutputFormat outputFormat, string fileName)" + ParamDoc[]? Parameters = null, + bool? IsProtected = null); // a protected property or event (methods say so in their signature) -public sealed record AlternateType(string FullName, string NugetPackage, int MemberCount); +public sealed record ParamDoc(string Name, string Summary); + +/// One value of an enum, in declaration order. +public sealed record EnumValue( + string Name, + long Value, + string Summary, + string? Obsolete = null); // the [Obsolete] message, usually naming the replacement + +/// A member marked [Obsolete]: left out of the member lists, kept so a lookup by name gets the replacement. +public sealed record ObsoleteMember( + string Name, + string Kind, // "property", "event", "method" or "constructor" + string Message, // Infragistics' own, usually naming the replacement + string? Signature = null, + string? DeclaredOn = null); + +/// Another type with the page's short name, or (SameType) the same class listed under another package. +public sealed record AlternateType( + string FullName, + string NugetPackage, + int MemberCount, // properties + events + methods, inherited ones included + string? Assembly = null, + bool? SameType = null, // same assembly and full name as the page's type: no page of its own + string? Lookup = null, // what get_wpf_api_reference takes to open it: the full name (+ ", Assembly") + bool? IsDefault = null, // the type the short name opens + string? XamlNamespace = null, + string? DefaultPrefix = null, + string? Summary = null, // its summary's first sentence + int? ValueCount = null, // enums: how many values, + string[]? ValueNames = null); // and the first few public sealed record ApiEntry( string Component, @@ -40,7 +74,13 @@ public sealed record ApiEntry( ApiMemberEntry[] Properties, ApiMemberEntry[] Events, ApiMemberEntry[] Methods, - string? XamlName = null); // nested types only: "Outer+Inner" + string? XamlName = null, // nested types only: "Outer+Inner" + ApiMemberEntry[]? Constructors = null, // public ones that take parameters + ObsoleteMember[]? Obsolete = null, + string? ObsoleteMessage = null, // the type itself is [Obsolete] + EnumValue[]? Values = null, // enums only + bool? IsFlags = null, // a [Flags] enum + string? Lookup = null); // a type that lost its short name (api/alternates/): what opens this page /// Compact entry in search-index.json — single-letter keys keep the ~5MB index small. public sealed record SearchIndexEntry( @@ -48,7 +88,8 @@ public sealed record SearchIndexEntry( [property: JsonPropertyName("s")] string Summary, [property: JsonPropertyName("a")] string Assembly, [property: JsonPropertyName("p")] string NugetPackage, - [property: JsonPropertyName("m")] string[] Members); + [property: JsonPropertyName("m")] string[] Members, + [property: JsonPropertyName("f")] string? Lookup = null); // types of a shared name: the full name that opens this one public record DocIndexEntry( string Slug, @@ -56,7 +97,8 @@ public record DocIndexEntry( string[] ControlNames, string[] Tags, string Source, - string Summary); + string Summary, + Dictionary? Mentions = null); // Xam* names the topic mentions, lowercased → count public sealed record DocEntry( string Slug, @@ -65,8 +107,9 @@ public sealed record DocEntry( string[] Tags, string Source, string Summary, - string Body, - string[] XamlSnippets) : DocIndexEntry(Slug, Title, ControlNames, Tags, Source, Summary); + string Body, // Markdown: # headings, code in ``` fences + string[] XamlSnippets, + Dictionary? Mentions = null) : DocIndexEntry(Slug, Title, ControlNames, Tags, Source, Summary, Mentions); public sealed record ThemeResourceFile(string Path, string File, string[] TargetTypes); diff --git a/server/README.md b/server/README.md index d08b95d..c873cbc 100644 --- a/server/README.md +++ b/server/README.md @@ -2,7 +2,7 @@ -MCP server for **Infragistics Ultimate UI for WPF** — component registry, XAML namespace lookup, full API reference with property types and enum values, keyword search across 5,300+ public types, 2,600+ documentation topics, and named-theme setup with verified recolor steps. Everything an AI coding agent needs to write correct Infragistics XAML instead of guessing. +MCP server for **Infragistics Ultimate UI for WPF** — component registry, XAML namespace lookup, full API reference with property types, enum values and method signatures, keyword search across 5,100+ public types, 2,300+ documentation topics, and named-theme setup with verified recolor steps. Everything an AI coding agent needs to write correct Infragistics XAML instead of guessing. Packaged as a `dotnet tool`. All Infragistics data ships inside the package; nothing is downloaded at runtime, and every tool is read-only. @@ -91,12 +91,12 @@ To use `dnx` instead of a global install, set `"command": "dnx"` and `"args": [" | `list_wpf_components` | List all Xam* controls with canonical XAML namespace URIs, NuGet packages, and descriptions. Always call this first before writing XAML. | | `get_wpf_project_scaffold` | Generate ready-to-run `dotnet new` + `dotnet add package` + `dotnet restore` commands and xmlns declarations for a new WPF project. | | `search_wpf_api` | Search across all types by keyword — matches type names, summaries, and member names. Use when you don't know the exact type name. | -| `get_wpf_api_reference` | Full API reference for any type: properties with types and enum values, events, methods, and inherited members grouped by base class. Large types are compacted to stay under ~25K characters (events and methods first, then inherited members, are listed by name only); the optional `member` filter (case-insensitive name substring, e.g. `"YAxis"`) returns full details for just the members you need. | +| `get_wpf_api_reference` | Full API reference for any type: properties with types and enum values, constructors and methods with the C# signature of every overload, events, and inherited members grouped by base class. Obsolete members are left out; looking one up returns its replacement. Large types are compacted to stay under ~25K characters (events and methods first, then inherited members, are listed by name only); the optional `member` filter (case-insensitive name substring, e.g. `"YAxis"`) returns full details, including parameter descriptions, for just the members you need. | | `setup_wpf_theme` | List available named themes and the raw XAML files backing them, covering both theming mechanisms (legacy embedded-BAML `Theme="..."` and newer `ThemeManager`). Returns ready-to-paste apply steps. | | `get_wpf_theme_resource` | Retrieve the full raw XAML of one theme/style resource-dictionary file (real `Style`/`ControlTemplate`/brush definitions) to copy and override. Large files come in pages that concatenate to the exact file; `find` returns single styles, templates or brushes in full. | | `get_wpf_theme_palette` | Return a newer-family (ThemeManager) theme's color/brush palette — exact resource keys and values — plus verified steps to recolor that theme (control-level overrides or an extended theme) without creating a new one. | -| `search_wpf_docs` | Search how-to documentation topics by keyword and/or control name — layouts, styling, data binding, filtering/sorting/grouping, exporting, performance, known issues, etc. | -| `get_wpf_doc` | Full text (including XAML samples) of one documentation topic by slug. | +| `search_wpf_docs` | Search how-to documentation topics by keyword and/or control name — layouts, styling, data binding, filtering/sorting/grouping, exporting, performance, known issues, etc. A control name also finds the topics that cover it under a family (e.g. `XamMaskedInput` under xamInputs). | +| `get_wpf_doc` | Full text of one documentation topic by slug, with its XAML, C# and VB samples; topics over ~24K characters come in pages. | All tools are annotated `readOnlyHint: true`, `openWorldHint: false` — none of them modify your project, filesystem, or any external system. diff --git a/server/ServerInstructions.cs b/server/ServerInstructions.cs index 0df0def..cea2d34 100644 --- a/server/ServerInstructions.cs +++ b/server/ServerInstructions.cs @@ -14,7 +14,7 @@ public static class ServerInstructions 3. Whenever the task needs HOW-TO guidance beyond "what members exist" — layouts/nesting, styling/theming, data binding, editing/validation, filtering/sorting/grouping/summaries, exporting, performance, commands, known issues, etc. — call search_wpf_docs(query, control: component), passing the SAME component name from step 1/2 to scope the search. 4. get_wpf_doc(slug) on the most relevant result from step 3 to read the full XAML example or how-to text before writing any code or giving usage advice. 5. For new projects, get_wpf_project_scaffold(components) after step 1 for dotnet CLI + xmlns setup, then still run steps 2-4 per component before writing real XAML. - 6. Establish the visual theme EARLY — treat it as a default step for any new window/app, not an optional afterthought. Call setup_wpf_theme(component?, theme?) to pick a named theme and get the exact ready-to-paste apply steps (NuGet package + App.xaml.cs ThemeManager call for the newer family, or Theme="" for the legacy family) plus the resource file paths; then get_wpf_theme_resource(path) only if you need to copy/override a specific Style/ControlTemplate (find: "" returns just that entry; large files come in pages). When the goal is to RE-COLOR an existing theme (brand colors, a different accent) rather than restyle one control, call get_wpf_theme_palette(theme) for that theme's colors plus the verified recolor steps — overriding the palette keys themselves does NOT recolor a theme. + 6. Establish the visual theme EARLY — treat it as a default step for any new window/app, not an optional afterthought. Call setup_wpf_theme(component?, theme?) to pick a named theme and get the exact ready-to-paste apply steps (NuGet package + App.xaml.cs ThemeManager call, which themes the newer controls and most legacy ones such as XamDataGrid too; or Theme="" on one legacy control) plus the resource file paths; then get_wpf_theme_resource(path) only if you need to copy/override a specific Style/ControlTemplate (find: "" returns just that entry; large files come in pages). When the goal is to RE-COLOR an existing theme (brand colors, a different accent) rather than restyle one control, call get_wpf_theme_palette(theme) for that theme's colors plus the verified recolor steps — overriding the palette keys themselves does NOT recolor a theme. ALWAYS call list_wpf_components/search_wpf_api before writing any XAML to get the correct xmlns namespace URI; wrong values cause immediate compile errors. ALWAYS establish a theme via setup_wpf_theme (then get_wpf_theme_palette to recolor if needed) BEFORE writing any Style, Setter, Brush, or color attribute on an Infragistics control. Do NOT hand-author a color scheme (dark, neon, corporate, etc.) to approximate a look — a named theme plus the verified recolor steps from get_wpf_theme_palette is the correct, lower-maintenance path. diff --git a/server/Tools/ComponentTools.cs b/server/Tools/ComponentTools.cs index e36f7f1..000deb9 100644 --- a/server/Tools/ComponentTools.cs +++ b/server/Tools/ComponentTools.cs @@ -79,6 +79,10 @@ static double MatchScore(ComponentEntry c, string needle) $"- **Assembly:** `{c.Assembly}`", $"- {c.Description}", }; + // Another control of this name (the legacy Infragistics.Windows.Editors.XamComboEditor), which only its full name opens. + line.AddRange((c.Alternates ?? []).Select(a => + $"- ⚠️ Not the only {c.Component}: `{a.FullName}` is another (NuGet `{a.NugetPackage}`, `xmlns:{a.DefaultPrefix}=\"{a.XamlNamespace}\"`; " + + $"get_wpf_api_reference(\"{a.Lookup}\")). The name alone means the one above in get_wpf_api_reference and get_wpf_project_scaffold.")); if (ThemeLookup.IsThemeable(c.Component, store.ThemeIndex)) line.Add($"- 🎨 Ships with named themes — call `setup_wpf_theme(component: \"{c.Component}\")` before writing style/theme overrides."); return string.Join('\n', line); @@ -93,17 +97,41 @@ static double MatchScore(ComponentEntry c, string needle) // ── get_wpf_api_reference ──────────────────────────────────────────────── - private static string FormatMember(ApiMemberEntry m) + // A Markdown code span, double-fenced when the text has a backtick of its own (a generic type's "EnumWrapper`1"), + // which would otherwise end the span and cut a call in two. + private static string Code(string text) => text.Contains('`') ? $"`` {text} ``" : $"`{text}`"; + + // Which type of a shared name the name alone opens (familyRank in scripts/build-api.ts). + private const string DefaultTypeRule = + "the type of the control family Infragistics recommends (never a retired control's, such as XamGrid's or the xamInputs'; " + + "a newer Infragistics.Controls / Infragistics.Themes copy before the legacy Infragistics.Windows one), else the most fully documented one"; + + // One line per other type of the page's short name: the call that opens it, then what tells them apart. + private static string FormatAlternate(AlternateType a, string shortName) { - var line = $"- **{m.Name}**"; - if (!string.IsNullOrEmpty(m.TypeName)) - { - line += $" `{m.TypeName}`"; - if (m.IsEnum == true && m.EnumValues is { Length: > 0 }) - line += $" — values: `{string.Join("` | `", m.EnumValues)}`"; - } + var size = a.ValueCount is { } count + ? $"{count} values ({string.Join(", ", (a.ValueNames ?? []).Select(v => $"`{v}`"))}{(count > (a.ValueNames?.Length ?? 0) ? ", …" : "")})" + : $"{a.MemberCount} members"; + var xmlns = a.XamlNamespace is { } ns ? $", `xmlns:{a.DefaultPrefix}=\"{ns}\"`" : ""; + var isDefault = a.IsDefault == true ? $" (what \"{shortName}\" alone returns)" : ""; + return $"- {Code($"get_wpf_api_reference(\"{a.Lookup}\")")}{isDefault} — {size}, NuGet `{a.NugetPackage}`{xmlns}: {(string.IsNullOrEmpty(a.Summary) ? "—" : a.Summary)}"; + } + + private static string FormatMember(ApiMemberEntry m, bool withParameters) + { + // A signature names the type itself: "void Export(OutputFormat outputFormat)", "double this[int index]", "protected" + // and "int?" included. Otherwise a Nullable comes as TypeName T with IsNullable set. + var line = m.Signature is { } signature ? $"- `{signature}`" : $"- **{m.Name}**"; + if (m.Signature is null && !string.IsNullOrEmpty(m.TypeName)) line += $" `{m.TypeName}{(m.IsNullable == true ? "?" : "")}`"; + // The XML docs list protected properties and events with the public ones (XamGrid.CurrentEditCell): CS0122 outside a subclass. + if (m.Signature is null && m.IsProtected == true) line += " _(protected)_"; + if (m.IsEnum == true && m.EnumValues is { Length: > 0 }) + line += $" — values: `{string.Join("` | `", m.EnumValues)}`"; if (m.IsField == true) line += " _(static field)_"; line += $": {(string.IsNullOrEmpty(m.Summary) ? "—" : m.Summary)}"; + // Only with `member`: parameter descriptions tell overloads apart, but would double the size of a full listing. + if (withParameters && m.Parameters is { Length: > 0 }) + line += string.Concat(m.Parameters.Select(p => $"\n - `{p.Name}`: {p.Summary}")); return line; } @@ -115,7 +143,10 @@ private static string FormatMember(ApiMemberEntry m) // How much detail the member sections get, tried in this order; the first rendering that fits wins. // Properties keep full detail the longest because they are what XAML needs (types, enum values): // on the grids nearly every property is inherited, so compacting inherited members first would - // hide exactly those. Events and methods are one `member` call away. + // hide exactly those. Events and methods are one `member` call away. Before the next level, a level is tried + // with its protected properties and events by name only (protectedByName): only a subclass can use them, and + // their types and markers would push the kind "properties" responses of 21 types (20 chart series and SeriesViewer) + // past 25K and down to CompactInherited or lower. private enum ApiDetail { Full, CompactEventsMethods, CompactInherited, CompactAll } private static bool IsCompact(ApiDetail detail, string kindKey, bool inherited) => detail switch @@ -126,21 +157,46 @@ private enum ApiDetail { Full, CompactEventsMethods, CompactInherited, CompactAl _ => true, }; - private static string CompactionSummary(ApiDetail detail) => detail switch + private static string CompactionSummary(ApiDetail detail, bool protectedByName) => (detail, protectedByName) switch { - ApiDetail.CompactEventsMethods => "events and methods are listed by name only (properties keep full detail)", - ApiDetail.CompactInherited => "inherited members are listed by name only (members declared on this type keep full detail)", + (ApiDetail.Full, _) => "protected properties and events are listed by name only (public members keep full detail)", + (ApiDetail.CompactEventsMethods, false) => "events and methods are listed by name only (properties keep full detail)", + (ApiDetail.CompactEventsMethods, true) => "events, methods and protected properties are listed by name only (public properties keep full detail)", + (ApiDetail.CompactInherited, false) => "inherited members are listed by name only (members declared on this type keep full detail)", + (ApiDetail.CompactInherited, true) => "inherited members and protected properties and events are listed by name only (public members declared on this type keep full detail)", _ => "all members are listed by name only", }; - // Compact rendering: one line of names per section; details come from a `member` call. - private static string FormatMemberNames(IEnumerable items) => - string.Join(", ", items.Select(m => $"`{m.Name}`")); + // Properties and events carry the flag; methods (and indexers) say "protected" in their signature. + private static bool IsProtected(ApiMemberEntry m) => + m.Signature is { } signature ? signature.StartsWith("protected ", StringComparison.Ordinal) : m.IsProtected == true; - private static List RenderMemberSections((ApiMemberEntry m, string kind)[] members, ApiDetail detail) + // What the protectedByName levels list by name: protected properties and events, whose full lines carry a type and a + // marker. A protected method's full line is its signature, which says so already. + private static bool IsProtectedMember(ApiMemberEntry m) => m.Signature is null && m.IsProtected == true; + + // Compact rendering: one line of names per section, an overloaded name once with its count; details come from a `member` call. + // Protected members come last, after "protected:", so a name-only list doesn't pass them off as public; a method name + // only when every overload listed is protected (one public overload makes the name callable). + private static string FormatMemberNames(IEnumerable items) + { + static string Names(IEnumerable> groups) => + string.Join(", ", groups.Select(g => g.Count() > 1 ? $"`{g.Key}` ({g.Count()})" : $"`{g.Key}`")); + var byName = items.GroupBy(m => m.Name).ToLookup(g => g.All(IsProtected)); + var open = Names(byName[false]); + if (!byName.Contains(true)) return open; + return $"{open}{(open.Length > 0 ? "; " : "")}protected: {Names(byName[true])}"; + } + + private static List RenderMemberSections((ApiMemberEntry m, string kind)[] members, ApiDetail detail, bool protectedByName, bool withParameters) { var output = new List(); - foreach (var (label, kindKey) in new[] { ("Properties", "property"), ("Events", "event"), ("Methods", "method") }) + string Format(ApiMemberEntry m) => FormatMember(m, withParameters); + IEnumerable Detailed(ApiMemberEntry[] group) => + protectedByName && group.Any(IsProtectedMember) + ? group.Where(m => !IsProtectedMember(m)).Select(Format).Append($"- {FormatMemberNames(group.Where(IsProtectedMember))}") + : group.Select(Format); + foreach (var (label, kindKey) in new[] { ("Constructors", "constructor"), ("Properties", "property"), ("Events", "event"), ("Methods", "method") }) { var items = members.Where(m => m.kind == kindKey).Select(m => m.m).ToArray(); if (items.Length == 0) continue; @@ -154,36 +210,61 @@ private static List RenderMemberSections((ApiMemberEntry m, string kind) output.Add(""); output.Add($"### {label}"); if (IsCompact(detail, kindKey, inherited: false)) output.Add(FormatMemberNames(direct)); - else output.AddRange(direct.Select(FormatMember)); + else output.AddRange(Detailed(direct)); } foreach (var group in inherited) { output.Add(""); - output.Add($"### {label} (inherited from `{group.Key}`)"); + output.Add($"### {label} (inherited from {Code(group.Key)})"); if (IsCompact(detail, kindKey, inherited: true)) output.Add(FormatMemberNames(group)); - else output.AddRange(group.Select(FormatMember)); + else output.AddRange(Detailed(group.ToArray())); } } return output; } + // An enum's values in declaration order, with their numbers: "- **XPS** = 0: Exports to XPS format". A [Flags] enum + // says how its values combine, with two of its own single-bit values as the example (without one when it has fewer: + // LicenseOptions). Compact: names only (UTMZone has 1,197 values). + private static List RenderValues(ApiEntry doc, EnumValue[] values, bool compact) + { + if (values.Length == 0) return []; + var output = new List { "", "### Values" }; + if (doc.IsFlags == true) + { + var pair = values.Where(v => v.Obsolete is null && v.Value > 0 && (v.Value & (v.Value - 1)) == 0).Take(2).ToArray(); + output.Add(pair is [var a, var b] + ? $"_[Flags] enum: values combine — `{doc.Component}.{a.Name} | {doc.Component}.{b.Name}` in C#, `\"{a.Name}, {b.Name}\"` in a XAML attribute._" + : "_[Flags] enum: values combine with `|` in C#, comma-separated in a XAML attribute._"); + } + if (compact) output.Add(string.Join(", ", values.Select(v => $"`{v.Name}`"))); + else output.AddRange(values.Select(v => + $"- **{v.Name}** = {v.Value}: {(string.IsNullOrEmpty(v.Summary) ? "—" : v.Summary)}" + + (v.Obsolete is { } message ? $" ⚠ OBSOLETE: {(string.IsNullOrEmpty(message) ? "don't use it." : message)}" : ""))); + return output; + } + [McpServerTool(Name = "get_wpf_api_reference", ReadOnly = true, Idempotent = true, OpenWorld = false)] [Description(ToolDescriptions.GetWpfApiReference)] public CallToolResult GetApiReference( [Description(""" - Any public Infragistics WPF type name — 5,300+ types including controls (e.g. "XamDataGrid"), + Any public Infragistics WPF type name — 5,100+ types including controls (e.g. "XamDataGrid"), supporting types (e.g. "FieldLayout", "SummaryDefinition"), enums, strategies, indicators, and more. Case-insensitive. To find a Xam* control name use list_wpf_components. To discover any other type name use search_wpf_api. + Or a full name (Namespace.Type), e.g. "Infragistics.Windows.Editors.XamComboEditor": a name several types share + returns the one of the control family Infragistics recommends (never a retired control's; a newer copy before + a legacy Infragistics.Windows one; else the most fully documented) and lists the others with the full name that opens each. """)] [MinLength(1), MaxLength(128)] string component, - [Description("""Member category to return. Defaults to "all". Use a specific category when the user asks only about properties, methods, or events.""")] + [Description("""Member category to return. Defaults to "all". Use a specific category when the user asks only about properties, methods, or events. An enum's values come back with any kind.""")] MemberKind kind = MemberKind.all, [Description(""" Optional member name filter — case-insensitive substring, combinable with "kind". - Returns only the matching members, including inherited ones, with full detail (type, enum values, summary). + Returns only the matching members, including inherited ones, with full detail (type, enum values, summary; + for methods and constructors the signature of every overload and its parameter descriptions). Examples: "YAxis" (every YAxis* member), "FieldLayout", "Changed". Use it to check specific members on a large type, - or to get details of members a compacted response lists by name only. + or to get details of members a compacted response lists by name only. On an enum it filters the values. """)] [MaxLength(128)] string? member = null) @@ -197,16 +278,24 @@ or to get details of members a compacted response lists by name only. } var memberFilter = member?.Trim(); - var doc = store.LoadApiDoc(component); + var doc = store.LoadApiType(component, out var byShortName); if (doc is null) { var known = string.Join(", ", store.Components.Select(c => c.Component)); - var msg = $"Component \"{component}\" not found. Known Xam* controls: {known}\n\nNote: Supporting types (FieldLayout, SummaryDefinition, etc.) are also available — use the exact type name."; + // A full name whose short name has a page: the types of that name, as the page lists them. + var msg = byShortName is { } page + ? $"Type \"{component}\" not found. The Infragistics type(s) named \"{page.Component}\" — pass one of these exactly:\n" + + string.Join('\n', new[] { $"- {Code($"get_wpf_api_reference(\"{page.DotnetNamespace}.{page.Component}\")")} (what \"{page.Component}\" alone returns) — NuGet `{page.NugetPackage}`" } + .Concat((page.Alternates ?? []).Where(a => a.Lookup is not null).Select(a => $"- {Code($"get_wpf_api_reference(\"{a.Lookup}\")")} — NuGet `{a.NugetPackage}`"))) + : $"Component \"{component}\" not found. Known Xam* controls: {known}\n\nNote: Supporting types (FieldLayout, SummaryDefinition, etc.) are also available — use the exact type name."; log.Write("get_wpf_api_reference", input, msg, timer); return ToolResult.Error(msg); } + // What opens this page again, for the hints below: the short name, or for a type that lost it, its full name. + var self = doc.Lookup ?? doc.Component; - var allMembers = doc.Properties.Select(m => (m, kind: "property")) + var allMembers = (doc.Constructors ?? []).Select(m => (m, kind: "constructor")) + .Concat(doc.Properties.Select(m => (m, kind: "property"))) .Concat(doc.Events.Select(m => (m, kind: "event"))) .Concat(doc.Methods.Select(m => (m, kind: "method"))) .ToArray(); @@ -217,15 +306,16 @@ or to get details of members a compacted response lists by name only. MemberKind.methods => "method", _ => null, }; - var members = wanted is null ? allMembers : allMembers.Where(m => m.kind == wanted).ToArray(); + // kind "methods" covers constructors too. + bool IsWanted(string memberKind) => wanted is null || memberKind == wanted || (wanted == "method" && memberKind == "constructor"); + var members = allMembers.Where(m => IsWanted(m.kind)).ToArray(); var summary = string.IsNullOrEmpty(doc.Summary) ? "_(no summary)_" : doc.Summary; - var output = new List - { - $"## {doc.Component}", - "", - $"**xmlns:** `xmlns:{doc.DefaultPrefix}=\"{doc.XamlNamespace}\"`", - }; + var output = new List { $"## {doc.Component}", "" }; + // Old code and docs still use some [Obsolete] types (3 topics use UnboundField): say so before anything else. + if (doc.ObsoleteMessage is { } obsoleteMessage) + output.AddRange([$"⚠ **OBSOLETE type:** {(string.IsNullOrEmpty(obsoleteMessage) ? "don't use it in new code." : obsoleteMessage)}", ""]); + output.Add($"**xmlns:** `xmlns:{doc.DefaultPrefix}=\"{doc.XamlNamespace}\"`"); if (doc.XamlName is { } xamlName) output.Add($"**XAML name:** `{doc.DefaultPrefix}:{xamlName}` (nested class)"); // clr-namespace names the assembly, and only the .NET (8+) builds are called Infragistics.WPF.*. @@ -241,13 +331,28 @@ or to get details of members a compacted response lists by name only. summary, }); - if (doc.Alternates is { Length: > 0 }) + // The other types of this short name (an agent on XamDataGrid got XamGrid's SelectionType), each with the call that + // opens it — its full name — and enough to choose: size, package, xmlns, what it is. + var others = (doc.Alternates ?? []).Where(a => a.SameType != true).ToArray(); + if (others.Length > 0) { var memberCount = doc.Properties.Length + doc.Events.Length + doc.Methods.Length; + // Member counts say nothing about enums, which have values instead (every one would read "0 members"). + var count = doc.Values is { } enumValues ? $"{enumValues.Length} values" : $"{memberCount} members"; + var which = doc.Lookup is null + ? $"This page documents {Code($"{doc.DotnetNamespace}.{doc.Component}")} ({count}), which \"{doc.Component}\" alone returns: {DefaultTypeRule}." + : $"This page documents {Code(doc.Lookup)} ({count}); \"{doc.Component}\" alone returns the one marked below: {DefaultTypeRule}."; + output.Add(""); + output.Add($"⚠️ **\"{doc.Component}\" is ambiguous** — {others.Length} other Infragistics type(s) share this name, each in a different namespace/package with its own xmlns. {which} To open another, pass its full name exactly as given:"); + output.AddRange(others.Select(a => FormatAlternate(a, doc.Component))); + output.Add($"If the {(doc.Values is null ? "members" : "values")} below don't match the control you're using, you're likely on another type — confirm which package/xmlns your project references before writing XAML."); + } + // The same class listed under a second package (its DLL shipped in two; none with the 26.1 packages): not another + // type, so no page of its own. + if ((doc.Alternates ?? []).Where(a => a.SameType == true).Select(a => $"`{a.NugetPackage}`").ToArray() is { Length: > 0 } packages) + { output.Add(""); - output.Add($"⚠️ **\"{doc.Component}\" is ambiguous** — {doc.Alternates.Length} other Infragistics type(s) share this name, each in a different namespace/package with its own xmlns. This page documents `{doc.DotnetNamespace}.{doc.Component}` ({memberCount} members), the most fully documented one. The others are not indexed separately:"); - output.AddRange(doc.Alternates.Select(a => $"- `{a.FullName}` — {a.MemberCount} members, NuGet `{a.NugetPackage}`")); - output.Add("If the members below don't match the control you're using, you're likely on the other type — confirm which package/xmlns your project references before writing XAML."); + output.Add($"_The same class, from the same assembly, is also listed under NuGet {string.Join(", ", packages)}._"); } if (!string.IsNullOrEmpty(doc.Remarks)) @@ -258,7 +363,18 @@ or to get details of members a compacted response lists by name only. } var kindPrefix = kind == MemberKind.all ? "" : kind + " "; - if (!string.IsNullOrEmpty(memberFilter)) + // An enum has values instead of members: listed with any `kind` (hiding them for kind "properties" would bring + // back the "no members" answer), filtered by name with `member`. + var values = doc.Values; + if (!string.IsNullOrEmpty(memberFilter) && values is not null) + { + values = values.Where(v => v.Name.Contains(memberFilter, StringComparison.OrdinalIgnoreCase)).ToArray(); + output.Add(""); + output.Add(values.Length > 0 + ? $"_Showing {values.Length} {(values.Length == 1 ? "value" : "values")} whose name contains \"{memberFilter}\"._" + : $"_No values whose name contains \"{memberFilter}\". Call get_wpf_api_reference(\"{self}\") without \"member\" to see every value._"); + } + else if (!string.IsNullOrEmpty(memberFilter)) { members = members.Where(m => m.m.Name.Contains(memberFilter, StringComparison.OrdinalIgnoreCase)).ToArray(); var (one, many) = kind switch @@ -268,14 +384,26 @@ or to get details of members a compacted response lists by name only. MemberKind.methods => ("method", "methods"), _ => ("member", "members"), }; + // [Obsolete] members aren't listed, but old code and docs still use them (8 topics set HorizontalZoomable): + // a lookup by name gets Infragistics' own note, which usually names the replacement, instead of "not found". + var obsolete = (doc.Obsolete ?? []) + .Where(o => IsWanted(o.Kind) && o.Name.Contains(memberFilter, StringComparison.OrdinalIgnoreCase)).ToArray(); output.Add(""); output.Add(members.Length > 0 ? $"_Showing {members.Length} {(members.Length == 1 ? one : many)} whose name contains \"{memberFilter}\", including inherited ones._" - : $"_No {many} whose name contains \"{memberFilter}\", including inherited ones. Call get_wpf_api_reference(\"{doc.Component}\") without \"member\" to see every member name, or search_wpf_api(\"{memberFilter}\") to find which type declares it._"); + : obsolete.Length > 0 + ? $"_No current {many} whose name contains \"{memberFilter}\"; the matching obsolete ones:_" + : $"_No {many} whose name contains \"{memberFilter}\", including inherited ones. Call get_wpf_api_reference(\"{self}\") without \"member\" to see every member name, or search_wpf_api(\"{memberFilter}\") to find which type declares it._"); + foreach (var o in obsolete) + { + var what = o.Signature is { } signature ? $"`{signature}`" : $"`{doc.Component}.{o.Name}`"; + var from = o.DeclaredOn is { } declaredOn ? $" (inherited from {Code(declaredOn)})" : ""; + output.Add($"- ⚠ OBSOLETE {o.Kind} {what}{from}: {(string.IsNullOrEmpty(o.Message) ? "don't use it." : o.Message)}"); + } } var footer = new List(); - if (members.Length == 0 && string.IsNullOrEmpty(memberFilter)) + if (members.Length == 0 && string.IsNullOrEmpty(memberFilter) && values is null) { var baseHint = !string.IsNullOrEmpty(doc.BaseType) ? $"Call get_wpf_api_reference(\"{doc.BaseType}\") to see the inherited API surface." @@ -285,8 +413,9 @@ or to get details of members a compacted response lists by name only. footer.Add($"_This type's own API surface is empty or was filtered by \"kind\". {baseHint}_"); } - // Theming hint only when the type has Brush-typed members AND ships with named themes. - var brushMembers = allMembers.Where(m => m.m.TypeName?.Contains("Brush", StringComparison.Ordinal) == true).Select(m => m.m).ToArray(); + // Theming hint only when the response lists Brush-typed members (not on a `member: "Axes"` lookup) AND the type + // ships with named themes. Not protected ones (XamChart3D.CrosshairRamp): an app can't set those. + var brushMembers = members.Where(m => m.m.IsProtected != true && m.m.TypeName?.Contains("Brush", StringComparison.Ordinal) == true).Select(m => m.m).ToArray(); if (brushMembers.Length > 0 && ThemeLookup.IsThemeable(doc.Component, store.ThemeIndex)) { var examples = string.Join(", ", brushMembers.Take(3).Select(m => $"`{m.Name}`")); @@ -294,25 +423,42 @@ or to get details of members a compacted response lists by name only. footer.Add($"_⚠️ This type has Brush-typed properties (e.g. {examples}) and ships with named themes. Before setting these directly or writing Style/ControlTemplate overrides, call `setup_wpf_theme(component: \"{doc.Component}\")` to check whether a named theme (`Theme=\"...\"` or `ThemeManager`) already covers this, and to get exact resource file paths for customizing it._"); } - string Render(ApiDetail detail, string? note = null) => string.Join('\n', - (note is null ? output : output.Append("").Append(note)).Concat(RenderMemberSections(members, detail)).Concat(footer)); + var withParameters = !string.IsNullOrEmpty(memberFilter); + string Render(ApiDetail detail, string? note = null, bool protectedByName = false) => string.Join('\n', + (note is null ? output : output.Append("").Append(note)) + .Concat(values is null ? [] : RenderValues(doc, values, compact: detail == ApiDetail.CompactAll)) + .Concat(RenderMemberSections(members, detail, protectedByName, withParameters)).Concat(footer)); var text = Render(ApiDetail.Full); // Size guard: take the first level of ApiDetail whose rendering, note included, fits. Headings // stay, so "inherited from" remains visible; the note tells the agent how to get full details. - if (text.Length > MaxApiReferenceChars) + // An enum (no members) only has the last level: its values by name. + if (text.Length > MaxApiReferenceChars && values is not null) + { + var next = string.IsNullOrEmpty(memberFilter) + ? $"Call get_wpf_api_reference(component: \"{self}\", member: \"\") for their numbers and summaries — \"member\" is a case-insensitive substring of the value name." + : "Call again with a more specific \"member\" for their numbers and summaries."; + text = Render(ApiDetail.CompactAll, $"_ℹ️ Compacted: the full response would be {text.Length} characters, so values are listed by name only. {next}_"); + } + else if (text.Length > MaxApiReferenceChars) { var fullLength = text.Length; var next = string.IsNullOrEmpty(memberFilter) - ? $"Call get_wpf_api_reference(component: \"{doc.Component}\", member: \"\") for full details (type, enum values, summary) — \"member\" is a case-insensitive substring, so one call can cover a family of related members sharing a prefix." - : "Call again with a more specific \"member\" for full details (type, enum values, summary)."; - string Compacted(ApiDetail detail) => - Render(detail, $"_ℹ️ Compacted: the full response would be {fullLength} characters, so {CompactionSummary(detail)}. {next}_"); - // Without properties (kind: events/methods) the first level would only cost the members declared on this type their detail. - var levels = members.Any(m => m.kind == "property") - ? new[] { ApiDetail.CompactEventsMethods, ApiDetail.CompactInherited } - : new[] { ApiDetail.CompactInherited }; - text = levels.Select(Compacted).FirstOrDefault(t => t.Length <= MaxApiReferenceChars) ?? Compacted(ApiDetail.CompactAll); + ? $"Call get_wpf_api_reference(component: \"{self}\", member: \"\") for full details (type, enum values, summary, every overload's signature) — \"member\" is a case-insensitive substring, so one call can cover a family of related members sharing a prefix." + : "Call again with a more specific \"member\" for full details (type, enum values, summary, every overload's signature)."; + string Compacted((ApiDetail detail, bool protectedByName) level) => + Render(level.detail, $"_ℹ️ Compacted: the full response would be {fullLength} characters, so {CompactionSummary(level.detail, level.protectedByName)}. {next}_", level.protectedByName); + // Without properties (kind: events/methods) CompactEventsMethods would only cost the members declared on this type their detail. + var details = members.Any(m => m.kind == "property") + ? new[] { ApiDetail.Full, ApiDetail.CompactEventsMethods, ApiDetail.CompactInherited } + : new[] { ApiDetail.Full, ApiDetail.CompactInherited }; + bool ShowsProtectedInFull(ApiDetail detail) => + members.Any(m => IsProtectedMember(m.m) && !IsCompact(detail, m.kind, inherited: !string.IsNullOrEmpty(m.m.DeclaredOn))); + // Each level as it is (Full has been tried), then with its protected properties and events by name if it shows any in full. + var levels = details + .SelectMany(detail => new[] { (detail, protectedByName: false), (detail, protectedByName: true) }) + .Where(level => level.protectedByName ? ShowsProtectedInFull(level.detail) : level.detail != ApiDetail.Full); + text = levels.Select(Compacted).FirstOrDefault(t => t.Length <= MaxApiReferenceChars) ?? Compacted((ApiDetail.CompactAll, false)); } log.Write("get_wpf_api_reference", input, text, timer); @@ -332,6 +478,8 @@ public CallToolResult GetProjectScaffold( List of Infragistics WPF component names to include in the project. Call list_wpf_components first to resolve exact names. Examples: ["XamDataGrid", "XamCategoryChart"], ["XamDockManager"]. + A name two controls share means the one list_wpf_components lists; for the other pass the full name it gives + (e.g. "Infragistics.Windows.Editors.XamComboEditor"). """)] [MinLength(1)] string[] components, @@ -356,14 +504,27 @@ Call list_wpf_components first to resolve exact names. return ToolResult.Error(msg); } - var resolved = new List<(string component, string nugetPackage, string xmlns)>(); + var resolved = new List<(string component, string nugetPackage, string xmlns, string? note)>(); var notFoundWarnings = new List(); foreach (var name in components) { var nameLower = name.ToLowerInvariant(); - var entry = store.Components.FirstOrDefault(c => c.Component.ToLowerInvariant() == nameLower); - if (entry is null) + var entry = store.Components.FirstOrDefault(c => c.Component.ToLowerInvariant() == nameLower) + // A full name, as list_wpf_components gives it for a name two controls share: the one the name means… + ?? store.Components.FirstOrDefault(c => string.Equals($"{c.DotnetNamespace}.{c.Component}", name, StringComparison.OrdinalIgnoreCase)); + // …or the other (the legacy Infragistics.Windows.Editors.XamComboEditor). + var other = entry is null && name.Contains('.') + ? store.Components.SelectMany(c => (c.Alternates ?? []).Select(a => (owner: c, a))) + .FirstOrDefault(o => o.a.Lookup is not null && + (string.Equals(o.a.FullName, name, StringComparison.OrdinalIgnoreCase) || string.Equals(o.a.Lookup, name, StringComparison.OrdinalIgnoreCase))) + : default; + if (other.a is { } alt) + { + resolved.Add((alt.FullName, alt.NugetPackage, $"xmlns:{alt.DefaultPrefix}=\"{alt.XamlNamespace}\"", + $"not the {other.owner.Component} the name alone means (`{other.owner.DotnetNamespace}.{other.owner.Component}`, NuGet `{other.owner.NugetPackage}`)")); + } + else if (entry is null) { var suggestions = store.Components .Where(c => c.Component.ToLowerInvariant().Contains(nameLower, StringComparison.Ordinal)) @@ -377,7 +538,13 @@ Call list_wpf_components first to resolve exact names. } else { - resolved.Add((entry.Component, entry.NugetPackage, $"xmlns:{entry.DefaultPrefix}=\"{entry.XamlNamespace}\"")); + // A name another control shares names it, with the full name that scaffolds it instead. + var note = entry.Alternates is { Length: > 0 } alternates + ? $"This is `{entry.DotnetNamespace}.{entry.Component}`. {(alternates.Length == 1 ? $"Another {entry.Component} exists" : $"Other {entry.Component} types exist")}: " + + string.Join("; ", alternates.Select(a => + $"`{a.FullName}` (NuGet `{a.NugetPackage}`, `xmlns:{a.DefaultPrefix}=\"{a.XamlNamespace}\"`) — pass \"{a.Lookup}\" in components for that one")) + "." + : null; + resolved.Add((entry.Component, entry.NugetPackage, $"xmlns:{entry.DefaultPrefix}=\"{entry.XamlNamespace}\"", note)); } } @@ -425,7 +592,7 @@ Call list_wpf_components first to resolve exact names. lines.Add(""); lines.Add("### Components resolved"); - lines.AddRange(resolved.Select(r => $"- **{r.component}** → `{r.nugetPackage}`")); + lines.AddRange(resolved.Select(r => $"- **{r.component}** → `{r.nugetPackage}`{(r.note is null ? "" : $" ⚠️ {r.note}")}")); var text = string.Join('\n', lines); log.Write("get_wpf_project_scaffold", input, text, timer); diff --git a/server/Tools/DocsTools.cs b/server/Tools/DocsTools.cs index a5159aa..e254335 100644 --- a/server/Tools/DocsTools.cs +++ b/server/Tools/DocsTools.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Diagnostics; +using System.Text.RegularExpressions; using Infragistics.Wpf.Mcp.Data; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -8,7 +9,7 @@ namespace Infragistics.Wpf.Mcp.Tools; [McpServerToolType] -public sealed class DocsTools(DataStore store, ToolLog log) +public sealed partial class DocsTools(DataStore store, ToolLog log) { // ── search_wpf_docs ────────────────────────────────────────────────────── @@ -25,8 +26,10 @@ public CallToolResult SearchDocs( [MaxLength(128)] string? query = null, [Description(""" - Optional control name to narrow results, e.g. "XamDockManager", "ContentPane", "XamDataGrid". - Case-insensitive substring match against the topic's associated control names. + Optional control name to narrow results, e.g. "XamDockManager", "XamMaskedInput", "XamDataGrid". + Matches topics tagged with it (case-insensitive substring), titled after it, or naming it in their text — + many topics are tagged with a family (xamInputs, xamSlider, xamSchedule, xamDataPresenter) rather than + with each control they cover. Topics tagged with or titled after the control rank first. At least one of `query` or `control` must be provided. """)] [MaxLength(64)] @@ -55,27 +58,18 @@ At least one of `query` or `control` must be provided. } var controlMatchCount = 0; - var results = new List<(DocIndexEntry entry, string matchedOn, int score)>(); + var results = new List<(DocIndexEntry entry, string matchedOn, int score, int controlScore)>(); foreach (var entry in store.DocIndex) { - // Hard pre-filter on control name: bidirectional substring match with hyphens and spaces - // stripped, so "FinancialChart" ~ "XamFinancialChart" and "financial-chart" both hit, and - // "ThemeManager" ~ "IG Theme Manager". - if (controlFilter is not null && !entry.ControlNames.Any(c => - { - var cNorm = c.ToLowerInvariant().Replace("-", "").Replace(" ", ""); - var fNorm = controlFilter.Replace("-", "").Replace(" ", ""); - return cNorm.Contains(fNorm, StringComparison.Ordinal) || fNorm.Contains(cNorm, StringComparison.Ordinal); - })) - { - continue; - } + // Hard pre-filter on control name, which also ranks: see ControlMatch. + var (controlScore, controlMatchedOn) = controlFilter is null ? (0, "") : ControlMatch(entry, controlFilter); + if (controlFilter is not null && controlScore == 0) continue; if (controlFilter is not null) controlMatchCount++; if (tokens.Length == 0) { - results.Add((entry, "control name", 0)); + results.Add((entry, controlMatchedOn, controlScore, controlScore)); continue; } @@ -91,41 +85,75 @@ At least one of `query` or `control` must be provided. if (titleHits + controlHits + tagHits + summaryHits == 0) continue; var matchedOn = titleHits > 0 ? "title" : controlHits > 0 ? "control name" : tagHits > 0 ? "tags" : "summary"; - var score = titleHits * 4 + controlHits * 3 + tagHits * 2 + summaryHits; - results.Add((entry, matchedOn, score)); + if (controlFilter is not null) matchedOn += $"; {controlMatchedOn}"; + var score = titleHits * 4 + controlHits * 3 + tagHits * 2 + summaryHits + controlScore; + results.Add((entry, matchedOn, score, controlScore)); } var ordered = results.OrderByDescending(r => r.score).ToArray(); - var totalFound = ordered.Length; - var limited = ordered.Take(limit).ToArray(); + // A topic published for several controls (xamdatagrid-X and xamdatapresenter-X, mostly one include:: file under + // two titles) is listed once, in its best-ranked copy's place, naming the others: two copies took two places. + // The copy shown is the one for `control` (tagged with or titled after it), else the best match for `query`. + // Only topics with the same title or the same opening (the summary after the title) are compared by text: the + // xamDataCards and xamDataPresenter "Undo Operations" link lists differ in title only. + var listed = new List<(DocIndexEntry entry, string matchedOn, int controlScore, string title, string opening, List copies)>(); + var textKeys = new Dictionary(); + string TextKey(DocIndexEntry e) => textKeys.TryGetValue(e.Slug, out var key) ? key + : textKeys[e.Slug] = store.LoadDoc(e.Slug) is { } doc ? SameTextKey(doc.Body) : e.Slug; + var copyCount = 0; + foreach (var r in ordered) + { + var title = SameTitle(r.entry.Title); + var opening = Opening(r.entry); + var i = listed.FindIndex(l => (l.title == title || opening.Length > 0 && l.opening == opening) && TextKey(l.entry) == TextKey(r.entry)); + if (i < 0) + { + if (listed.Count < limit) listed.Add((r.entry, r.matchedOn, r.controlScore, title, opening, [])); + continue; + } + copyCount++; + var shown = listed[i]; + if (r.controlScore <= shown.controlScore) shown.copies.Add(r.entry); + else + { + shown.copies.Add(shown.entry); + listed[i] = (r.entry, r.matchedOn, r.controlScore, title, opening, shown.copies); + } + } + var totalFound = ordered.Length - copyCount; - if (limited.Length == 0) + if (listed.Count == 0) { string msg; if (controlFilter is not null && controlMatchCount == 0) - msg = $"No indexed topics reference control \"{control}\". Check the exact name with list_wpf_components — the control filter is a substring match, so a typo or wrong casing of the underlying name returns nothing."; + msg = $"No topic is tagged with, titled after or mentions \"{control}\". Search with `query` words alone (no `control`), or with the control this one is part of."; else if (controlFilter is not null) msg = $"{controlMatchCount} topic(s) reference control \"{control}\", but none matched any word in \"{query}\". Try fewer/simpler keywords, or call search_wpf_docs(control: \"{control}\") with no query to browse all {controlMatchCount} topics for this component."; else msg = $"No documentation topics found matching any word in \"{query}\". Try shorter or more general keywords (e.g. \"getting started\", \"grouping\", \"pin pane\"), or add `control` to browse a specific component's topics directly."; + // XamScheduler is the Xamarin control; no WPF topic uses the word, so a search for it ended here. The whole + // word: .NET's TaskScheduler is not a scheduling control. + if (SchedulerWord().IsMatch($"{query} {control}")) + msg += " WPF has no XamScheduler (a Xamarin control): its scheduling controls are the XamSchedule family (XamScheduleView, XamDayView, XamMonthView, XamOutlookCalendarView), documented under control \"xamSchedule\" — call search_wpf_docs(control: \"xamSchedule\"), with `query` words to narrow."; log.Write("search_wpf_docs", input, msg, timer); return ToolResult.Error(msg); } - var lines = limited.Select(r => string.Join('\n', new[] + var lines = listed.Select(r => string.Join('\n', new[] { $"### {(string.IsNullOrEmpty(r.entry.Title) ? r.entry.Slug : r.entry.Title)}", $"- **Topic slug:** `{r.entry.Slug}`", $"- **Matched on:** {r.matchedOn}", $"- **Controls:** {(r.entry.ControlNames.Length > 0 ? string.Join(", ", r.entry.ControlNames) : "_(none listed)_")}", $"- **Tags:** {(r.entry.Tags.Length > 0 ? string.Join(", ", r.entry.Tags) : "_(none)_")}", - $"- {(string.IsNullOrEmpty(r.entry.Summary) ? "_(no summary)_" : r.entry.Summary)}", - })); + } + .Concat(r.copies.Select(c => $"- **Same text also under:** `{c.Slug}`" + (c.ControlNames.Length > 0 ? $" ({string.Join(", ", c.ControlNames)})" : ""))) + .Append($"- {(string.IsNullOrEmpty(r.entry.Summary) ? "_(no summary)_" : r.entry.Summary)}"))); var queryLabel = !string.IsNullOrEmpty(query) ? $"\"{query}\"" : "(browsing by control)"; var text = string.Join("\n\n", new[] { - $"## WPF Documentation Search: {queryLabel} ({limited.Length} of {totalFound} matches)", + $"## WPF Documentation Search: {queryLabel} ({listed.Count} of {totalFound} matches)", "", } .Concat(lines) @@ -139,11 +167,66 @@ At least one of `query` or `control` must be provided. return ToolResult.Text(text); } + // How strongly a topic is about a control. Tags first: a bidirectional substring match with hyphens and spaces + // stripped, so "FinancialChart" ~ "XamFinancialChart" and "financial-chart", and "ThemeManager" ~ "IG Theme Manager"; + // a tag that is only part of the filter ("xamGrid" for "XamGridBarcode") counts least. Then the title, and the Xam* + // names the text mentions: many topics are tagged with a family (xamInputs, xamDataPresenter) rather than with each + // control they cover (XamMaskedInput, XamDataGrid). "MaskedInput" also finds "XamMaskedInput". + private static (int Score, string MatchedOn) ControlMatch(DocIndexEntry entry, string filter) + { + var fNorm = filter.Replace("-", "").Replace(" ", ""); + var tags = entry.ControlNames.Select(c => c.ToLowerInvariant().Replace("-", "").Replace(" ", "")).ToArray(); + var tag = tags.Any(c => c.Contains(fNorm, StringComparison.Ordinal)) ? 3 + : tags.Any(c => fNorm.Contains(c, StringComparison.Ordinal)) ? 1 : 0; + var title = entry.Title.Replace(" ", "").Contains(fNorm, StringComparison.OrdinalIgnoreCase) ? 3 : 0; + var mentions = entry.Mentions is null ? 0 + : entry.Mentions.GetValueOrDefault(fNorm) + (fNorm.StartsWith("xam", StringComparison.Ordinal) ? 0 : entry.Mentions.GetValueOrDefault("xam" + fNorm)); + // A topic tagged with no control at all (release notes, deployment, assembly lists) names many controls in + // passing: its mentions count once, so the family topics that cover the control rank above it. + var score = tag + title + Math.Min(mentions, entry.ControlNames.Length > 0 ? 3 : 1); + var how = new List(); + if (tag > 0) how.Add("control tag"); + if (title > 0) how.Add("control in title"); + if (mentions > 0) how.Add($"control named {mentions}× in text"); + return (score, string.Join(", ", how)); + } + + // The title without the controls it names in parentheses: "Cross-Field Record Filtering (xamDataGrid)" and + // "… (xamDataPresenter)" are one title. + private static string SameTitle(string title) => XamParenthetical().Replace(title, "").Trim().ToLowerInvariant(); + + // The summary after the title it starts with: the text's first words, or all of a short one. + private static string Opening(DocIndexEntry e) => + (e.Summary.StartsWith(e.Title, StringComparison.Ordinal) ? e.Summary[e.Title.Length..] : e.Summary).Trim(); + + // The text below the title, every Xam* name the same word: each copy names its own control ("About xamDataGrid" and + // "About xamDataPresenter" in xamdatagrid- and xamdatapresenter-crossfieldrecordfiltering). + private static string SameTextKey(string body) + { + var text = body.StartsWith("# ", StringComparison.Ordinal) ? body[(body.IndexOf('\n') + 1)..] : body; + return Whitespace().Replace(XamName().Replace(text, "xam"), " ").Trim(); + } + + [GeneratedRegex(@"\s*\([^()]*\b[Xx]am[A-Z][^()]*\)")] + private static partial Regex XamParenthetical(); + + [GeneratedRegex(@"\b[Xx]am[A-Z][A-Za-z0-9]*\b")] + private static partial Regex XamName(); + + [GeneratedRegex(@"\s+")] + private static partial Regex Whitespace(); + + [GeneratedRegex(@"\b(?:xam)?scheduler\b", RegexOptions.IgnoreCase)] + private static partial Regex SchedulerWord(); + // ── get_wpf_doc ────────────────────────────────────────────────────────── - private const int MaxBodyChars = 6000; - private const int MaxSnippetsShown = 6; - private const int MaxSnippetChars = 3000; + // Topics up to this size come back whole (2,360 of 2,386 on 26.1.21). The rest come in pages split at their section + // headings, like large theme files: up to 9 (resources-financialdata, 192K; the next longest is 50K). They used to be + // cut at 6,000 characters, code samples included. + private const int MaxDocChars = 24000; + // Body text per page; with the response's header this stays under the 25K get_wpf_api_reference keeps to as well. + private const int DocPageChars = 23000; [McpServerTool(Name = "get_wpf_doc", ReadOnly = true, Idempotent = true, OpenWorld = false)] [Description(ToolDescriptions.GetWpfDoc)] @@ -154,10 +237,16 @@ public CallToolResult GetDoc( Case-insensitive. """)] [MinLength(1), MaxLength(160)] - string topic) + string topic, + [Description(""" + Optional page number for topics too long for one response (the response says how many pages there are and + which sections each holds). Pages split at section headings and concatenate to the whole topic. + """)] + [Range(1, 100)] + int? page = null) { var timer = Stopwatch.StartNew(); - var input = new { topic }; + var input = new { topic, page }; if (ToolInput.CheckLength("topic", topic, max: 160, min: 1) is { } invalid) { log.Write("get_wpf_doc", input, invalid, timer); @@ -192,46 +281,72 @@ public CallToolResult GetDoc( $"**Tags:** {(doc.Tags.Length > 0 ? string.Join(", ", doc.Tags) : "_(none)_")}", }; - if (doc.XamlSnippets.Length > 0) + CallToolResult Respond(string text, bool isError = false) { - output.Add(""); - output.Add("### XAML Examples"); - var shown = doc.XamlSnippets.Take(MaxSnippetsShown).ToArray(); - for (var i = 0; i < shown.Length; i++) - { - var snippet = shown[i]; - var text = snippet.Length > MaxSnippetChars - ? $"{snippet[..MaxSnippetChars]}\n" - : snippet; - output.Add(""); - output.Add($"#### Example {i + 1}"); - output.Add("```xml"); - output.Add(text); - output.Add("```"); - } - var remaining = doc.XamlSnippets.Length - shown.Length; - if (remaining > 0) - { - output.Add(""); - output.Add($"_{remaining} additional XAML example(s) omitted for brevity._"); - } + log.Write("get_wpf_doc", input, text, timer); + return isError ? ToolResult.Error(text) : ToolResult.Text(text); } - else + // The body is the topic in Markdown, its code samples (XAML, C#, VB) in ``` fences. + // [Range] only describes the schema: page 0 or below is out of range like a page past the end. + var n = page ?? 1; + if (doc.Body.Length <= MaxDocChars) { - output.Add(""); - output.Add("_This topic has no extracted XAML code samples — see body text below for conceptual guidance._"); + if (n != 1) return Respond($"page {n} is out of range: \"{doc.Slug}\" fits in one response (page: 1).", isError: true); + return Respond(string.Join('\n', output.Concat(["", doc.Body]))); } - var body = doc.Body.Length > MaxBodyChars - ? $"{doc.Body[..MaxBodyChars]}\n\n_(truncated, {doc.Body.Length} chars total)_" - : doc.Body; + // A section longer than a page goes in line by line, so the page before it fills up instead of ending early + // (one topic is a 600-line XAML listing under a single heading). + var pages = XamlPages.Paginate( + Sections(doc.Body).SelectMany(s => s.Text.Length <= DocPageChars + ? [s] : XamlPages.Lines(s.Text, DocPageChars).Select((line, i) => (Text: line, Label: i == 0 ? s.Label : ""))), + DocPageChars); + if (n < 1 || n > pages.Count) + return Respond($"page {n} is out of range: \"{doc.Slug}\" has {pages.Count} pages (page: 1 to {pages.Count}).", isError: true); + // A code block that runs across pages is closed at the end of one and reopened, language included, at the + // start of the next, so each page is valid Markdown. + var texts = new List(); + string? openFence = null; + foreach (var p in pages) + { + var reopen = openFence is null ? "" : openFence + "\n"; + foreach (var line in p.Text.Split('\n')) + if (line.StartsWith("```", StringComparison.Ordinal)) openFence = openFence is null ? line : null; + texts.Add(reopen + p.Text + (openFence is null ? "" : (p.Text.EndsWith('\n') ? "" : "\n") + "```\n")); + } + static string Headings(XamlPages.Page p) => p.Labels.Count == 0 ? "(continued)" + : string.Join(", ", p.Labels.Take(4)) + (p.Labels.Count > 4 ? $", … ({p.Labels.Count} sections)" : ""); output.Add(""); - output.Add("### Full Topic Text"); + output.Add($"_This topic is {doc.Body.Length} characters, so it comes in {pages.Count} pages split at its section headings; " + + $"this is page {n}. {(n < pages.Count ? $"Next: page: {n + 1}." : "This is the last page.")} Contents:_"); + output.AddRange(pages.Select((p, i) => $"- Page {i + 1}: {Headings(p)}")); output.Add(""); - output.Add(body); + output.Add(texts[n - 1]); + return Respond(string.Join('\n', output)); + } - var result = string.Join('\n', output); - log.Write("get_wpf_doc", input, result, timer); - return ToolResult.Text(result); + // The body split before each heading line ("## Binding to Data"), labelled with the heading. A # line inside a code + // fence (a C# #region) is code, not a heading. + private static IEnumerable<(string Text, string Label)> Sections(string body) + { + var start = 0; + var label = ""; + var inFence = false; + var lineStart = 0; + while (lineStart < body.Length) + { + var lineEnd = body.IndexOf('\n', lineStart); + var next = lineEnd < 0 ? body.Length : lineEnd + 1; + var line = body[lineStart..(lineEnd < 0 ? body.Length : lineEnd)]; + if (line.StartsWith("```", StringComparison.Ordinal)) inFence = !inFence; + else if (!inFence && line.StartsWith('#') && line.TrimStart('#').StartsWith(' ') && lineStart > start) + { + yield return (body[start..lineStart], label); + start = lineStart; + } + if (!inFence && lineStart == start && line.StartsWith('#')) label = line.TrimStart('#').Trim(); + lineStart = next; + } + yield return (body[start..], label); } } diff --git a/server/Tools/SearchTools.cs b/server/Tools/SearchTools.cs index be24c8e..1f60474 100644 --- a/server/Tools/SearchTools.cs +++ b/server/Tools/SearchTools.cs @@ -46,7 +46,7 @@ public CallToolResult SearchApi( var tokens = Tokenize(query); limit = Math.Clamp(limit, 1, 50); - var results = new List<(string typeName, string summary, string nugetPackage, string matchedOn, int score)>(); + var results = new List<(string typeName, string summary, string nugetPackage, string? lookup, string matchedOn, int score)>(); foreach (var entry in store.SearchIndex) { @@ -69,10 +69,15 @@ public CallToolResult SearchApi( : "summary"; var score = nameHits * 4 + memberHits * 2 + summaryHits; - results.Add((entry.TypeName, entry.Summary, entry.NugetPackage, matchedOn, score)); + results.Add((entry.TypeName, entry.Summary, entry.NugetPackage, entry.Lookup, matchedOn, score)); } - var ordered = results.OrderByDescending(r => r.score).ToArray(); + // Ties: the types of a shared name that the query spells exactly ("Axis": the chart's, Excel's) come first. They + // came after the others of their score, the ones that lost the name last of all, and the limit cut them off. + // OrderBy is stable, so every other result keeps its order. + var ordered = results.OrderByDescending(r => r.score) + .ThenByDescending(r => r.lookup is not null && string.Equals(r.typeName, query.Trim(), StringComparison.OrdinalIgnoreCase)) + .ToArray(); var totalFound = ordered.Length; var limited = ordered.Take(limit).ToArray(); @@ -86,8 +91,11 @@ public CallToolResult SearchApi( return ToolResult.Error(msg); } + // A type whose name others share (the two XamComboEditors) says which full name opens it: the name alone opens one of them. var lines = limited.Select(r => - $"### {r.typeName}\n- **Matched:** {r.matchedOn}\n- **NuGet:** `{r.nugetPackage}`\n- {(string.IsNullOrEmpty(r.summary) ? "_(no summary)_" : r.summary)}"); + $"### {r.typeName}\n- **Matched:** {r.matchedOn}\n- **NuGet:** `{r.nugetPackage}`\n" + + (r.lookup is { } lookup ? $"- **Several types are named \"{r.typeName}\":** open this one with get_wpf_api_reference(\"{lookup}\")\n" : "") + + $"- {(string.IsNullOrEmpty(r.summary) ? "_(no summary)_" : r.summary)}"); var text = string.Join("\n\n", new[] { diff --git a/server/Tools/ThemeTools.cs b/server/Tools/ThemeTools.cs index 0a1007b..1b0fb37 100644 --- a/server/Tools/ThemeTools.cs +++ b/server/Tools/ThemeTools.cs @@ -27,6 +27,8 @@ public sealed partial class ThemeTools(DataStore store, ToolLog log) "", NewerMechanismLine, "", + "_The ThemeManager also themes most legacy-family controls (XamDataGrid, the xamEditors, XamDockManager, XamRibbon, …): `setup_wpf_theme(component)` says so for each control it covers._", + "", "**Newer-family apply template (replace `` with the theme):**", "```sh", "dotnet add package Infragistics.WPF.Themes..Trial", @@ -57,7 +59,7 @@ private static string NewerApplyBlock(string theme) => string.Join('\n', new[] " base.OnStartup(e);", "}", "```", - "_Covers the newer \"Infragistics.Controls.*\" family (charts, gauges, maps, XamGrid, etc.)._", + "_Themes the whole app: the Infragistics.Controls.* controls (charts, gauges, maps, XamGrid, …) and the Infragistics.Windows ones its files are listed for (XamDataGrid, the xamEditors, XamDockManager, XamRibbon, …)._", }); // Built-in WPF types that appear as TargetType in nearly every ControlTemplate — an exact @@ -209,6 +211,19 @@ componentFilter is null || output.Add(""); if (matchedLegacy.Length > 0) output.Add(LegacyMechanismLine); if (matchedNewer.Length > 0) output.Add(NewerMechanismLine); + // A control both families style (XamDataGrid, XamDockManager, …): say which to use, or agents read "legacy" as "Theme= only". + // Only a control with a Theme property takes Theme="" — not XamDataChart, whose DefaultStyles/ folder holds its + // default templates. Matched as "XamDataGrid" or as the "DataGrid" agents also pass. + var both = componentFilter is null ? null : matchedNewer + .SelectMany(t => t.files).SelectMany(f => f.TargetTypes) + .Where(tt => tt.ToLowerInvariant() == componentFilter || tt.ToLowerInvariant() == "xam" + componentFilter) + .Distinct(StringComparer.Ordinal) + .FirstOrDefault(tt => store.LoadApiDoc(tt)?.Properties.Any(p => p.Name == "Theme" && p.TypeName == "String") == true); + if (both is not null) + { + output.Add(""); + output.Add($"_**`{both}` is in both families.** For an app-wide theme use `Infragistics.Themes.ThemeManager.ApplicationTheme` (newer family above): it themes `{both}` together with every other control. `Theme=\"\"` on the control is the per-control alternative._"); + } if (totalFiles == 1) { diff --git a/server/Tools/ToolDescriptions.cs b/server/Tools/ToolDescriptions.cs index 65cde92..5e898c0 100644 --- a/server/Tools/ToolDescriptions.cs +++ b/server/Tools/ToolDescriptions.cs @@ -21,13 +21,15 @@ public static class ToolDescriptions """; public const string GetWpfApiReference = """ - Get full API documentation for an Infragistics WPF type: xmlns declaration, NuGet package, type summary, and all members (properties, methods, events) with descriptions. + Get full API documentation for an Infragistics WPF type: xmlns declaration, NuGet package, type summary, and all members (constructors, properties, methods, events) with descriptions. Methods, constructors and indexers come with their C# signature, one line per overload (e.g. `void Export(OutputFormat outputFormat, string fileName)`). Use after list_wpf_components (or search_wpf_api, if the exact type name isn't known yet) to resolve the component name. Also use to check whether a specific property or event exists on a known type — it returns the complete member list, including members inherited from Infragistics base classes (grouped under "inherited from `Base`" headings). - The xmlns line is resolved from the type's own assembly, so use it exactly as given — it can differ between types of one control (e.g. igDP for XamDataGrid, igThemes for DataPresenterRoyalDark, igPrim for primitives, or a clr-namespace: line). Static resource keys and commands (e.g. DockManagerBrushKeys.*, DataPresenterCommands.*) are listed with the properties, marked "(static field)" — use them via {x:Static prefix:Type.Member}. Nested classes show a "XAML name" (Outer+Inner). + Some names belong to several types in different namespaces and packages (e.g. SelectionType: XamDataGrid's and XamGrid's; XamComboEditor: the newer Infragistics.Controls.Editors one and the legacy Infragistics.Windows.Editors one). The name alone returns the type of the control family Infragistics recommends — never a retired control's (XamGrid, retired for XamDataGrid; the xamInputs such as XamMaskedInput, retiring for the xamEditors such as XamMaskedEditor), and a newer Infragistics.Controls / Infragistics.Themes copy before the legacy Infragistics.Windows one (XamComboEditor, ThemeManager); otherwise the most fully documented one — with an "is ambiguous" note listing the others and the call that opens each — by full name, e.g. get_wpf_api_reference("Infragistics.Windows.Editors.XamComboEditor"). Check its xmlns and NuGet package against the project before using the members. - Large types (mostly charts and grids, whose members are largely inherited) are compacted to stay under ~25K characters, giving up detail in this order until the response fits: events and methods are listed by name only first (properties keep full detail), then inherited members, and as a last resort all members. The response then says so in an "ℹ️ Compacted" line. Every member name is always listed. For full properties only, kind: "properties" is often small enough to come back uncompacted. To get full details (type, enum values, summary) for specific members, pass member: a case-insensitive name substring, combinable with kind — e.g. get_wpf_api_reference("XamCategoryChart", member: "YAxis") returns every YAxis* member, inherited ones included. When you already know which members you need, pass member up front. + The xmlns line is resolved from the type's own assembly, so use it exactly as given — it can differ between types of one control (e.g. igDP for XamDataGrid, igThemes for DataPresenterRoyalDark, igPrim for primitives, or a clr-namespace: line). Static resource keys and commands (e.g. DockManagerBrushKeys.*, DataPresenterCommands.*) are listed with the properties, marked "(static field)" — use them via {x:Static prefix:Type.Member}. Protected properties and events are marked "(protected)", and a protected method's signature says "protected". Only a subclass can use them, so in a list of names they come after "protected:" (a method name when all its listed overloads are protected). An enum lists its values in declaration order with their numbers, and a [Flags] enum says how values combine. Nested classes show a "XAML name" (Outer+Inner). Members marked [Obsolete] are left out of the lists; looking one up with member returns an "⚠ OBSOLETE" line with Infragistics' note on what replaces it. + + Large types (mostly charts and grids, whose members are largely inherited) are compacted to stay under ~25K characters, giving up detail in this order until the response fits: events and methods are listed by name only first (properties keep full detail; an overloaded method once, with its overload count), then inherited members, and as a last resort all members; protected properties and events go to names before each of these steps. The response then says so in an "ℹ️ Compacted" line. Every member name is always listed. For full properties only, kind: "properties" is often small enough to come back uncompacted. To get full details (type, enum values, summary; for methods every overload's signature and parameter descriptions) for specific members, pass member: a case-insensitive name substring, combinable with kind — e.g. get_wpf_api_reference("XamCategoryChart", member: "YAxis") returns every YAxis* member, inherited ones included. When you already know which members you need, pass member up front. If a type's own member list is unexpectedly sparse, the response names its base type — call get_wpf_api_reference again on that base type to see the rest of the inherited surface. @@ -51,13 +53,13 @@ public static class ToolDescriptions Use when you don't know the exact type name or want to discover types related to a feature — e.g. the user describes a capability ("filter", "export", "pivot") rather than naming a control. Do NOT use to verify members on a known type — call get_wpf_api_reference instead, which returns the complete member list. - Returns a ranked list of matching types (name, summary, NuGet package). The `typeName` of any result is the key that chains forward — reuse it verbatim in get_wpf_api_reference(component) for full members, or in search_wpf_docs(query, control: typeName) for how-to topics. + Returns a ranked list of matching types (name, summary, NuGet package). The `typeName` of any result is the key that chains forward — reuse it verbatim in get_wpf_api_reference(component) for full members, or in search_wpf_docs(query, control: typeName) for how-to topics. When several types share a name, the result says so and gives the get_wpf_api_reference call with the full name that opens that one. """; public const string SearchWpfDocs = """ - Search Infragistics WPF how-to and conceptual documentation (2,600+ topics from the official docs-wpf and docs-common repositories) by keyword and/or control name — this is the FULL documentation set, covering far more than child-element nesting: getting started, layouts, styling/theming, data binding, editing/validation, filtering/sorting/grouping/summaries, exporting, performance, commands, persistence, known issues, and breaking changes. + Search Infragistics WPF how-to and conceptual documentation (2,300+ topics from the official docs-wpf and docs-common repositories, WPF content only) by keyword and/or control name — this is the FULL documentation set, covering far more than child-element nesting: getting started, layouts, styling/theming, data binding, editing/validation, filtering/sorting/grouping/summaries, exporting, performance, commands, persistence, known issues, and breaking changes. - Use this whenever a task goes beyond "what members exist on this type" — i.e. whenever you need to know HOW to accomplish something with a control (any non-trivial XAML, behavior, styling, or configuration), not only when the control nests child content. Pass the exact component name already resolved via list_wpf_components / search_wpf_api / get_wpf_api_reference as `control` to scope results to that component instead of searching all 2,600+ topics blind. + Use this whenever a task goes beyond "what members exist on this type" — i.e. whenever you need to know HOW to accomplish something with a control (any non-trivial XAML, behavior, styling, or configuration), not only when the control nests child content. Pass the exact component name already resolved via list_wpf_components / search_wpf_api / get_wpf_api_reference as `control` to scope results to that component instead of searching all 2,300+ topics blind. `query` uses ranked OR matching — a topic matching more words ranks higher, but matching just one word still returns it, so prefer 2-4 specific words over a full sentence (a full sentence still works, it's just less precise). `query` is optional: pass `control` alone to browse every topic for that component. @@ -69,16 +71,16 @@ public static class ToolDescriptions public const string GetWpfDoc = """ Retrieve the full text and extracted XAML code examples for one WPF documentation topic — any topic type: getting started, layouts/nesting, styling/theming, data binding, editing/validation, filtering/sorting/grouping/summaries, exporting, performance, commands, persistence, known issues, breaking changes, etc. Not limited to structural/nesting topics. - Use the exact slug returned by search_wpf_docs — this is always the last step of the discovery chain (list_wpf_components/search_wpf_api → get_wpf_api_reference → search_wpf_docs → get_wpf_doc), never the first call in a session. Returns the topic title, associated control names, tags, up to 6 XAML code samples (verified working examples from Infragistics' official docs), and the full descriptive body text. + Use the exact slug returned by search_wpf_docs — this is always the last step of the discovery chain (list_wpf_components/search_wpf_api → get_wpf_api_reference → search_wpf_docs → get_wpf_doc), never the first call in a session. Returns the topic title, associated control names, tags, and the whole topic as Markdown with its code samples (XAML, C#, VB — verified working examples from Infragistics' official docs) in ``` fences. Topics longer than ~24K characters come in pages split at their section headings: the response lists each page's sections; pass page: 2, 3, … for the rest. Call this before writing XAML or giving configuration/usage advice for any control whose behavior isn't already confirmed by an existing working example — this is the most effective way to avoid build errors from guessed property names, runtime errors from invalid nesting, and incorrect advice on styling, data binding, performance, or any other topic covered by the docs. """; public const string SetupWpfTheme = """ - List available Infragistics WPF named themes and the raw XAML style/resource-dictionary files backing them, sourced directly from the official Infragistics/wpf-resources repository. Covers BOTH theming mechanisms used across the product — they are NOT interchangeable, always check which one applies before writing code: + List available Infragistics WPF named themes and the raw XAML style/resource-dictionary files backing them, sourced directly from the official Infragistics/wpf-resources repository. Covers BOTH theming mechanisms used across the product — always check which ones apply to a control before writing code: - 1. Legacy "Infragistics.Windows.*" family (XamDataGrid/XamDataPresenter, XamRibbon, XamDockManager, XamTabControl, etc.) — named themes (MetroDark, RoyalDark, Office2013, ...) ship embedded as BAML **inside the component's own assembly**. Apply out-of-the-box by simply setting `Theme="MetroDark"` on the control — no package or merge needed. The matching files under `DefaultStyles/{Folder}/` are for *customizing*: copy the relevant Style/ControlTemplate and override just what you need. - 2. Newer "Infragistics.Controls.*" family (charts, gauges, and other newer controls) — themes are NOT embedded; apply via a separate `Infragistics.WPF.Themes..Trial` NuGet package plus `Infragistics.Themes.ThemeManager.ApplicationTheme = new Theme();` in App.xaml.cs (the IG theme's class is `IgTheme`; do NOT manually merge the `Themes/{ThemeName}/` files into Application.Resources — that throws a runtime XamlParseException). The matching files under `Themes/{ThemeName}/` are for inspecting/customizing the exact brushes and templates that theme applies. + 1. Legacy "Infragistics.Windows.*" family (XamDataGrid/XamDataPresenter, XamRibbon, XamDockManager, XamTabControl, etc.) — named themes (MetroDark, RoyalDark, Office2013, ...) ship embedded as BAML **inside the component's own assembly**. Apply to one control by setting `Theme="MetroDark"` on it — no package or merge needed. The matching files under `DefaultStyles/{Folder}/` are for *customizing*: copy the relevant Style/ControlTemplate and override just what you need. + 2. ThemeManager themes — the newer "Infragistics.Controls.*" controls (charts, gauges, ...) AND most legacy ones too (XamDataGrid, the xamEditors, XamDockManager, XamRibbon, XamTabControl, ...), so for an app-wide theme this one line covers both families; a legacy control it covers is listed under both, with a note saying so. Themes are NOT embedded; apply via a separate `Infragistics.WPF.Themes..Trial` NuGet package plus `Infragistics.Themes.ThemeManager.ApplicationTheme = new Theme();` in App.xaml.cs (the IG theme's class is `IgTheme`; do NOT manually merge the `Themes/{ThemeName}/` files into Application.Resources — that throws a runtime XamlParseException). The matching files under `Themes/{ThemeName}/` are for inspecting/customizing the exact brushes and templates that theme applies. Call this FIRST for any styling/theming task — before writing XAML, before advising which mechanism applies, and before calling get_wpf_theme_resource. For newer-family themes it also returns the COMPLETE ready-to-paste apply block (the `dotnet add package Infragistics.WPF.Themes..Trial` line plus the `App.xaml.cs` ThemeManager call), so applying a real theme is fewer steps than hand-authoring brushes — always prefer it over inventing your own color scheme. Pass `component` and/or `theme` to filter; omit both to see the full list of theme names and legacy style folders. Returns exact `path` values — reuse them verbatim in get_wpf_theme_resource(path) to read the actual XAML content. """; diff --git a/server/Tools/XamlPages.cs b/server/Tools/XamlPages.cs index 3c73640..43215eb 100644 --- a/server/Tools/XamlPages.cs +++ b/server/Tools/XamlPages.cs @@ -111,7 +111,8 @@ void Flush() return pages; } - private static IEnumerable Lines(string text, int budget) + /// The text as lines that keep their "\n" (a line longer than `budget` in pieces), so they add up to it exactly. + internal static IEnumerable Lines(string text, int budget) { var start = 0; for (var i = 0; i <= text.Length; i++)