From ef649e09a7a6cec52e288eb098d746d1e44ad2a0 Mon Sep 17 00:00:00 2001 From: Phil Scott Date: Sun, 24 May 2026 23:39:37 -0400 Subject: [PATCH] chore(roslyn): remove the Roslyn integration The docs site migrated off Roslyn to the reflection-based API metadata provider (AddApiMetadataFromCompiledAssembly) plus TreeSitter :symbol fences, so Pennington.Roslyn is now redundant. - Delete src/Pennington.Roslyn, tests/Pennington.Roslyn.Tests, and the examples/BeyondRoslynExample example (incl. its Sample/ library). - Drop those projects from Pennington.slnx/.slnf and remove the 7 Roslyn-only package versions from Directory.Packages.props (keep DiffPlex, shared with TreeSitter, and MetadataLoadContext). - Remove the vestigial Roslyn project reference and two obsolete skipped tests from Pennington.IntegrationTests. - Land the reflection inheritdoc / record-param resolvers and tests that bring the compiled-assembly backend to parity. - Scrub every Roslyn / :xmldocid reference from docs content, examples, READMEs, CLAUDE.md files, and code comments; delete the connect-roslyn tutorial and repoint dangling xrefs to the reflection / :symbol forms. Verified: dotnet build (0/0), dotnet test (all pass), and the static docs build (457 pages, no broken-xref diagnostics). --- CLAUDE.md | 3 - Directory.Packages.props | 7 - Pennington.slnf | 1 - Pennington.slnx | 4 - README.md | 1 - .../ApiReference/FrontMatterKeyIndex.cs | 232 ++--- docs/Pennington.Docs/CLAUDE.md | 4 +- docs/Pennington.Docs/Components/Index.razor | 16 +- .../Reference/FrontMatterKeys.razor | 17 +- .../Components/Reference/_Imports.razor | 2 +- .../blog/api-reference-from-xmldocs.md | 12 +- .../Content/blog/hot-reload.md | 15 +- .../Content/blog/introducing-pennington.md | 12 +- .../Content/blog/pennington-on-nuget.md | 6 +- .../explanation/rendering/highlighting.md | 22 +- .../Content/explanation/spa/islands.md | 2 +- .../code-samples/focused-code-samples.md | 95 +- .../content-services/auto-api-reference.md | 51 +- .../code-block-preprocessor.md | 6 +- docs/Pennington.Docs/Content/llms.txt | 2 +- .../Content/reference/host/extensions.md | 2 +- .../reference/markdown/code-block-args.md | 18 +- .../Content/reference/ui/content.md | 2 +- .../tutorials/beyond-basics/connect-roslyn.md | 287 ------ .../tutorials/getting-started/navigation.md | 2 +- docs/Pennington.Docs/Pennington.Docs.csproj | 6 +- docs/Pennington.Docs/Program.cs | 40 +- examples/AUDIT_LOG.md | 26 - .../BeyondRoslynExample.csproj | 19 - .../BeyondRoslynExample.slnx | 3 - .../Components/CodeBlockRazorPage.razor | 25 - .../Components/_Imports.razor | 2 - .../BeyondRoslynExample/Content/api-pulls.md | 72 -- examples/BeyondRoslynExample/Content/index.md | 23 - examples/BeyondRoslynExample/Program.cs | 41 - examples/BeyondRoslynExample/README.md | 19 - .../Sample/BeyondRoslynExample.Sample.csproj | 11 - .../BeyondRoslynExample/Sample/Calculator.cs | 47 - .../BeyondRoslynExample/Sample/Greeter.cs | 27 - .../BeyondRoslynExample/Stage1_NoRoslyn.cs | 35 - .../BeyondRoslynExample/Stage2_AddRoslyn.cs | 42 - .../BeyondTreeSitterExample/Content/index.md | 6 +- examples/BeyondTreeSitterExample/Program.cs | 4 +- examples/BeyondTreeSitterExample/README.md | 12 +- examples/CLAUDE.md | 29 +- .../extensions/markdown-extensions.md | 3 +- .../LineCountPreprocessor.cs | 2 +- examples/FocusedCodeSamplesExample/README.md | 16 +- examples/FusionCacheDocSiteExample/Program.cs | 2 +- examples/FusionCacheDocSiteExample/README.md | 2 +- .../CompiledAssemblyApiMetadataExtensions.cs | 2 +- .../CompiledAssemblyApiMetadataProvider.cs | 733 ++++++++------ .../ReflectionInheritDocResolver.cs | 244 +++++ .../ReflectionRecordParamFallback.cs | 106 ++ .../SignatureFormatter.cs | 219 +++++ .../XmlDocFile.cs | 48 +- .../XmlDocIdFormatter.cs | 2 +- .../IApiMetadataProvider.cs | 2 +- .../Pennington.ApiMetadata.csproj | 2 +- src/Pennington.ApiMetadata/UidDisplay.cs | 2 +- .../ApiMetadata/ApiReferenceOptions.cs | 46 - .../RoslynApiMetadataExtensions.cs | 45 - .../ApiMetadata/RoslynApiMetadataProvider.cs | 927 ------------------ .../Documentation/InheritDocResolver.cs | 190 ---- .../RecordParamFallbackResolver.cs | 116 --- .../Highlighting/RoslynHighlighter.cs | 37 - .../Highlighting/SyntaxHighlighter.cs | 259 ----- .../Pennington.Roslyn.csproj | 32 - .../RoslynCodeBlockPreprocessor.cs | 442 --------- src/Pennington.Roslyn/RoslynExtensions.cs | 53 - src/Pennington.Roslyn/RoslynOptions.cs | 23 - .../Symbols/CodeFragmentExtractor.cs | 175 ---- .../Symbols/CodeFragmentResult.cs | 8 - .../Symbols/ISymbolExtractionService.cs | 28 - .../Symbols/RequiredUsingsAnalyzer.cs | 265 ----- .../Symbols/SymbolExtractionService.cs | 426 -------- .../Symbols/SymbolExtractionWarmupService.cs | 38 - src/Pennington.Roslyn/Symbols/SymbolInfo.cs | 20 - .../Symbols/XmlDocIdNormalizer.cs | 150 --- .../Utilities/TextFormatter.cs | 84 -- .../Workspace/ISolutionWorkspaceService.cs | 38 - .../Workspace/SolutionWorkspaceService.cs | 579 ----------- src/Pennington.UI/Components/CodeBlock.razor | 13 +- src/Pennington.UI/wwwroot/spa-engine.js | 2 +- .../Content/RedirectContentService.cs | 2 +- .../DocsSite/ApiReferenceComponentTests.cs | 57 -- .../DocsSite/XmlDocIdFenceSweepSmokeTest.cs | 35 - .../DocsWebApplicationFactory.cs | 4 +- .../Pennington.IntegrationTests.csproj | 1 - .../ApiReferenceOptionsRegistrationTests.cs | 33 - .../Documentation/InheritDocResolverTests.cs | 242 ----- .../RoslynApiMetadataProviderTests.cs | 473 --------- .../Documentation/XmlDocHtmlRendererTests.cs | 144 --- .../Documentation/XmlDocNodeAssertions.cs | 22 - .../Documentation/XmlDocParserTests.cs | 190 ---- tests/Pennington.Roslyn.Tests/GlobalUsings.cs | 2 - .../Highlighting/RoslynHighlighterTests.cs | 77 -- .../Pennington.Roslyn.Tests.csproj | 19 - .../RoslynCodeBlockPreprocessorTests.cs | 408 -------- .../Symbols/CodeFragmentExtractorTests.cs | 197 ---- .../Symbols/RequiredUsingsAnalyzerTests.cs | 236 ----- .../Symbols/XmlDocIdNormalizerTests.cs | 87 -- .../Utilities/TextFormatterTests.cs | 72 -- .../SolutionWorkspaceServiceTests.cs | 157 --- ...ompiledAssemblyApiMetadataProviderTests.cs | 93 ++ .../CodeBlockRenderingServiceTests.cs | 6 +- .../Pennington.Tests/Pennington.Tests.csproj | 1 + 107 files changed, 1424 insertions(+), 7825 deletions(-) delete mode 100644 docs/Pennington.Docs/Content/tutorials/beyond-basics/connect-roslyn.md delete mode 100644 examples/BeyondRoslynExample/BeyondRoslynExample.csproj delete mode 100644 examples/BeyondRoslynExample/BeyondRoslynExample.slnx delete mode 100644 examples/BeyondRoslynExample/Components/CodeBlockRazorPage.razor delete mode 100644 examples/BeyondRoslynExample/Components/_Imports.razor delete mode 100644 examples/BeyondRoslynExample/Content/api-pulls.md delete mode 100644 examples/BeyondRoslynExample/Content/index.md delete mode 100644 examples/BeyondRoslynExample/Program.cs delete mode 100644 examples/BeyondRoslynExample/README.md delete mode 100644 examples/BeyondRoslynExample/Sample/BeyondRoslynExample.Sample.csproj delete mode 100644 examples/BeyondRoslynExample/Sample/Calculator.cs delete mode 100644 examples/BeyondRoslynExample/Sample/Greeter.cs delete mode 100644 examples/BeyondRoslynExample/Stage1_NoRoslyn.cs delete mode 100644 examples/BeyondRoslynExample/Stage2_AddRoslyn.cs create mode 100644 src/Pennington.ApiMetadata.Reflection/ReflectionInheritDocResolver.cs create mode 100644 src/Pennington.ApiMetadata.Reflection/ReflectionRecordParamFallback.cs delete mode 100644 src/Pennington.Roslyn/ApiMetadata/ApiReferenceOptions.cs delete mode 100644 src/Pennington.Roslyn/ApiMetadata/RoslynApiMetadataExtensions.cs delete mode 100644 src/Pennington.Roslyn/ApiMetadata/RoslynApiMetadataProvider.cs delete mode 100644 src/Pennington.Roslyn/Documentation/InheritDocResolver.cs delete mode 100644 src/Pennington.Roslyn/Documentation/RecordParamFallbackResolver.cs delete mode 100644 src/Pennington.Roslyn/Highlighting/RoslynHighlighter.cs delete mode 100644 src/Pennington.Roslyn/Highlighting/SyntaxHighlighter.cs delete mode 100644 src/Pennington.Roslyn/Pennington.Roslyn.csproj delete mode 100644 src/Pennington.Roslyn/Preprocessing/RoslynCodeBlockPreprocessor.cs delete mode 100644 src/Pennington.Roslyn/RoslynExtensions.cs delete mode 100644 src/Pennington.Roslyn/RoslynOptions.cs delete mode 100644 src/Pennington.Roslyn/Symbols/CodeFragmentExtractor.cs delete mode 100644 src/Pennington.Roslyn/Symbols/CodeFragmentResult.cs delete mode 100644 src/Pennington.Roslyn/Symbols/ISymbolExtractionService.cs delete mode 100644 src/Pennington.Roslyn/Symbols/RequiredUsingsAnalyzer.cs delete mode 100644 src/Pennington.Roslyn/Symbols/SymbolExtractionService.cs delete mode 100644 src/Pennington.Roslyn/Symbols/SymbolExtractionWarmupService.cs delete mode 100644 src/Pennington.Roslyn/Symbols/SymbolInfo.cs delete mode 100644 src/Pennington.Roslyn/Symbols/XmlDocIdNormalizer.cs delete mode 100644 src/Pennington.Roslyn/Utilities/TextFormatter.cs delete mode 100644 src/Pennington.Roslyn/Workspace/ISolutionWorkspaceService.cs delete mode 100644 src/Pennington.Roslyn/Workspace/SolutionWorkspaceService.cs delete mode 100644 tests/Pennington.IntegrationTests/DocsSite/ApiReferenceComponentTests.cs delete mode 100644 tests/Pennington.IntegrationTests/DocsSite/XmlDocIdFenceSweepSmokeTest.cs delete mode 100644 tests/Pennington.Roslyn.Tests/ApiMetadata/ApiReferenceOptionsRegistrationTests.cs delete mode 100644 tests/Pennington.Roslyn.Tests/Documentation/InheritDocResolverTests.cs delete mode 100644 tests/Pennington.Roslyn.Tests/Documentation/RoslynApiMetadataProviderTests.cs delete mode 100644 tests/Pennington.Roslyn.Tests/Documentation/XmlDocHtmlRendererTests.cs delete mode 100644 tests/Pennington.Roslyn.Tests/Documentation/XmlDocNodeAssertions.cs delete mode 100644 tests/Pennington.Roslyn.Tests/Documentation/XmlDocParserTests.cs delete mode 100644 tests/Pennington.Roslyn.Tests/GlobalUsings.cs delete mode 100644 tests/Pennington.Roslyn.Tests/Highlighting/RoslynHighlighterTests.cs delete mode 100644 tests/Pennington.Roslyn.Tests/Pennington.Roslyn.Tests.csproj delete mode 100644 tests/Pennington.Roslyn.Tests/Preprocessing/RoslynCodeBlockPreprocessorTests.cs delete mode 100644 tests/Pennington.Roslyn.Tests/Symbols/CodeFragmentExtractorTests.cs delete mode 100644 tests/Pennington.Roslyn.Tests/Symbols/RequiredUsingsAnalyzerTests.cs delete mode 100644 tests/Pennington.Roslyn.Tests/Symbols/XmlDocIdNormalizerTests.cs delete mode 100644 tests/Pennington.Roslyn.Tests/Utilities/TextFormatterTests.cs delete mode 100644 tests/Pennington.Roslyn.Tests/Workspace/SolutionWorkspaceServiceTests.cs create mode 100644 tests/Pennington.Tests/ApiMetadata/Reflection/CompiledAssemblyApiMetadataProviderTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 619ca3e8..726bd9c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,13 +14,11 @@ Content engine library targeting .NET 11 / C# 15 with union types. - `src/Pennington.MonorailCss/` — MonorailCSS integration (utility-first CSS generation) - `src/Pennington.DocSite/` — Documentation site template (layout, pages, content resolver) - `src/Pennington.BlogSite/` — Blog site template (home/archive/tag pages, blog front matter, content service) -- `src/Pennington.Roslyn/` — Optional Roslyn-based highlighting, symbol extraction, xmldocid code fragment preprocessor - `src/Pennington.TreeSitter/` — Optional tree-sitter-based multi-language code-fragment extraction (`:symbol` fence, name-path addressing) via the `TreeSitter.DotNet` package - `docs/Pennington.Docs/` — The Pennington docs site (Divio-style: tutorials, how-to, reference, explanation) - `examples/` — Variety of example sites used for reference and verification across scenarios - `tests/Pennington.Tests/` — Unit tests (xunit.v3, Shouldly) - `tests/Pennington.IntegrationTests/` — Integration tests (WebApplicationFactory) -- `tests/Pennington.Roslyn.Tests/` — Tests for the Roslyn package - `tests/Pennington.TreeSitter.Tests/` — Tests for the TreeSitter package (resolver/grammar configs, fragment service, render pipeline) ## Key Namespaces (Pennington core) @@ -44,7 +42,6 @@ Content engine library targeting .NET 11 / C# 15 with union types. - `services.AddPennington(...)` / `app.UsePennington()` / `app.RunOrBuildAsync(args)` — core - `services.AddDocSite(...)` / `app.UseDocSite()` / `app.RunDocSiteAsync(args)` — doc site template - `services.AddBlogSite(...)` — blog site template -- `services.AddPenningtonRoslyn(...)` — optional Roslyn highlighting + symbol services (C#/VB `:xmldocid` fence) - `services.AddPenningtonTreeSitter(...)` — optional tree-sitter multi-language `:symbol` fragment extraction (registers only when `ContentRoot` is set) ## Cross-Platform (WSL) diff --git a/Directory.Packages.props b/Directory.Packages.props index 9c07cb77..e47201a5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,12 +11,6 @@ - - - - - - @@ -28,7 +22,6 @@ - diff --git a/Pennington.slnf b/Pennington.slnf index 49c4bbfb..f6543db2 100644 --- a/Pennington.slnf +++ b/Pennington.slnf @@ -9,7 +9,6 @@ "src\\Pennington.DocSite.Api\\Pennington.DocSite.Api.csproj", "src\\Pennington.ApiMetadata\\Pennington.ApiMetadata.csproj", "src\\Pennington.ApiMetadata.Reflection\\Pennington.ApiMetadata.Reflection.csproj", - "src\\Pennington.Roslyn\\Pennington.Roslyn.csproj", "src\\Pennington.TreeSitter\\Pennington.TreeSitter.csproj", "src\\Pennington.TranslationAudit\\Pennington.TranslationAudit.csproj", "src\\Pennington.BlogSite\\Pennington.BlogSite.csproj", diff --git a/Pennington.slnx b/Pennington.slnx index cc01f1be..15e93ec9 100644 --- a/Pennington.slnx +++ b/Pennington.slnx @@ -7,7 +7,6 @@ - @@ -17,7 +16,6 @@ - @@ -38,8 +36,6 @@ - - diff --git a/README.md b/README.md index a587c6c1..684a2c8e 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,6 @@ dotnet add package Pennington.UI # Razor components dotnet add package Pennington.MonorailCss # Utility-first CSS dotnet add package Pennington.DocSite # Documentation site template dotnet add package Pennington.BlogSite # Blog site template -dotnet add package Pennington.Roslyn # Roslyn-based code extraction ``` ## Quick Start diff --git a/docs/Pennington.Docs/ApiReference/FrontMatterKeyIndex.cs b/docs/Pennington.Docs/ApiReference/FrontMatterKeyIndex.cs index 3a431f16..cdd7495f 100644 --- a/docs/Pennington.Docs/ApiReference/FrontMatterKeyIndex.cs +++ b/docs/Pennington.Docs/ApiReference/FrontMatterKeyIndex.cs @@ -1,10 +1,10 @@ namespace Pennington.Docs.ApiReference; using System.Collections.Immutable; -using Microsoft.CodeAnalysis; +using System.IO; +using System.Reflection; +using Pennington.FrontMatter; using Pennington.Infrastructure; -using Pennington.Roslyn.ApiMetadata; -using Pennington.Roslyn.Workspace; /// /// One YAML front-matter key observed on one or more concrete IFrontMatter implementations. @@ -19,8 +19,9 @@ internal sealed record FrontMatterKeyEntry( string XmlDocId); /// -/// Singleton that walks every public type implementing Pennington.FrontMatter.IFrontMatter in the solution -/// and projects their declared properties into a per-YAML-key catalog for the front-matter reference page. +/// Singleton that reflects over every public type implementing in the +/// referenced Pennington assemblies and projects their declared properties into a per-YAML-key +/// catalog for the front-matter reference page. Pure reflection — no compilation. /// internal sealed class FrontMatterKeyIndex { @@ -33,82 +34,37 @@ internal sealed class FrontMatterKeyIndex "IRedirectable", ]; - private readonly ISolutionWorkspaceService _workspace; - private readonly ApiReferenceOptions _options; private readonly AsyncLazy> _entries; - public FrontMatterKeyIndex(ISolutionWorkspaceService workspace, ApiReferenceOptions options) + public FrontMatterKeyIndex() { - _workspace = workspace; - _options = options; - _entries = new AsyncLazy>(BuildAsync); + _entries = new AsyncLazy>(() => Task.FromResult(Build())); } public Task> GetEntriesAsync() => _entries.Value; - private async Task> BuildAsync() + private static ImmutableArray Build() { - var filter = _options.ProjectFilter ?? ApiReferenceOptions.DefaultProjectFilter(); - var projects = await _workspace.GetProjectsAsync(p => filter(p)); - var observations = new List<(string YamlKey, string Clr, string TypeDisplay, string? DefaultValue, string Record, string Surface, string XmlDocId)>(); + var nullability = new NullabilityInfoContext(); - foreach (var project in projects) + foreach (var type in FrontMatterTypes()) { - var compilation = await _workspace.GetCompilationAsync(project); - if (compilation is null) - { - continue; - } - - foreach (var type in EnumerateTypes(compilation.Assembly.GlobalNamespace)) + foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) { - if (type.TypeKind is not (TypeKind.Class or TypeKind.Struct)) - { - continue; - } - - if (type.DeclaredAccessibility != Accessibility.Public) - { - continue; - } - - if (type.IsAbstract || type.IsStatic) - { - continue; - } - - if (!ImplementsFrontMatter(type)) + if (property.GetIndexParameters().Length > 0) { continue; } - foreach (var member in type.GetMembers()) - { - if (member is not IPropertySymbol { DeclaredAccessibility: Accessibility.Public } property) - { - continue; - } - - if (property.IsIndexer || property.IsStatic) - { - continue; - } - - var surface = ResolveDeclaringSurface(type, property.Name); - var defaultValue = ExtractDefault(property); - var typeDisplay = property.Type.ToDisplayString(TypeFormat); - var docId = property.GetDocumentationCommentId() ?? string.Empty; - - observations.Add(( - YamlKey: ToCamelCase(property.Name), - Clr: property.Name, - TypeDisplay: typeDisplay, - DefaultValue: defaultValue, - Record: type.Name, - Surface: surface, - XmlDocId: docId)); - } + observations.Add(( + YamlKey: ToCamelCase(property.Name), + Clr: property.Name, + TypeDisplay: TypeDisplay(property, nullability), + DefaultValue: ExtractDefault(property, nullability), + Record: type.Name, + Surface: ResolveDeclaringSurface(type, property.Name), + XmlDocId: "P:" + FullName(type) + "." + property.Name)); } } @@ -127,7 +83,6 @@ private async Task> BuildAsync() .Select(o => o.TypeDisplay) .Distinct(StringComparer.Ordinal) .ToList(); - var typeDisplay = types.Count == 1 ? types[0] : string.Join(" / ", types); var defaults = group @@ -135,7 +90,6 @@ private async Task> BuildAsync() .Where(d => d is not null) .Distinct(StringComparer.Ordinal) .ToList(); - var defaultValue = defaults.Count switch { 0 => null, @@ -160,95 +114,141 @@ private async Task> BuildAsync() .ToImmutableArray(); } - private static IEnumerable EnumerateTypes(INamespaceSymbol root) + private static IEnumerable FrontMatterTypes() { - var queue = new Queue(); - queue.Enqueue(root); - - while (queue.Count > 0) + var entry = Assembly.GetEntryAssembly()?.GetName().Name; + foreach (var path in Directory.EnumerateFiles(AppContext.BaseDirectory, "Pennington*.dll")) { - foreach (var member in queue.Dequeue().GetMembers()) + var name = Path.GetFileNameWithoutExtension(path); + if (string.Equals(name, entry, StringComparison.Ordinal)) { - if (member is INamespaceSymbol ns) - { - queue.Enqueue(ns); - } - else if (member is INamedTypeSymbol type) + continue; + } + + Assembly asm; + try { asm = Assembly.Load(new AssemblyName(name)); } + catch { continue; } + + Type[] types; + try { types = asm.GetTypes(); } + catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(t => t is not null).ToArray()!; } + catch { continue; } + + foreach (var t in types) + { + if (t is { IsPublic: true, IsAbstract: false } + && (t.IsClass || t.IsValueType) + && typeof(IFrontMatter).IsAssignableFrom(t)) { - yield return type; + yield return t; } } } } - private static readonly SymbolDisplayFormat TypeFormat = new( - genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, - miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes - | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier, - typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypes); - - private static string ResolveDeclaringSurface(INamedTypeSymbol record, string propertyName) + private static string ResolveDeclaringSurface(Type record, string propertyName) { - foreach (var iface in record.AllInterfaces) + foreach (var iface in record.GetInterfaces()) { if (!CapabilityInterfaces.Contains(iface.Name, StringComparer.Ordinal)) { continue; } - foreach (var member in iface.GetMembers().OfType()) + if (iface.GetProperties().Any(p => string.Equals(p.Name, propertyName, StringComparison.Ordinal))) { - if (string.Equals(member.Name, propertyName, StringComparison.Ordinal)) - { - return iface.Name; - } + return iface.Name; } } + return "record-local"; } - private static string? ExtractDefault(IPropertySymbol property) + private static string? ExtractDefault(PropertyInfo property, NullabilityInfoContext nullability) { - foreach (var reference in property.DeclaringSyntaxReferences) + // Property initializers (= 0, = "x") compile into the constructor and are not visible to + // reflection, so fall back to type heuristics when no initializer was present. + var type = property.PropertyType; + if (Nullable.GetUnderlyingType(type) is not null + || (!type.IsValueType && nullability.Create(property).ReadState == NullabilityState.Nullable)) { - var syntax = reference.GetSyntax(); - if (syntax is Microsoft.CodeAnalysis.CSharp.Syntax.PropertyDeclarationSyntax decl - && decl.Initializer?.Value is { } value) - { - return value.ToString(); - } + return "null"; } - if (property.NullableAnnotation == NullableAnnotation.Annotated) + if (type == typeof(bool)) { - return "null"; + return "false"; } - return property.Type.SpecialType switch + if (type == typeof(string)) { - SpecialType.System_Boolean => "false", - SpecialType.System_String => "\"\"", - _ => null, - }; + return "\"\""; + } + + return null; } - private static bool ImplementsFrontMatter(INamedTypeSymbol type) => - type.AllInterfaces.Any(i => - i.Name == "IFrontMatter" - && i.ContainingNamespace.ToDisplayString() == "Pennington.FrontMatter"); + private static string TypeDisplay(PropertyInfo property, NullabilityInfoContext nullability) + { + var display = TypeName(property.PropertyType); + if (!property.PropertyType.IsValueType + && nullability.Create(property).ReadState == NullabilityState.Nullable) + { + display += "?"; + } - private static string ToCamelCase(string name) + return display; + } + + private static string TypeName(Type t) { - if (string.IsNullOrEmpty(name)) + if (Nullable.GetUnderlyingType(t) is { } underlying) { - return name; + return TypeName(underlying) + "?"; } - if (char.IsLower(name[0])) + if (t.IsArray) + { + return TypeName(t.GetElementType()!) + "[]"; + } + + if (t.IsGenericType) + { + var name = t.Name; + var tick = name.IndexOf('`'); + var baseName = tick < 0 ? name : name[..tick]; + return baseName + "<" + string.Join(", ", t.GetGenericArguments().Select(TypeName)) + ">"; + } + + return t.FullName switch + { + "System.String" => "string", + "System.Boolean" => "bool", + "System.Int32" => "int", + "System.Int64" => "long", + "System.Double" => "double", + "System.Object" => "object", + _ => t.Name, + }; + } + + private static string FullName(Type t) + { + if (t.IsNested) + { + return FullName(t.DeclaringType!) + "." + t.Name; + } + + return string.IsNullOrEmpty(t.Namespace) ? t.Name : t.Namespace + "." + t.Name; + } + + private static string ToCamelCase(string name) + { + if (string.IsNullOrEmpty(name) || char.IsLower(name[0])) { return name; } return char.ToLowerInvariant(name[0]) + name[1..]; } -} \ No newline at end of file +} diff --git a/docs/Pennington.Docs/CLAUDE.md b/docs/Pennington.Docs/CLAUDE.md index 0691f34b..59f64ea1 100644 --- a/docs/Pennington.Docs/CLAUDE.md +++ b/docs/Pennington.Docs/CLAUDE.md @@ -68,7 +68,7 @@ The personality stays the same; the *register* shifts. ## Code-block embedding syntax -This site embeds source through tree-sitter `:symbol` fences (not Roslyn). Pennington preprocesses fenced code blocks whose info string ends in `:symbol` or `:symbol-diff`. The language before the colon (`csharp`, `razor`, `text`, etc.) drives highlighting. Body paths are relative to the repo root (the tree-sitter `ContentRoot`). Do not use `raw-file="…"` — that form is not parsed. +This site embeds source through tree-sitter `:symbol` fences. Pennington preprocesses fenced code blocks whose info string ends in `:symbol` or `:symbol-diff`. The language before the colon (`csharp`, `razor`, `text`, etc.) drives highlighting. Body paths are relative to the repo root (the tree-sitter `ContentRoot`). Do not use `raw-file="…"` — that form is not parsed. ### Embed a whole file — `:symbol` Body is one file path with no member path. Works for any language (markdown, razor, json, css, …) — whole-file embedding needs no grammar. @@ -110,8 +110,6 @@ Body must contain exactly 2 references (before/after), one per line. Supports th Requires `Pennington.TreeSitter` wired (`AddPenningtonTreeSitter`) with `ContentRoot` set. -> Some pages (`tutorials/beyond-basics/connect-roslyn.md`, `how-to/code-samples/focused-code-samples.md`) still document the Roslyn `:xmldocid`/`:path` fences as an optional library feature. That prose is historical — this site no longer executes those fences (`RoslynOptions.EnableCodeFragmentFences` is off). - ## Writing conventions ### Internal links: uid/xref, never URL paths diff --git a/docs/Pennington.Docs/Components/Index.razor b/docs/Pennington.Docs/Components/Index.razor index 85bace9b..c28f1748 100644 --- a/docs/Pennington.Docs/Components/Index.razor +++ b/docs/Pennington.Docs/Components/Index.razor @@ -279,8 +279,8 @@ for the current shape.
-

Syntax highlighting from the actual compiler.

-

Reference a symbol by xmldocid. Pennington.Roslyn pulls the source and hands it to the real semantic highlighter. Renames don’t break your docs.

+

Pull real source into your docs.

+

Reference a member by name path with :symbol. Pennington.TreeSitter extracts the declaration straight from source — no copy to drift out of sync.

  • - - Connect Roslyn + + Add a second locale
  • diff --git a/docs/Pennington.Docs/Components/Reference/FrontMatterKeys.razor b/docs/Pennington.Docs/Components/Reference/FrontMatterKeys.razor index 31947523..9d786b41 100644 --- a/docs/Pennington.Docs/Components/Reference/FrontMatterKeys.razor +++ b/docs/Pennington.Docs/Components/Reference/FrontMatterKeys.razor @@ -1,12 +1,11 @@ @* Renders the full catalog of YAML front-matter keys observed across every - IFrontMatter implementation in the solution. One row per YAML key; the body - prefixes "Applies to:" and "Declared on:" lines pulled from Roslyn. *@ + IFrontMatter implementation in the referenced Pennington assemblies. One row + per YAML key; the body prefixes "Applies to:" and "Declared on:" lines. *@ @using Pennington.Docs.ApiReference @inject FrontMatterKeyIndex Index -@inject IXmlDocParser Parser +@inject IServiceProvider Services @inject IXmlDocHtmlRenderer Renderer -@inject Pennington.Roslyn.Symbols.ISymbolExtractionService SymbolService @if (_rows.Count == 0) { @@ -22,16 +21,16 @@ else protected override void OnParametersSet() { + var provider = Services.GetRequiredKeyedService("default"); var entries = AsyncHelpers.RunSync(() => Index.GetEntriesAsync()); var rows = new List(entries.Length); foreach (var entry in entries) { - var info = string.IsNullOrEmpty(entry.XmlDocId) - ? null - : AsyncHelpers.RunSync(() => SymbolService.FindSymbolAsync(entry.XmlDocId)); - var parsed = info is null ? null : Parser.Parse(info.Symbol.GetDocumentationCommentXml()); - var summaryHtml = parsed?.HasSummary == true + var parsed = string.IsNullOrEmpty(entry.XmlDocId) + ? ParsedXmlDoc.Empty + : AsyncHelpers.RunSync(() => provider.GetXmldocAsync(entry.XmlDocId)); + var summaryHtml = parsed.HasSummary ? Renderer.RenderInlineHtml(parsed.Summary) : string.Empty; diff --git a/docs/Pennington.Docs/Components/Reference/_Imports.razor b/docs/Pennington.Docs/Components/Reference/_Imports.razor index 73bc5884..3a917428 100644 --- a/docs/Pennington.Docs/Components/Reference/_Imports.razor +++ b/docs/Pennington.Docs/Components/Reference/_Imports.razor @@ -1,7 +1,7 @@ @using System.Net @using Microsoft.AspNetCore.Components +@using Microsoft.Extensions.DependencyInjection @using Pennington.Docs.ApiReference @using Pennington.DocSite.Api.Components.Reference @using Pennington.ApiMetadata @using Pennington.Infrastructure -@using Pennington.Roslyn.Symbols diff --git a/docs/Pennington.Docs/Content/blog/api-reference-from-xmldocs.md b/docs/Pennington.Docs/Content/blog/api-reference-from-xmldocs.md index cfa0b4ed..71094287 100644 --- a/docs/Pennington.Docs/Content/blog/api-reference-from-xmldocs.md +++ b/docs/Pennington.Docs/Content/blog/api-reference-from-xmldocs.md @@ -1,12 +1,11 @@ --- title: API reference, generated from your XML docs -description: Pennington now builds API reference pages straight from Roslyn xmldocs — one page per type, Stripe-style definition lists, inherited members and union cases included. +description: Pennington now builds API reference pages straight from your assemblies' XML docs — one page per type, Stripe-style definition lists, inherited members and union cases included. author: Phil Scott date: 2026-04-28 isDraft: false tags: - api-reference - - roslyn --- API reference is the documentation that drifts fastest. Rename a parameter, add @@ -15,8 +14,9 @@ mentioned it is quietly wrong. Pennington can now generate those pages instead. ## Reference pages, generated from the source -Pennington builds API reference straight from the Roslyn workspace. Every public -type that carries an xmldoc gets its own page at `/reference/api/{type}/`, with +Pennington builds API reference straight from your compiled assemblies and their +XML docs. Every public type that carries an xmldoc gets its own page at +`/reference/api/{type}/`, with descriptions pulled from the `///` comments you already wrote. An index page lists every discovered type, grouped by namespace. @@ -44,5 +44,5 @@ source. The pages also serve two readers at once: paired `.humans-only` and `.robots-only` content gives the browser the visual layout and the [llms.txt sidecar](xref:how-to.feeds.llms-txt) a plain HTML version. To wire your -solution in, start with [connecting a Roslyn -solution](xref:tutorials.beyond-basics.connect-roslyn). +own library in, see [auto-generate an API reference +tree](xref:how-to.content-services.auto-api-reference). diff --git a/docs/Pennington.Docs/Content/blog/hot-reload.md b/docs/Pennington.Docs/Content/blog/hot-reload.md index b80ca4e2..d96802a4 100644 --- a/docs/Pennington.Docs/Content/blog/hot-reload.md +++ b/docs/Pennington.Docs/Content/blog/hot-reload.md @@ -1,6 +1,6 @@ --- title: A dev loop that keeps up -description: Edit a markdown file and the browser refreshes itself — WebSocket live reload, debounced file watching, and .cs hot reload that reaches into the Roslyn symbol cache. +description: Edit a markdown file and the browser refreshes itself — WebSocket live reload, debounced file watching, and .cs hot reload for embedded code samples. author: Phil Scott date: 2026-04-15 isDraft: false @@ -24,13 +24,10 @@ after a server restart. ## .cs edits, too -Code samples come from a live Roslyn workspace, so editing the C# they reference -should update the docs. At first it didn't: a `.cs` edit left `xmldocid` fences -rendering the pre-edit code. - -Now editing a `.cs` file clears the Roslyn symbol cache, so the next render -re-queries the patched solution and the embedded sample reflects what you just -typed. The watcher filters out `obj/`, `bin/`, and generated files, so a rebuild -burst doesn't thrash the cache. The [hot reload +Code samples come from source files via `:symbol` fences, so a sample reflects the +current source every time the docs render. Edit the referenced `.cs` and the next +render re-reads it, so the embedded sample reflects what you just typed — no copy +to keep in sync. The watcher filters out `obj/`, `bin/`, and generated files, so a +rebuild burst doesn't thrash anything. The [hot reload explanation](xref:explanation.dev-experience.hot-reload) covers how the watcher and the WebSocket fit together. diff --git a/docs/Pennington.Docs/Content/blog/introducing-pennington.md b/docs/Pennington.Docs/Content/blog/introducing-pennington.md index 33eff5e5..e16e5d26 100644 --- a/docs/Pennington.Docs/Content/blog/introducing-pennington.md +++ b/docs/Pennington.Docs/Content/blog/introducing-pennington.md @@ -1,6 +1,6 @@ --- title: Introducing Pennington -description: A content engine for .NET — markdown, Roslyn-powered code samples, and ready-made documentation and blog templates. +description: A content engine for .NET — markdown, live code samples pulled from source, and ready-made documentation and blog templates. author: Phil Scott date: 2026-04-04 isDraft: false @@ -46,13 +46,13 @@ the project for you. ## Code samples that stay in sync Code samples in docs are usually copy-pasted snippets: correct the day they're -written, slowly wrong after that. Pennington can pull samples from a Roslyn -workspace instead. You reference a real symbol, and the current source renders -at build time: +written, slowly wrong after that. Pennington can pull samples from your source +files instead. You reference a member by name path, and the current source +renders at build time: ````markdown -```csharp:xmldocid -T:Pennington.Pipeline.ContentPipeline +```csharp:symbol +src/Pennington/Pipeline/ContentPipeline.cs > ContentPipeline ``` ```` diff --git a/docs/Pennington.Docs/Content/blog/pennington-on-nuget.md b/docs/Pennington.Docs/Content/blog/pennington-on-nuget.md index ffd61cb2..3586ad51 100644 --- a/docs/Pennington.Docs/Content/blog/pennington-on-nuget.md +++ b/docs/Pennington.Docs/Content/blog/pennington-on-nuget.md @@ -24,9 +24,9 @@ dotnet add package Pennington.DocSite ``` `Pennington` is the core engine. `Pennington.DocSite` and `Pennington.BlogSite` -are the site templates, `Pennington.UI` carries the Razor components, -`Pennington.MonorailCss` handles styling, and `Pennington.Roslyn` adds the -Roslyn-backed code samples. Reference the ones you need and skip the rest. +are the site templates, `Pennington.UI` carries the Razor components, and +`Pennington.MonorailCss` handles styling. Reference the ones you need and skip +the rest. ## SourceLink and symbol packages diff --git a/docs/Pennington.Docs/Content/explanation/rendering/highlighting.md b/docs/Pennington.Docs/Content/explanation/rendering/highlighting.md index 69cef7d4..0556041d 100644 --- a/docs/Pennington.Docs/Content/explanation/rendering/highlighting.md +++ b/docs/Pennington.Docs/Content/explanation/rendering/highlighting.md @@ -4,22 +4,22 @@ description: "Why Pennington dispatches code fences through a priority-ordered c uid: explanation.rendering.highlighting order: 302020 sectionLabel: "Rendering and Theming" -tags: [highlighting, textmate, roslyn, cascade] +tags: [highlighting, textmate, cascade] --- A content engine that renders Markdown through Markdig could reasonably pick one syntax highlighter and ship it — so why does Pennington dispatch every fenced code block through a priority-ordered chain of highlighters that falls through to a plain-text fallback, instead of binding the pipeline to a single parser? ## Context -Pennington renders code in three very different shapes: shell sessions that want command-and-flag styling but no formal grammar, roughly eighty mainstream languages that need real tokenization but no semantic analysis, and C# samples pulled from a real solution via xmldocid fences that want Roslyn-grade classification — including type and member references the grammar alone cannot see. A single-parser design forces one of those shapes to lose. A Roslyn-only build is a non-starter for YAML, Bash, and TOML blocks; a TextMate-only build cannot tell `IReadOnlySet` apart from a user-defined generic; a bash-aware but otherwise plain build gives up nearly the full language surface the first time someone pastes a Python snippet. The design brief was therefore "layer, don't pick" — every shape is a highlighter, the ones that care most about a given language win, and nothing Markdig hands the service can make the pipeline throw. The fallback is not a politeness; it is the property that keeps `HighlightingService` total. +Pennington renders code in very different shapes: shell sessions that want command-and-flag styling but no formal grammar, and roughly eighty mainstream languages that need real tokenization. A single-parser design forces one of those shapes to lose. A shell-only build gives up nearly the full language surface the first time someone pastes a Python snippet; a TextMate-only build styles a bash command no differently from its flags. The design brief was therefore "layer, don't pick" — every shape is a highlighter, the ones that care most about a given language win, and nothing Markdig hands the service can make the pipeline throw. The fallback is not a politeness; it is the property that keeps `HighlightingService` total. ## How it works ### The priority chain -`HighlightingService` takes every registered `ICodeHighlighter` at construction, sorts once by descending `Priority`, and for each code block walks that list asking `SupportedLanguages.Contains(language)` — with `"*"` matching anything. The first hit wins; if none match, the service falls back to `PlainTextHighlighter`, which HTML-encodes the code and hands it back. The priority numbers form a tidy 0/50/75/100 ladder: `PlainTextHighlighter` at 0, `TextMateHighlighter` at 50 with `"*"` support so it claims any language it can find a grammar for, `ShellHighlighter` at 75 for `bash`/`shell`/`sh` specifically, and `RoslynHighlighter` at 100 for `csharp`/`cs`/`c#`/`vb`/`vbnet` when the Roslyn package is wired. +`HighlightingService` takes every registered `ICodeHighlighter` at construction, sorts once by descending `Priority`, and for each code block walks that list asking `SupportedLanguages.Contains(language)` — with `"*"` matching anything. The first hit wins; if none match, the service falls back to `PlainTextHighlighter`, which HTML-encodes the code and hands it back. The priority numbers form a tidy 0/50/75 ladder: `PlainTextHighlighter` at 0, `TextMateHighlighter` at 50 with `"*"` support so it claims any language it can find a grammar for, and `ShellHighlighter` at 75 for `bash`/`shell`/`sh` specifically. A higher-priority highlighter can be registered to claim specific languages above TextMate. -The cascade is specificity-ordered, not quality-ordered. Shell wins over TextMate for bash not because it produces better HTML in the abstract, but because it knows the one thing worth styling in a command fence — the command itself versus its flags. TextMate wins over plain because it has a real tokenizer. Roslyn wins over TextMate for C# because it can classify semantics the grammar cannot see. A new custom highlighter slots in by announcing a higher priority for the languages it cares about; it does not have to replace or remove anything that is already there. +The cascade is specificity-ordered, not quality-ordered. Shell wins over TextMate for bash not because it produces better HTML in the abstract, but because it knows the one thing worth styling in a command fence — the command itself versus its flags. TextMate wins over plain because it has a real tokenizer. A new custom highlighter slots in by announcing a higher priority for the languages it cares about; it does not have to replace or remove anything that is already there. The `HighlightingService` dispatcher is stateless past construction, so adding a highlighter via `HighlightingOptions.AddHighlighter` in DI is enough — no registry mutation, no re-sorting at runtime, no ordering surprise that depends on registration order. Priority is the only tiebreaker that matters. @@ -29,24 +29,22 @@ The `ICodeHighlighter` contract is three members — `SupportedLanguages`, `Prio The broad middle of the chain — every language that is not bash and not C# — runs through `TextMateHighlighter`, which loads TextMate grammars through TextMateSharp and tokenizes line by line. TextMate grammars are the same regex-state-machine format VS Code uses for its default highlighting, which buys Pennington roughly the full set of languages you've heard of in a single dependency, without compiling a parser, without building an AST, and without pulling a language service per language. The highlighter keeps a scope-to-hljs-class mapping table so the emitted HTML uses the familiar `hljs-keyword` / `hljs-string` / `hljs-type` class names, meaning the same CSS theme highlights Python, Rust, Go, and JSON uniformly. -The alternatives that were considered and rejected make the choice clearer. A Roslyn-only story covers two languages out of eighty and ships a heavy compiler dependency for zero value on the rest. A Prism or highlight.js port would require either a JavaScript runtime at build time or a reimplementation of dozens of grammars in C#; TextMateSharp inherits VS Code's grammar corpus for free. A hand-rolled regex-per-language table scales linearly with language count and loses the "paste a new fence, it works" property the first time someone wants Kotlin. TextMate's cost is real — it is a regex state machine, so it does not know that `Foo` on line 40 refers to the `class Foo` on line 2 — but that cost is precisely what the Roslyn corner is shaped to address. +The alternatives that were considered and rejected make the choice clearer. A single-language semantic parser covers a language or two out of eighty and ships a heavy dependency for zero value on the rest. A Prism or highlight.js port would require either a JavaScript runtime at build time or a reimplementation of dozens of grammars in C#; TextMateSharp inherits VS Code's grammar corpus for free. A hand-rolled regex-per-language table scales linearly with language count and loses the "paste a new fence, it works" property the first time someone wants Kotlin. TextMate's cost is real — it is a regex state machine, so it does not know that `Foo` on line 40 refers to the `class Foo` on line 2 — but a higher-priority highlighter can be slotted in for a language that needs more, which is exactly what the cascade is shaped to allow. The `"*"` entry in `TextMateHighlighter.SupportedLanguages` is load-bearing — it is how TextMate claims every language it can find a grammar for without having to enumerate the list at registration time, and it is what lets a new grammar added to the registry light up automatically. -### The Roslyn corner (deferred) +### Slotting in a higher-priority highlighter -The optional `Pennington.Roslyn` package registers `RoslynHighlighter` at priority 100, which beats TextMate at 50 for `csharp`/`cs`/`c#`/`vb`/`vbnet`. Unlike the TextMate case, Roslyn's advantage is not grammar coverage — TextMate already has a C# grammar — it is semantic classification. Roslyn's classifier can tell a type name apart from a method name apart from a local, can resolve generic arguments, and can annotate references to types that live in other files. That is the quality jump xmldocid fences need: when the Markdown preprocessor pulls a real method body out of a loaded solution via `RoslynCodeBlockPreprocessor`, the same package is already there to classify it properly. +The cascade is the extension mechanism. A highlighter that wants to claim a language TextMate already handles — say a semantic C# highlighter that can tell a type name apart from a method name, resolve generic arguments, and annotate references to types in other files — registers at a priority above 50 for `csharp`/`cs`/`c#`. When it is present, the only change to the cascade is that C# rises from "TextMate handles it" to "the new highlighter handles it"; every other language keeps its previous highlighter. -This highlighter is described as "deferred" because that is the user-facing shape of the feature. Pennington core does not take a Roslyn dependency; the base package ships with the three tokenizers and a plain-text fallback that together cover every site that does not need C#-specific treatment. The Roslyn corner is opt-in via `AddPenningtonRoslyn`, and when opted in, the only change to the cascade is that C# rises from "TextMate handles it" to "Roslyn handles it" — every other language keeps its previous highlighter. The cascade is the extension mechanism; `RoslynHighlighter` is its most prominent user. - -This is the same pattern a third-party highlighter would follow — declare the relevant languages, pick a priority that beats whatever is currently handling them, register. The cascade does not know or care where a highlighter came from. +Nothing in the core has to change to allow that. The base package ships the shell and TextMate tokenizers plus a plain-text fallback that together cover every site that does not need language-specific semantic treatment, and a higher-priority highlighter is purely additive — declare the relevant languages, pick a priority that beats whatever is currently handling them, register. The cascade does not know or care where a highlighter came from. ## Trade-offs - **Cost — priority numbers are global and implicit.** A custom highlighter that announces priority 60 silently displaces TextMate for its chosen languages; there is no central registry warning anyone that the decision happened. That is the price of "no mutable state past construction" — anyone can slot in, and anyone can accidentally claim a language they did not mean to. The 0/50/75/100 ladder leaves room between tiers deliberately. -- **Alternative considered — one parser chosen at build time.** Pick Roslyn for C# sites, TextMate for everyone else, wire the choice into DI. This was rejected because real sites mix languages — a docs site wants C# samples, bash install commands, YAML front-matter, and JSON configuration on the same page — and a single parser either wins one of those shapes and loses the rest, or has to pretend to be a cascade anyway. +- **Alternative considered — one parser chosen at build time.** Pick a single semantic parser for one language, TextMate for everyone else, wire the choice into DI. This was rejected because real sites mix languages — a docs site wants C# samples, bash install commands, YAML front-matter, and JSON configuration on the same page — and a single parser either wins one of those shapes and loses the rest, or has to pretend to be a cascade anyway. - **Alternative considered — no fallback, throw on unknown languages.** Rejected for the same reason Markdig extensions do not throw: a bad language tag on a fenced block is an authoring mistake, not a site-killing error. The plain-text fallback means an unrecognized `language-foo` renders as HTML-encoded text with a `language-foo` class attribute still on it, so the reader sees their code and the author sees their typo in the dev overlay. Totality is the property traded for a small reduction in loudness. -- **Consequence — grammar-level highlighters cannot understand semantics.** A TextMate highlighter sees tokens, not meanings; it cannot tell that the `Foo` on line 40 is the class declared on line 2. Sites that need that level of understanding for C# pick up `Pennington.Roslyn`; sites that do not accept the grammar-level approximation. The cascade does not hide this trade; it names it by letting Roslyn take over for C# when present and leaving TextMate responsible otherwise. +- **Consequence — grammar-level highlighters cannot understand semantics.** A TextMate highlighter sees tokens, not meanings; it cannot tell that the `Foo` on line 40 is the class declared on line 2. Sites that need that level of understanding can register a higher-priority semantic highlighter for the language; sites that do not accept the grammar-level approximation. The cascade does not hide this trade; it names it by letting a higher-priority highlighter take over a language when present and leaving TextMate responsible otherwise. ## Further reading diff --git a/docs/Pennington.Docs/Content/explanation/spa/islands.md b/docs/Pennington.Docs/Content/explanation/spa/islands.md index b63b0e4f..a3518a0a 100644 --- a/docs/Pennington.Docs/Content/explanation/spa/islands.md +++ b/docs/Pennington.Docs/Content/explanation/spa/islands.md @@ -50,7 +50,7 @@ The `` of the parsed response is the source of truth for everything page-s The round trip is small but not instant, and the earliest version of this engine wrapped each swap in `document.startViewTransition` with a 150ms cross-fade and offered a per-region skeleton placeholder for slower fetches. Both layers turned out to introduce more visible motion than they masked. The cross-fade is a flash for the eye to notice; the skeleton replaces real content with shimmer the moment the network takes longer than a tick. The engine now waits — old content stays on screen while the fetch runs — and the swap, scroll reset, and head update all execute in one synchronous block so the browser paints the new page as a single frame. Hover-prefetch hides the wait for the cases where it would otherwise be felt. -A top-of-viewport progress bar handles the unusual case where the response takes longer than the engine's silent threshold — a Roslyn cold start, a slow CDN edge. It only shows after the threshold elapses, so fast navigations never see it. +A top-of-viewport progress bar handles the unusual case where the response takes longer than the engine's silent threshold — a cold cache, a slow CDN edge. It only shows after the threshold elapses, so fast navigations never see it. ### Why one render path diff --git a/docs/Pennington.Docs/Content/how-to/code-samples/focused-code-samples.md b/docs/Pennington.Docs/Content/how-to/code-samples/focused-code-samples.md index c7536e16..c2ca47f7 100644 --- a/docs/Pennington.Docs/Content/how-to/code-samples/focused-code-samples.md +++ b/docs/Pennington.Docs/Content/how-to/code-samples/focused-code-samples.md @@ -1,17 +1,17 @@ --- title: "Embed focused code samples" -description: "Scope xmldocid fences to one member, strip declaration noise with bodyonly, and refactor long methods into named helpers so each section of a walkthrough shows one idea." +description: "Scope :symbol fences to one member, strip declaration noise with bodyonly, and refactor long methods into named helpers so each section of a walkthrough shows one idea." uid: how-to.code-samples.focused-code-samples order: 202030 sectionLabel: "Code Samples" -tags: [authoring, xmldocid, code-samples, roslyn] +tags: [authoring, symbol, code-samples, tree-sitter] --- -To limit a code fence to the one member a walkthrough discusses — rather than dumping the whole enclosing type with its xmldoc and every sibling property — use the xmldocid preprocessor's member-scoped forms. The recipes below scope a fence to a member, strip declaration noise, copy-paste the surrounding `using` directives, and diff two implementations. Prefer `:xmldocid` over `:path` wherever the source has a C# symbol — `:xmldocid` survives renames and line shifts that silently break `:path` fences. For the fence grammar itself, see . +To limit a code fence to the one member a walkthrough discusses — rather than dumping the whole enclosing file with every sibling member — use the `:symbol` preprocessor's member-scoped form. The recipes below scope a fence to a member, strip declaration noise, and diff two implementations. Address a member by its **name path** (`Type.Member`) rather than a hard-coded line range — a name path survives the line shifts that silently break a range. For the fence grammar itself, see . ## Before you begin -- An existing Pennington site (see if not), with `Pennington.Roslyn` wired through `AddPenningtonRoslyn` and `SolutionPath` pointing at the solution that owns the source to fence. -- Comfort authoring markdown code fences — the techniques on this page are all info-string changes on a `csharp:xmldocid` fence. +- An existing Pennington site (see if not), with `Pennington.TreeSitter` wired through `AddPenningtonTreeSitter` and `ContentRoot` pointing at the root that holds the source to fence. +- Comfort authoring markdown code fences — the techniques on this page are all info-string changes on a `csharp:symbol` fence. For a working setup, see [`examples/FocusedCodeSamplesExample`](https://github.com/usepennington/pennington/tree/main/examples/FocusedCodeSamplesExample). `MonolithWordCounter` carries one long `CountWords` method; `ModularWordCounter` splits the same logic into `Tokenize`, `Tally`, and `Format`. Both are referenced by the fences below. @@ -19,21 +19,21 @@ For a working setup, see [`examples/FocusedCodeSamplesExample`](https://github.c ## Fence one member, not the whole type -When the surrounding prose is about one method, reach for `M:Type.Method(...)` instead of `T:Type`. Member-scoped forms (`M:` for methods, `P:` for properties, `F:` for fields, `E:` for events) shrink the fence to the member the reader cares about. A `T:` fence pulls the full class declaration, its xmldoc, and every sibling member. +When the surrounding prose is about one method, reach for `Type.Method` instead of a bare `Type`. A member path shrinks the fence to the member the reader cares about; a `Type` reference (or a bare file path with no `>`) pulls the full type or file. The wide form, which lands on a page that only discusses `CountWords`: ````markdown -```csharp:xmldocid -T:FocusedCodeSamplesExample.MonolithWordCounter +```csharp:symbol +examples/FocusedCodeSamplesExample/MonolithWordCounter.cs > MonolithWordCounter ``` ```` The narrow form, scoped to the method under discussion: ````markdown -```csharp:xmldocid -M:FocusedCodeSamplesExample.MonolithWordCounter.CountWords(System.String,System.Int32) +```csharp:symbol +examples/FocusedCodeSamplesExample/MonolithWordCounter.cs > MonolithWordCounter.CountWords ``` ```` @@ -43,34 +43,16 @@ Which renders as: examples/FocusedCodeSamplesExample/MonolithWordCounter.cs > MonolithWordCounter.CountWords ``` -The xmldocid grammar for each member kind — `T:`, `M:`, `P:`, `F:`, `E:` — is listed in . +The name-path grammar — `Type`, `Type.Member`, nested `Type.Inner.Member` — is listed in . ## Strip declaration noise with `,bodyonly` -Even a member-scoped `M:` fence still carries the leading `/// ` xmldoc and the method signature. When the prose has already named the method and summarized what it does, both are redundant. Appending `,bodyonly` renders only the body between the braces. +Even a member-scoped fence still carries the signature and any leading doc comment. When the prose has already named the method and summarized what it does, both are redundant. Appending `,bodyonly` renders only the body between the braces. ````markdown -```csharp:xmldocid,bodyonly -M:FocusedCodeSamplesExample.MonolithWordCounter.CountWords(System.String,System.Int32) -``` -```` - -Which renders as: - ```csharp:symbol,bodyonly examples/FocusedCodeSamplesExample/MonolithWordCounter.cs > MonolithWordCounter.CountWords ``` - -`,bodyonly` also works on types (members between the braces, skipping the class header) and properties (the `get`/`set` accessors without the leading xmldoc). - -## Make the snippet copy-pasteable with `,usings` - -A `,bodyonly` fence shows the body, but a reader copying it into a fresh file still has to guess which `using` directives the body needs. Append `,usings` to prepend the file-local `using` directives the fragment actually references. Only the directives whose namespaces or aliases appear in the body are emitted — the rest of the file's using block is suppressed. - -````markdown -```csharp:xmldocid,bodyonly,usings -M:FocusedCodeSamplesExample.MonolithWordCounter.CountWords(System.String,System.Int32) -``` ```` Which renders as: @@ -79,17 +61,15 @@ Which renders as: examples/FocusedCodeSamplesExample/MonolithWordCounter.cs > MonolithWordCounter.CountWords ``` -The body uses `StringBuilder`, so `using System.Text;` lands above the snippet. `List<>`, `Dictionary<>`, and the `OrderByDescending` chain resolve through implicit/global usings — those are skipped by design, on the assumption that a reader who has `enable` already has them. - -`,usings` and `,bodyonly` compose in any order, and the flag works on full-declaration fences too. For multi-symbol fences (one XmlDocId per line), the required usings are unioned and rendered in a single block at the top. +`,bodyonly` also works on types (members between the braces, skipping the type header) and properties (the `get`/`set` accessors). ## Walk a multi-phase method through named helpers -When the target method runs 25+ lines across distinct phases, fence each phase as its own helper instead of fencing the monolith. `ModularWordCounter` is the same logic as `MonolithWordCounter` split into three helpers — `Tokenize`, `Tally`, and `Format` — orchestrated by a short `CountWords`. A `T:` fence on the whole class gives the reader the full picture in one place: +When the target method runs 25+ lines across distinct phases, fence each phase as its own helper instead of fencing the monolith. `ModularWordCounter` is the same logic as `MonolithWordCounter` split into three helpers — `Tokenize`, `Tally`, and `Format` — orchestrated by a short `CountWords`. A whole-type fence gives the reader the full picture in one place: ````markdown -```csharp:xmldocid -T:FocusedCodeSamplesExample.ModularWordCounter +```csharp:symbol +examples/FocusedCodeSamplesExample/ModularWordCounter.cs > ModularWordCounter ``` ```` @@ -102,20 +82,20 @@ examples/FocusedCodeSamplesExample/ModularWordCounter.cs > ModularWordCounter In a walkthrough, fence each helper separately so each section carries one idea: ````markdown -```csharp:xmldocid,bodyonly -M:FocusedCodeSamplesExample.ModularWordCounter.CountWords(System.String,System.Int32) +```csharp:symbol,bodyonly +examples/FocusedCodeSamplesExample/ModularWordCounter.cs > ModularWordCounter.CountWords ``` -```csharp:xmldocid,bodyonly -M:FocusedCodeSamplesExample.ModularWordCounter.Tokenize(System.String) +```csharp:symbol,bodyonly +examples/FocusedCodeSamplesExample/ModularWordCounter.cs > ModularWordCounter.Tokenize ``` -```csharp:xmldocid,bodyonly -M:FocusedCodeSamplesExample.ModularWordCounter.Tally(System.Collections.Generic.List{System.String},System.Int32) +```csharp:symbol,bodyonly +examples/FocusedCodeSamplesExample/ModularWordCounter.cs > ModularWordCounter.Tally ``` -```csharp:xmldocid,bodyonly -M:FocusedCodeSamplesExample.ModularWordCounter.Format(System.Collections.Generic.List{System.Collections.Generic.KeyValuePair{System.String,System.Int32}}) +```csharp:symbol,bodyonly +examples/FocusedCodeSamplesExample/ModularWordCounter.cs > ModularWordCounter.Format ``` ```` @@ -143,18 +123,18 @@ examples/FocusedCodeSamplesExample/ModularWordCounter.cs > ModularWordCounter.Ta examples/FocusedCodeSamplesExample/ModularWordCounter.cs > ModularWordCounter.Format ``` -Keep the helpers `public` — `internal` methods do not surface xmldoc and do not participate in the symbol table the preprocessor walks. +`:symbol` resolves a member by name path within the file, so give each helper a distinct name — overloads resolve to the first declaration and can't be told apart. -## Show a delta with `xmldocid-diff` +## Show a delta with `symbol-diff` -When the article's point is that one version replaces another — a small refactor, a migration, a perf tweak — fence both versions with `xmldocid-diff`. The preprocessor emits a unified diff so the reader sees the two or three lines that moved rather than comparing two fences by eye. The form works best when the delta is small; whole-method rewrites render every line as changed and bury the point. +When the article's point is that one version replaces another — a small refactor, a migration, a perf tweak — fence both versions with `symbol-diff`. The preprocessor emits a unified diff so the reader sees the two or three lines that moved rather than comparing two fences by eye. The form works best when the delta is small; whole-method rewrites render every line as changed and bury the point. `ModularWordCounter.FormatV2` is deliberately a one-change variant of `Format`. It rents its `StringBuilder` from a pool instead of constructing a fresh one, and returns the builder at the end. Everything else is identical, so the diff collapses to those lines. ````markdown -```csharp:xmldocid-diff,bodyonly -M:FocusedCodeSamplesExample.ModularWordCounter.Format(System.Collections.Generic.List{System.Collections.Generic.KeyValuePair{System.String,System.Int32}}) -M:FocusedCodeSamplesExample.ModularWordCounter.FormatV2(System.Collections.Generic.List{System.Collections.Generic.KeyValuePair{System.String,System.Int32}}) +```csharp:symbol-diff,bodyonly +examples/FocusedCodeSamplesExample/ModularWordCounter.cs > ModularWordCounter.Format +examples/FocusedCodeSamplesExample/ModularWordCounter.cs > ModularWordCounter.FormatV2 ``` ```` @@ -165,14 +145,14 @@ examples/FocusedCodeSamplesExample/ModularWordCounter.cs > ModularWordCounter.Fo examples/FocusedCodeSamplesExample/ModularWordCounter.cs > ModularWordCounter.FormatV2 ``` -The fence body must hold exactly two xmldocids, one per line, in before → after order. `,bodyonly` applies to both sides, so the diff compares implementations without xmldoc boilerplate drowning out the change. +The fence body must hold exactly two references, one per line, in before → after order. `,bodyonly` applies to both sides, so the diff compares implementations without declaration boilerplate drowning out the change. -## Embed files without a C# symbol via `:path` +## Embed a whole file with a bare path -Top-level-statement `Program.cs` files, `.razor` components, markdown or YAML fixtures, and JSON / TOML / config files have no symbol for `:xmldocid` to target. `:path` embeds the whole file by path relative to the solution directory: +Top-level-statement `Program.cs` files, `.razor` components, markdown or YAML fixtures, and JSON / TOML / config files have no member to scope to. A bare `` reference with no `> member` embeds the entire file: ````markdown -```csharp:path +```csharp:symbol examples/FocusedCodeSamplesExample/Program.cs ``` ```` @@ -181,13 +161,12 @@ examples/FocusedCodeSamplesExample/Program.cs ## Verify -- Rebuild the site with `dotnet run --project docs/Pennington.Docs -- build` and reload the page — each fence renders at the scope its info string declares, with no carry-over of enclosing-type xmldoc. +- Rebuild the site with `dotnet run --project docs/Pennington.Docs -- build` and reload the page — each fence renders at the scope its info string declares, with no carry-over of enclosing-type members. - Grep `output/**/*.html` for `
    ` elements taller than 25 lines — those are candidates for a `,bodyonly` or member-scoped follow-up pass.
    -- Rename `Tokenize` to `Split` in `examples/FocusedCodeSamplesExample/ModularWordCounter.cs` and rebuild — the build report surfaces an unresolved `M:…Tokenize(…)` rather than silently rendering nothing.
    +- Rename `Tokenize` to `Split` in `examples/FocusedCodeSamplesExample/ModularWordCounter.cs` and rebuild — the build report surfaces an unresolved `ModularWordCounter.Tokenize` reference rather than silently rendering nothing.
     
     ## Related
     
    -- Reference: [Markdown extensions catalog](xref:reference.markdown.extensions) — the full fence grammar including `xmldocid`, `xmldocid,bodyonly`, `xmldocid-diff`, and `path`.
    +- Reference: [Markdown extensions catalog](xref:reference.markdown.extensions) — the full fence grammar including `symbol`, `symbol,bodyonly`, and `symbol-diff`.
     - Reference: [Code-block argument reference](xref:reference.markdown.code-block-args) — info-string parser details and the full list of suffix forms.
     - How-to: [Annotate code blocks](xref:how-to.code-samples.code-annotations) — per-line `[!code highlight]` / `[!code ++]` directives that compose with the fence forms on this page.
    -- Reference: [RoslynOptions](xref:reference.api.roslyn-options) — the `SolutionPath` setting that lets the preprocessor resolve `T:` / `M:` / `P:` targets.
    diff --git a/docs/Pennington.Docs/Content/how-to/content-services/auto-api-reference.md b/docs/Pennington.Docs/Content/how-to/content-services/auto-api-reference.md
    index ad7ec284..16c4d4be 100644
    --- a/docs/Pennington.Docs/Content/how-to/content-services/auto-api-reference.md
    +++ b/docs/Pennington.Docs/Content/how-to/content-services/auto-api-reference.md
    @@ -1,37 +1,21 @@
     ---
     title: "Auto-generate an API reference tree for a class library"
    -description: "Wire a metadata backend (Roslyn workspace or a compiled .dll + .xml pair), call AddApiReference, and get one /reference/api/{type}/ page per public type plus inline Mdazor components for member tables, summaries, and extension-method catalogs."
    +description: "Wire the reflection metadata backend (a compiled .dll + .xml pair), call AddApiReference, and get one /reference/api/{type}/ page per public type plus inline Mdazor components for member tables, summaries, and extension-method catalogs."
     uid: how-to.content-services.auto-api-reference
     order: 208020
     sectionLabel: "Content Services"
    -tags: [extensibility, roslyn, reflection, xmldoc, api-reference, content-service]
    +tags: [extensibility, reflection, xmldoc, api-reference, content-service]
     ---
     
     To ship a DocSite whose reference section stays in sync with a class library's public surface, register a metadata backend and call `AddApiReference()`. One Razor template renders every public type, and a handful of Mdazor components (``, ``, ``, ``) are available inline in markdown for hand-authored reference pages. Every downstream page, search entry, and xref keys off a single pass over the configured backend.
     
    -Two backends are available:
    -
    -- `Pennington.Roslyn.ApiMetadata.AddApiMetadataFromRoslyn()` — walks a live Roslyn workspace. Use when you build the documented library from source alongside the docs host.
    -- `Pennington.ApiMetadata.Reflection.AddApiMetadataFromCompiledAssembly()` — reflects over a compiled `.dll` and parses the companion xmldoc `.xml` file. Use when you document a third-party assembly (for example, a NuGet package) without vendoring its source.
    +`Pennington.ApiMetadata.Reflection.AddApiMetadataFromCompiledAssembly()` is the metadata backend: it reflects over a compiled `.dll` and parses the companion xmldoc `.xml` file. It documents any assembly — one you build alongside the docs host, or a third-party NuGet package — without needing its source.
     
     ## Before you begin
     
     - `AddDocSite` is already wired: `AddApiReference` appends its own assembly to `DocSiteOptions.AdditionalRoutingAssemblies` at registration time, so it must run after `AddDocSite`.
     - One metadata backend is registered before `AddApiReference`. Without one, the content service has nothing to publish.
     
    -## Wire the Roslyn backend
    -
    -Add project references to `Pennington.Roslyn` and `Pennington.DocSite.Api`, then call `AddApiMetadataFromRoslyn` followed by `AddApiReference` after `AddDocSite`. With no options, the default `ProjectFilter` excludes `*.Tests` / `*.IntegrationTests` projects and the entry assembly itself.
    -
    -```csharp
    -builder.Services.AddDocSite(() => new DocSiteOptions { /* ... */ });
    -builder.Services.AddPenningtonRoslyn(r => r.SolutionPath = "../MyLibrary.slnx");
    -builder.Services.AddApiMetadataFromRoslyn();
    -builder.Services.AddApiReference();
    -```
    -
    -The target library needs `true` — without that, `ISymbol.GetDocumentationCommentXml()` returns empty strings and the generated pages have no prose.
    -
     ## Wire the reflection backend
     
     Add a project reference to `Pennington.ApiMetadata.Reflection`, add a `` to the library you want to document, and have Pennington resolve the assembly by simple name. A complete single-package DocSite host:
    @@ -49,7 +33,7 @@ builder.Services.AddApiMetadataFromCompiledAssembly(opts =>
         opts.AssemblyFiles.Add(Path.Combine(builder.Environment.ContentRootPath, "lib", "net9.0", "Foo.dll")));
     ```
     
    -The reflection backend inspects metadata without running the assembly's code — no MSBuild workspace, no source needed. `` and the `:xmldocid` source fence require a live symbol graph and are unavailable under this backend.
    +The reflection backend inspects metadata without running the assembly's code — no MSBuild workspace, no source needed. It resolves ``, union cases, and `` from metadata. Only the `:xmldocid` source fence is unavailable under this backend, since it extracts source text rather than metadata.
     
     ## Customize the route prefix
     
    @@ -90,31 +74,19 @@ Each `FromPackageReference` call resolves one DLL from its matching ``.
     
    -Pass a predicate when a single solution mixes libraries you want to document with ones you do not — integration fixtures, sample apps, unrelated utility projects. The predicate receives the Roslyn `Project` directly, so filters on `Name`, `AssemblyName`, or language work equally well.
    +Use `AssemblyFiles` to document an explicit list of `.dll` paths when a folder holds more assemblies than you want documented — for example, dependencies copied alongside the target only so `MetadataLoadContext` can resolve them:
     
     ```csharp
    -builder.Services.AddApiMetadataFromRoslyn(opts =>
    +builder.Services.AddApiMetadataFromCompiledAssembly(opts =>
     {
    -    opts.ProjectFilter = project =>
    -        project.Name.StartsWith("MyLibrary", StringComparison.Ordinal)
    -        && !project.Name.EndsWith(".Tests", StringComparison.Ordinal);
    +    opts.AssemblyFiles.Add(Path.Combine(libDir, "MyLibrary.dll"));
    +    opts.AssemblyFiles.Add(Path.Combine(libDir, "MyLibrary.Extensions.dll"));
     });
     ```
     
    -### Hide individual types with `TypeFilter`
    -
    -`TypeFilter` runs on top of the built-in rules (public, non-delegate, non-attribute, non-`ComponentBase`, has xmldoc). Use it to drop a namespace that is public only by build necessity, or to skip types tagged with a marker attribute.
    -
    -```csharp
    -builder.Services.AddApiMetadataFromRoslyn(opts =>
    -{
    -    opts.TypeFilter = type =>
    -        type.ContainingNamespace.ToDisplayString() != "MyLibrary.Internal"
    -        && !type.GetAttributes().Any(a => a.AttributeClass?.Name == "InternalApiAttribute");
    -});
    -```
    +Use `AssemblyDirectories` instead to document every `.dll`/`.xml` pair in a folder — the typical NuGet `lib//` layout.
     
     ## Render reference fragments inline
     
    @@ -150,7 +122,7 @@ Pass a method xmldocid (`M:...`). The table pulls parameter names and types from
     
     ### Catalog extension methods by receiver with ``
     
    -Groups every public extension method in the workspace by the unqualified short name of its first (receiver) parameter. `Receiver="IServiceCollection"` gathers every `services.AddX()` helper the library ships. Roslyn-only; the DocFx backend returns an empty list.
    +Groups every public extension method in the assembly by the unqualified short name of its first (receiver) parameter. `Receiver="IServiceCollection"` gathers every `services.AddX()` helper the library ships.
     
     ````markdown
     
    @@ -176,6 +148,5 @@ Xref links like `` resolve, the pages flow
     
     ## Related
     
    -- Tutorial:  — the `AddPenningtonRoslyn` / `SolutionPath` wire-up the Roslyn backend builds on.
     - How-to:  — hand-write an `IContentService` when `AddApiReference`'s discovery rules are not the right shape.
     - Reference: , .
    diff --git a/docs/Pennington.Docs/Content/how-to/markdown-pipeline/code-block-preprocessor.md b/docs/Pennington.Docs/Content/how-to/markdown-pipeline/code-block-preprocessor.md
    index 579fbe81..5d493548 100644
    --- a/docs/Pennington.Docs/Content/how-to/markdown-pipeline/code-block-preprocessor.md
    +++ b/docs/Pennington.Docs/Content/how-to/markdown-pipeline/code-block-preprocessor.md
    @@ -14,7 +14,7 @@ The recipe references `examples/ExtensibilityLabExample/LineCountPreprocessor.cs
     ## Before you begin
     
     - An existing Pennington site with markdown rendering wired (see  if not).
    -- A chosen fence identifier — either a full `languageId` (`linecount`) or a `:modifier` suffix (`csharp:xmldocid`).
    +- A chosen fence identifier — either a full `languageId` (`linecount`) or a `:modifier` suffix (`csharp:symbol`).
     
     ## Write the preprocessor
     
    @@ -28,11 +28,11 @@ The returned `CodeBlockPreprocessResult` carries the pre-rendered HTML, the `Bas
     
     ## Pick a Priority value
     
    -`CodeHighlightRenderer` sorts preprocessors by `Priority` descending and returns the first non-null result. The shipped `RoslynCodeBlockPreprocessor` uses `100`; `LineCountPreprocessor` uses `500` so its `linecount` fence is never intercepted by a lower-priority modifier preprocessor. Pick above `100` to win against the Roslyn preprocessor on a contested `:modifier`; pick below `100` to fall through to Roslyn first.
    +`CodeHighlightRenderer` sorts preprocessors by `Priority` descending and returns the first non-null result. `LineCountPreprocessor` uses `500` so its `linecount` fence is never intercepted by a lower-priority modifier preprocessor. Pick a value above any preprocessor you need to beat on a contested `:modifier`, or below it to fall through first.
     
     ## Register the implementation
     
    -Pennington collects every `ICodeBlockPreprocessor` from DI. Register anywhere after `AddPennington` — there is no `PenningtonOptions` knob. `AddPenningtonRoslyn` performs the equivalent registration for `RoslynCodeBlockPreprocessor`.
    +Pennington collects every `ICodeBlockPreprocessor` from DI. Register anywhere after `AddPennington` — there is no `PenningtonOptions` knob. `AddPenningtonTreeSitter` performs the equivalent registration for its `:symbol` preprocessor.
     
     ```csharp
     builder.Services.AddSingleton();
    diff --git a/docs/Pennington.Docs/Content/llms.txt b/docs/Pennington.Docs/Content/llms.txt
    index 738bcb65..f82ab0e9 100644
    --- a/docs/Pennington.Docs/Content/llms.txt
    +++ b/docs/Pennington.Docs/Content/llms.txt
    @@ -2,4 +2,4 @@
     
     > A content engine for .NET that transforms markdown into static documentation sites and blogs.
     
    -Pennington is a library for building content-driven .NET websites. It provides a pipeline that discovers markdown files, parses YAML front matter, renders HTML with syntax highlighting, and generates static sites. Key features include hot reload during development, SPA-style navigation, pluggable code highlighting (including Roslyn-powered semantic highlighting), and utility-first CSS via MonorailCSS.
    +Pennington is a library for building content-driven .NET websites. It provides a pipeline that discovers markdown files, parses YAML front matter, renders HTML with syntax highlighting, and generates static sites. Key features include hot reload during development, SPA-style navigation, pluggable code highlighting, and utility-first CSS via MonorailCSS.
    diff --git a/docs/Pennington.Docs/Content/reference/host/extensions.md b/docs/Pennington.Docs/Content/reference/host/extensions.md
    index 8e5154d2..2a2ce7e1 100644
    --- a/docs/Pennington.Docs/Content/reference/host/extensions.md
    +++ b/docs/Pennington.Docs/Content/reference/host/extensions.md
    @@ -1,6 +1,6 @@
     ---
     title: "DI and middleware extension methods"
    -description: "Index of every AddPennington/UsePennington/Run* extension method across the Pennington, DocSite, BlogSite, MonorailCSS, and Roslyn packages."
    +description: "Index of every AddPennington/UsePennington/Run* extension method across the Pennington, DocSite, BlogSite, and MonorailCSS packages."
     sectionLabel: "Host Integration"
     order: 406010
     tags: [host, di, middleware, extensions]
    diff --git a/docs/Pennington.Docs/Content/reference/markdown/code-block-args.md b/docs/Pennington.Docs/Content/reference/markdown/code-block-args.md
    index cdffedc1..bf5b1797 100644
    --- a/docs/Pennington.Docs/Content/reference/markdown/code-block-args.md
    +++ b/docs/Pennington.Docs/Content/reference/markdown/code-block-args.md
    @@ -14,10 +14,8 @@ The fence info-string grammar: the opening-fence text after the three backticks,
     ```text
     info-string   := language [ ":" suffix ] ( WS attribute )*
     language      := IDENT
    -suffix        := "path"
    -              |  "xmldocid" xmldocid-flags
    -              |  "xmldocid-diff" [ ",bodyonly" ]
    -xmldocid-flags := ( "," ( "bodyonly" | "usings" ) )*
    +suffix        := "symbol" [ ",bodyonly" ]
    +              |  "symbol-diff" [ ",bodyonly" ]
     attribute     := key "=" value
     key           := IDENT
     value         := bare-value | "'" quoted-value "'" | '"' quoted-value '"'
    @@ -25,7 +23,7 @@ bare-value    := any run of non-whitespace chars
     quoted-value  := any chars up to the matching quote
     ```
     
    -`language` is typically `csharp`, `razor`, `text`, etc. `xmldocid-flags` may appear in any order. Quoting is required only when a value contains whitespace. Markdig exposes the language and colon-suffix on `FencedCodeBlock.Info` and the attribute tail on `FencedCodeBlock.Arguments`; attribute keys are matched case-insensitively.
    +`language` is typically `csharp`, `razor`, `text`, etc. Quoting is required only when a value contains whitespace. Markdig exposes the language and colon-suffix on `FencedCodeBlock.Info` and the attribute tail on `FencedCodeBlock.Arguments`; attribute keys are matched case-insensitively.
     
     ## Attributes
     
    @@ -38,13 +36,11 @@ quoted-value  := any chars up to the matching quote
     
     | Form | Body shape | Description |
     |---|---|---|
    -| `:path` | one file path relative to the solution directory | Embeds the entire file contents. Accepts any file type. |
    -| `:xmldocid` | one XmlDocId per line (`T:`, `M:`, `P:`, `F:`, `E:`) | Embeds each symbol's declaration and body, concatenated in order. |
    -| `:xmldocid,bodyonly` | one XmlDocId per line | Embeds only the member body, stripping the declaration line and enclosing braces. |
    -| `:xmldocid,usings` | one XmlDocId per line | Prepends the file-local `using` directives the fragment references, unioned across all listed XmlDocIds. Composes with `,bodyonly` in any order. Skips `global using` directives and implicit usings. |
    -| `:xmldocid-diff` | exactly two XmlDocIds, before then after | Emits a unified diff between the two symbols' source text. Accepts the `,bodyonly` suffix. |
    +| `:symbol` | one `` path, optionally followed by `> Member.Path`, per line | Embeds the whole file, or the named member's declaration and body. Concatenated in order. |
    +| `:symbol,bodyonly` | same as `:symbol` | Embeds only the member body, stripping the declaration line and enclosing braces. |
    +| `:symbol-diff` | exactly two references, before then after | Emits a unified diff between the two members' source text. Accepts the `,bodyonly` suffix. |
     
    -Suffix forms are resolved by an `ICodeBlockPreprocessor`; `Pennington.Roslyn` ships the implementations for `xmldocid` and `xmldocid-diff`.
    +Suffix forms are resolved by an `ICodeBlockPreprocessor`; `Pennington.TreeSitter` ships the implementations for `symbol` and `symbol-diff`.
     
     ## `[!code …]` directives
     
    diff --git a/docs/Pennington.Docs/Content/reference/ui/content.md b/docs/Pennington.Docs/Content/reference/ui/content.md
    index 8e54f509..962a953c 100644
    --- a/docs/Pennington.Docs/Content/reference/ui/content.md
    +++ b/docs/Pennington.Docs/Content/reference/ui/content.md
    @@ -131,7 +131,7 @@ Run `dotnet run` and visit `http://localhost:5000/`.
     
     ## `CodeBlock`
     
    -Razor-page entry to the shared code-block rendering pipeline — registered `ICodeBlockPreprocessor` implementations (including Roslyn `:xmldocid` and `:path` fences when `AddPenningtonRoslyn` is wired), highlighter dispatch via `HighlightingService`, `[!code …]` line transformations, and the standard `code-highlight-wrapper` container. Not registered with Mdazor — markdown authors should use a fenced code block (same pipeline, same output shape) instead.
    +Razor-page entry to the shared code-block rendering pipeline — registered `ICodeBlockPreprocessor` implementations (including tree-sitter `:symbol` fences when `AddPenningtonTreeSitter` is wired), highlighter dispatch via `HighlightingService`, `[!code …]` line transformations, and the standard `code-highlight-wrapper` container. Not registered with Mdazor — markdown authors should use a fenced code block (same pipeline, same output shape) instead.
     
     ### Parameters
     
    diff --git a/docs/Pennington.Docs/Content/tutorials/beyond-basics/connect-roslyn.md b/docs/Pennington.Docs/Content/tutorials/beyond-basics/connect-roslyn.md
    deleted file mode 100644
    index 1e249361..00000000
    --- a/docs/Pennington.Docs/Content/tutorials/beyond-basics/connect-roslyn.md
    +++ /dev/null
    @@ -1,287 +0,0 @@
    ----
    -title: "Connect to a Roslyn solution for live API snippets"
    -description: "Point Pennington at a .sln, pull method and class source into markdown with xmldocid fences, and watch hot reload refresh snippets when the source changes."
    -sectionLabel: Beyond the Basics
    -order: 104020
    -tags: [roslyn, api-docs, xmldocid, hot-reload]
    -uid: tutorials.beyond-basics.connect-roslyn
    ----
    -
    -By the end of this tutorial, your DocSite host loads a sibling C# class library through an inner `.slnx` and renders live `Calculator` and `Greeter` source inside a markdown page via `csharp:xmldocid` fences. You'll see how to register `Pennington.Roslyn`, set `SolutionPath`, write the three fence variants (`:xmldocid`, `:xmldocid,bodyonly`, and multi-symbol), and confirm that hot reload refreshes snippets when the backing source changes.
    -
    -## Prerequisites
    -
    -- .NET 11 SDK installed
    -- Completed [Scaffold a documentation site with DocSite](xref:tutorials.docsite.scaffold) (or have an equivalent DocSite host ready)
    -- A C# project or class library to fence into docs (we'll build a tiny one in unit 1)
    -
    -The finished code for this tutorial lives in [`examples/BeyondRoslynExample`](https://github.com/usepennington/pennington/tree/main/examples/BeyondRoslynExample).
    -
    ----
    -
    -## 1. Give your host a sibling library to fence
    -
    -Before `Pennington.Roslyn` can pull source, there needs to be a `.slnx` listing the project that holds the types to embed. This unit stands up the dual-project shape the rest of the tutorial uses.
    -
    -
    -
    -
    -**Review the starting DocSite host**
    -
    -This is the plain DocSite from the scaffold tutorial with no Roslyn wired yet. A `csharp:xmldocid` fence dropped into a markdown page right now renders as a literal code block, because no preprocessor is registered.
    -
    -```csharp:symbol,bodyonly
    -examples/BeyondRoslynExample/Stage1_NoRoslyn.cs > Stage1.Run
    -```
    -
    -
    -
    -
    -**Add a sibling `Sample` class library**
    -
    -From the host folder, scaffold the class library and add `GenerateDocumentationFile=true` so XmlDocId lookups resolve:
    -
    -```bash
    -dotnet new classlib -n BeyondRoslynExample.Sample -o Sample
    -```
    -
    -Then add this property to the host csproj — without it, the two projects compete over the same `.cs` files and the host build fails with duplicate-compile errors:
    -
    -```xml
    -$(DefaultItemExcludes);Sample\**
    -```
    -
    -`$(DefaultItemExcludes)` preserves the SDK's own excludes (bin/obj); the semicolon-separated `Sample\**` glob adds the sibling library on top.
    -
    -
    -
    -
    -**Add two small types to fence**
    -
    -Drop the two source files below into `Sample/`. They are the symbols the rest of the tutorial points at; the XML doc comments are what make them addressable by XmlDocId. (The fences below render from the in-repo example so the page can show what the disk file looks like — they are not yet wired into your local project.)
    -
    -```csharp:symbol
    -examples/BeyondRoslynExample/Sample/Calculator.cs > Calculator
    -```
    -
    -```csharp:symbol
    -examples/BeyondRoslynExample/Sample/Greeter.cs > Greeter
    -```
    -
    -
    -
    -
    -**Write an inner `BeyondRoslynExample.slnx`**
    -
    -Create an inner `.slnx` that registers only the Sample library. `SolutionPath` points at this file rather than the outer repo-level solution, so the MSBuild workspace loads exactly the source to fence into docs.
    -
    -```xml:symbol
    -examples/BeyondRoslynExample/BeyondRoslynExample.slnx
    -```
    -
    -> [!NOTE]
    -> On the .NET 11 preview SDK, `dotnet new sln` emits an XML `.slnx` by default. If you prefer the legacy `.sln` format, pass `--format sln`. `SolutionPath` accepts either extension — `Pennington.Roslyn` uses `Microsoft.CodeAnalysis.MSBuild.MSBuildWorkspace`, which opens both.
    -
    -
    -
    -
    -
    -
    -- Run `dotnet build` on both csprojs — they compile independently
    -- `BeyondRoslynExample.slnx` lives next to the host csproj and lists only `Sample/BeyondRoslynExample.Sample.csproj`
    -- Run `dotnet run` on the host — DocSite still serves, nothing has changed in the browser yet
    -
    -
    -
    ----
    -
    -## 2. Register `Pennington.Roslyn` and set `SolutionPath`
    -
    -A single DI call turns on the xmldocid preprocessor. Once `AddPenningtonRoslyn` runs with `SolutionPath` set, every markdown page in the content folder gains the `:xmldocid`, `:xmldocid,bodyonly`, `:xmldocid-diff`, and `:path` fence modifiers.
    -
    -> [!IMPORTANT]
    -> **`Pennington.Roslyn` requires three package references**, not one. `Pennington.Roslyn` itself, `Microsoft.CodeAnalysis.Workspaces.MSBuild`, and `Microsoft.Build.Framework` (with runtime excluded). Skipping either of the last two leaves the MSBuild workspace unable to launch its out-of-process `BuildHost`, and every `csharp:xmldocid` fence renders an error comment instead of source. The full csproj fragment is in the next step.
    -
    -
    -
    -
    -**Add the three package references**
    -
    -Add all three to the host csproj. `Pennington.Roslyn` brings in [`SyntaxHighlighter`](xref:reference.api.syntax-highlighter) and [`RoslynCodeBlockPreprocessor`](xref:reference.api.roslyn-code-block-preprocessor); the other two are runtime requirements of `Microsoft.CodeAnalysis.MSBuild.MSBuildWorkspace`.
    -
    -```xml
    - 
    - 
    - 
    -```
    -
    -`Microsoft.CodeAnalysis.Workspaces.MSBuild` ships the `BuildHost-netcore/` content DLLs the workspace launches at solution-load time. Without it, every `csharp:xmldocid` fence renders an `` comment. The `Microsoft.Build.Framework` reference (with runtime excluded) silences the MSBuild-locator resolution error without changing runtime behaviour.
    -
    -
    -
    -
    -**Call `AddPenningtonRoslyn`**
    -
    -Point it at the inner `.slnx`. That's the whole wire-up — no middleware call, no extra endpoint.
    -
    -```csharp
    -builder.Services.AddPenningtonRoslyn(opts =>
    -    opts.SolutionPath = "path/to/your.slnx");
    -```
    -
    -`SolutionPath` is resolved with `Path.GetFullPath`, so a relative value is interpreted against the **process working directory** — that is, the folder you run `dotnet run` from, which is normally the host csproj folder. The example string `"BeyondRoslynExample.slnx"` works because the inner `.slnx` sits next to the csproj. To point at a sibling folder, use a relative path like `"../OtherProject/Other.slnx"`; an absolute path also works.
    -
    -For the full `RoslynOptions` surface — including `ProjectFilter`, which narrows the workspace when the `.slnx` lists more than the docs need — see .
    -
    -The complete stage 2 host adds one `AddPenningtonRoslyn` call to the stage 1 setup; nothing else changes.
    -
    -```csharp:symbol,bodyonly
    -examples/BeyondRoslynExample/Stage2_AddRoslyn.cs > Stage2.Run
    -```
    -
    -
    -
    -
    -
    -
    -- Run `dotnet run` on the host
    -- The first request takes a beat longer while [`ISolutionWorkspaceService`](xref:reference.api.i-solution-workspace-service) loads the inner slnx
    -- No errors in the console — the workspace is loaded and ready to resolve XmlDocIds
    -
    -
    -
    ----
    -
    -## 3. Write your first `xmldocid` fence
    -
    -Now that [`RoslynCodeBlockPreprocessor`](xref:reference.api.roslyn-code-block-preprocessor) is registered, any fenced code block whose info string ends in `:xmldocid` has its body parsed as one XmlDocId per line and resolved against the loaded workspace.
    -
    -
    -
    -
    -**Create a new markdown page**
    -
    -Add `Content/api-pulls.md` with the front matter and heading below. The next step adds a fence to it.
    -
    -```markdown
    ----
    -title: API pulls
    -description: Live source from the Sample library.
    -order: 30
    ----
    -
    -# API pulls
    -```
    -
    -
    -
    -
    -**Fence a whole type with `T:`**
    -
    -The fence language is `csharp:xmldocid`. The body is a single XmlDocId — `T:` for a type, `M:` for a method, `P:` for a property, `F:` for a field.
    -
    -```csharp:symbol
    -examples/BeyondRoslynExample/Sample/Calculator.cs > Calculator
    -```
    -
    -
    -
    -
    -**Fence a single method with `M:`**
    -
    -Method XmlDocIds include full parameter types. The Sample library's `Add` method takes two `int` parameters, so the XmlDocId reads `M:...Add(System.Int32,System.Int32)`.
    -
    -```csharp:symbol
    -examples/BeyondRoslynExample/Sample/Calculator.cs > Calculator.Add
    -```
    -
    -
    -
    -
    -
    -
    -- Run `dotnet run` and visit `http://localhost:5000/api-pulls`
    -- The `Calculator` class and the `Add` method render as syntax-highlighted C#, pulled directly from `Sample/Calculator.cs`
    -- Right-click → View Source: the markup is real `
    ` with TextMate-style token spans, not an image
    -
    -
    -
    ----
    -
    -## 4. Watch hot reload refresh the snippet
    -
    -The workspace re-reads source on change. Edit the fenced method, request the page again, and Pennington serves the updated snippet without a manual rebuild.
    -
    -
    -
    -
    -**Start the host in watch mode**
    -
    -Run `dotnet watch` on the host csproj so file changes trigger a reload of the MSBuild workspace. Leave the browser open on `/api-pulls`.
    -
    -
    -
    -
    -**Edit the Sample library**
    -
    -Change the body of `Add` in `Sample/Calculator.cs` — add a comment or rename a local variable. Save the file.
    -
    -
    -
    -
    -
    -
    -- Refresh `/api-pulls`
    -- The `Add` method snippet now shows the change, pulled fresh from `Calculator.cs`
    -- No manual docs rebuild was required — the workspace picked it up
    -
    -
    -
    ----
    -
    -## 5. Use the `,bodyonly` variant and stack multiple symbols
    -
    -Two fence options let you control what renders: append `,bodyonly` to strip the declaration line, or list multiple XmlDocIds in one fence to concatenate their source.
    -
    -
    -
    -
    -**Strip the declaration with `,bodyonly`**
    -
    -Appending `,bodyonly` to the fence language returns only the block contents, or the expression-body expression for arrow members. Use it when the declaration is noise and the snippet should show what happens inside.
    -
    -```csharp:symbol,bodyonly
    -examples/BeyondRoslynExample/Sample/Calculator.cs > Calculator.Multiply
    -```
    -
    -
    -
    -
    -**Concatenate multiple XmlDocIds**
    -
    -Place multiple XmlDocIds in one fence, one per line. The preprocessor renders them all in the order listed — useful for pairing two related members in the same code block.
    -
    -```csharp:symbol
    -examples/BeyondRoslynExample/Sample/Greeter.cs > Greeter.Greet
    -examples/BeyondRoslynExample/Sample/Calculator.cs > Calculator.Mean
    -```
    -
    -
    -
    -
    -
    -
    -- Refresh `/api-pulls`
    -- The `Multiply` fence shows the `return a * b;` line only — no `public int Multiply(...)` declaration
    -- The concatenated fence shows `Greet` and `Mean` back-to-back in one highlighted code block
    -
    -
    -
    ----
    -
    -## Summary
    -
    -- A dual-project shape now stands up — a DocSite host plus a sibling Sample library wired through an inner slnx.
    -- `Pennington.Roslyn` is active via a single `AddPenningtonRoslyn` call and `RoslynOptions.SolutionPath`.
    -- `csharp:xmldocid` fences cover types (`T:`), methods (`M:`), body-only snippets (`,bodyonly`), and multi-symbol blocks.
    -- Hot reload refreshes rendered snippets when the backing source changes.
    diff --git a/docs/Pennington.Docs/Content/tutorials/getting-started/navigation.md b/docs/Pennington.Docs/Content/tutorials/getting-started/navigation.md
    index ee9bb5e5..2686609a 100644
    --- a/docs/Pennington.Docs/Content/tutorials/getting-started/navigation.md
    +++ b/docs/Pennington.Docs/Content/tutorials/getting-started/navigation.md
    @@ -120,4 +120,4 @@ Run `dotnet run` and open `http://localhost:5000/`.
     - A folder without an `index.md` becomes a section node — that is why `guides/` rendered as a labelled group.
     - The bare host now serves a complete site: a content pipeline, a styled layout, and navigation — all on `AddPennington`.
     
    -That is the whole getting-started arc. `AddPennington` is the path when you want full control — you wire the pipeline, the layout, and the navigation yourself, and you have now done each part. If you do not need that level of control, the [DocSite](xref:tutorials.docsite.scaffold) and [BlogSite](xref:tutorials.blogsite.scaffold) templates package this same wiring — layout, navigation, search — into a single call for two specific shapes; reach for one when your site *is* a documentation site or a blog. [Connect a Roslyn project](xref:tutorials.beyond-basics.connect-roslyn) and the other [beyond-basics tutorials](xref:tutorials.beyond-basics.custom-razor-component) build on the host you just finished.
    +That is the whole getting-started arc. `AddPennington` is the path when you want full control — you wire the pipeline, the layout, and the navigation yourself, and you have now done each part. If you do not need that level of control, the [DocSite](xref:tutorials.docsite.scaffold) and [BlogSite](xref:tutorials.blogsite.scaffold) templates package this same wiring — layout, navigation, search — into a single call for two specific shapes; reach for one when your site *is* a documentation site or a blog. The [beyond-basics tutorials](xref:tutorials.beyond-basics.custom-razor-component) build on the host you just finished.
    diff --git a/docs/Pennington.Docs/Pennington.Docs.csproj b/docs/Pennington.Docs/Pennington.Docs.csproj
    index 31c5ba90..f22ed240 100644
    --- a/docs/Pennington.Docs/Pennington.Docs.csproj
    +++ b/docs/Pennington.Docs/Pennington.Docs.csproj
    @@ -12,7 +12,11 @@
       
         
         
    -    
    +    
    +    
    +    
         
         
       
    diff --git a/docs/Pennington.Docs/Program.cs b/docs/Pennington.Docs/Program.cs
    index 96c9465e..177333b5 100644
    --- a/docs/Pennington.Docs/Program.cs
    +++ b/docs/Pennington.Docs/Program.cs
    @@ -1,4 +1,5 @@
     using Mdazor;
    +using Pennington.ApiMetadata.Reflection;
     using Pennington.Docs;
     using Pennington.Docs.ApiReference;
     using Pennington.Docs.Components.Reference;
    @@ -6,8 +7,6 @@
     using Pennington.DocSite.Api;
     using Pennington.Infrastructure;
     using Pennington.MonorailCss;
    -using Pennington.Roslyn;
    -using Pennington.Roslyn.ApiMetadata;
     using Pennington.TreeSitter;
     
     var builder = WebApplication.CreateBuilder(args);
    @@ -94,37 +93,20 @@
         treeSitter.ContentRoot = "../..";
     });
     
    -// Roslyn stays for the reflection-backed API reference only, scoped to a .slnf that lists just
    -// the Pennington.* projects — so the workspace loads ~13 projects instead of the whole solution.
    -// EnableCodeFragmentFences is off because tree-sitter now owns :symbol extraction.
    -builder.Services.AddPenningtonRoslyn(roslyn =>
    +// Reflection-backed API metadata: read the built Pennington.* assemblies and their companion
    +// xmldoc files straight from this app's output folder — no MSBuild workspace, no compilation.
    +// Every referenced Pennington library lands in bin/, so glob them (excluding this entry app)
    +// to document the full set of referenced Pennington libraries.
    +var entryAssembly = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name;
    +builder.Services.AddApiMetadataFromCompiledAssembly(opts =>
     {
    -    roslyn.SolutionPath = "../../Pennington.slnf";
    -    roslyn.EnableCodeFragmentFences = false;
    -});
    -
    -// Roslyn-backed API metadata: enumerate public types from the live Pennington
    -// workspace. Scope to Pennington.* projects so example apps in Pennington.slnx
    -// don't leak into /reference/api/.
    -var defaultApiFilter = ApiReferenceOptions.DefaultProjectFilter();
    -builder.Services.AddApiMetadataFromRoslyn(configure: opts =>
    -{
    -    opts.ProjectFilter = project =>
    +    foreach (var dll in Directory.EnumerateFiles(AppContext.BaseDirectory, "Pennington*.dll"))
         {
    -        if (!defaultApiFilter(project))
    +        if (!string.Equals(Path.GetFileNameWithoutExtension(dll), entryAssembly, StringComparison.Ordinal))
             {
    -            return false;
    +            opts.AssemblyFiles.Add(dll);
             }
    -
    -        var name = project.Name;
    -        var paren = name.IndexOf('(');
    -        if (paren > 0)
    -        {
    -            name = name[..paren];
    -        }
    -
    -        return name == "Pennington" || name.StartsWith("Pennington.", StringComparison.Ordinal);
    -    };
    +    }
     });
     
     // Auto-publishes /reference/api/{slug}/ pages and registers the reference
    diff --git a/examples/AUDIT_LOG.md b/examples/AUDIT_LOG.md
    index 29b1144f..3c0b23a1 100644
    --- a/examples/AUDIT_LOG.md
    +++ b/examples/AUDIT_LOG.md
    @@ -538,32 +538,6 @@ All 25 examples build and run. Highlights worth triaging:
     
     **Fixes applied.**
     
    -## 4. BeyondRoslynExample
    -
    -**README claim:** `AddPenningtonRoslyn` against the sibling `Sample/` library. Markdown fences resolve `:xmldocid`, `:xmldocid,bodyonly`, `:xmldocid-diff`, and `:path` against the inner `BeyondRoslynExample.slnx`.
    -
    -**Verified in browser (`dotnet run`, port 5000):**
    -- Symbol warmup logs `Symbol extraction warmup completed in 1809ms`. ✓
    -- `/api-pulls/` renders 5 distinct code blocks:
    -  1. `T:Calculator` — full class with xmldoc comments. ✓
    -  2. `M:Calculator.Add(...)` — method with xmldoc. ✓
    -  3. `M:Calculator.Multiply(...)` + `,bodyonly` — single line `return a * b;`. ✓ (correctly strips declaration)
    -  4. `T:Greeter` — full class with xmldoc. ✓
    -  5. Two M-ids in one fence — both members concatenated. ✓
    -- Console: clean. ✓
    -
    -**Findings:**
    -- **[DOC+APP] (minor)** README explicitly advertises `:xmldocid-diff` as part of the teaching surface but no markdown in this example uses it. Tutorial body (line 88) also lists `:xmldocid-diff` in the fence-modifier menu, then never demonstrates it. Either add a section in `api-pulls.md` showing a before/after with two `M:` IDs and the diff fence, or drop `:xmldocid-diff` from both the README and the tutorial fence-modifier list and surface it in an explanation-quadrant page instead.
    -- **[DOC] (minor)** README "Tutorial stages" section is a placeholder: it says only "The inner `BeyondRoslynExample.slnx` + `Sample/` library is part of the teaching surface." but the example has actual staged C# files (`Stage1_NoRoslyn.cs`, `Stage2_AddRoslyn.cs`). The standard pattern from `examples/CLAUDE.md` is `Stage1 → Stage2 → …` — call them out by name in the README so consumers know to look for them.
    -- **[DOC] (minor)** README's "Concepts" list mentions `` keeping `Sample/` out of the host's compile — this is true but the tutorial buries it as a one-liner in step 1.2 ("set `DefaultItemExcludes` … otherwise the two projects compete over the same `.cs` files"). Promote it: a reader copying the csproj fragment from the tutorial today won't see the `` line, only a generic instruction to add it.
    -
    -**Resolved 2026-05-13:**
    -- DOC+APP `:xmldocid-diff` demo — added a "Diff two symbols" section in `examples/BeyondRoslynExample/Content/api-pulls.md` that fences `:xmldocid-diff,bodyonly` against `Calculator.Add` vs `Calculator.Multiply`. Verified the rendered page shows `return a + b;` / `return a * b;` with full syntax highlighting.
    -- DOC Tutorial stages placeholder — README now reads `Stage1_NoRoslyn.cs → Stage2_AddRoslyn.cs.` per the `examples/CLAUDE.md` convention, plus a one-line note on what the inner slnx and `Sample/` library teach.
    -- DOC `` promotion — Step 1.2 of the tutorial now ships a real `` snippet showing the exact `$(DefaultItemExcludes);Sample\**` line, with one sentence explaining why `$(DefaultItemExcludes)` is preserved.
    -
    -**Fixes applied.**
    -
     ## 3. BeyondLocaleExample
     
     **README claim:** DocSite + `ConfigureLocalization` adds a second URL-prefixed locale (`es`); content lives under `Content//`; `LanguageSwitcher` appears once `Locales.Count > 1`; translations registered via `ConfigurePennington` escape hatch.
    diff --git a/examples/BeyondRoslynExample/BeyondRoslynExample.csproj b/examples/BeyondRoslynExample/BeyondRoslynExample.csproj
    deleted file mode 100644
    index 3115ce5d..00000000
    --- a/examples/BeyondRoslynExample/BeyondRoslynExample.csproj
    +++ /dev/null
    @@ -1,19 +0,0 @@
    -
    -  
    -    net11.0
    -    preview
    -    enable
    -    enable
    -    
    -    $(DefaultItemExcludes);Sample\**
    -  
    -  
    -    
    -    
    -  
    -  
    -    
    -  
    -
    diff --git a/examples/BeyondRoslynExample/BeyondRoslynExample.slnx b/examples/BeyondRoslynExample/BeyondRoslynExample.slnx
    deleted file mode 100644
    index 01bc711d..00000000
    --- a/examples/BeyondRoslynExample/BeyondRoslynExample.slnx
    +++ /dev/null
    @@ -1,3 +0,0 @@
    -
    -  
    -
    diff --git a/examples/BeyondRoslynExample/Components/CodeBlockRazorPage.razor b/examples/BeyondRoslynExample/Components/CodeBlockRazorPage.razor
    deleted file mode 100644
    index fd39c80d..00000000
    --- a/examples/BeyondRoslynExample/Components/CodeBlockRazorPage.razor
    +++ /dev/null
    @@ -1,25 +0,0 @@
    -@page "/codeblock-razor"
    -@namespace BeyondRoslynExample.Components
    -
    -CodeBlock razor repro
    -
    -
    -

    CodeBlock razor repro

    -

    - Four <CodeBlock> invocations authored in a real - Razor @@page. Confirms that the Razor-side component - runs the Roslyn preprocessor when a modifier is present. -

    - -

    Whole type via Code

    - - -

    Body only via ChildContent

    - M:BeyondRoslynExample.Sample.Calculator.Multiply(System.Int32,System.Int32) - -

    Plain csharp sentinel

    - var x = 1; - -

    Method with declaration via ChildContent

    - M:BeyondRoslynExample.Sample.Calculator.Add(System.Int32,System.Int32) -
    diff --git a/examples/BeyondRoslynExample/Components/_Imports.razor b/examples/BeyondRoslynExample/Components/_Imports.razor deleted file mode 100644 index 8bdcfd7d..00000000 --- a/examples/BeyondRoslynExample/Components/_Imports.razor +++ /dev/null @@ -1,2 +0,0 @@ -@using Microsoft.AspNetCore.Components.Web -@using Pennington.UI.Components diff --git a/examples/BeyondRoslynExample/Content/api-pulls.md b/examples/BeyondRoslynExample/Content/api-pulls.md deleted file mode 100644 index c5f50524..00000000 --- a/examples/BeyondRoslynExample/Content/api-pulls.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -title: API pulls -description: Live xmldocid fences pulling real source from the Sample library. -order: 20 ---- - -# API pulls - -Each fenced block below resolves an XmlDocId against the inner slnx that -`AddPenningtonRoslyn` loaded. When the Sample library source changes on disk, -hot reload re-reads it and the next request serves the new snippet. - -## Whole type — `T:...` - -Embed the entire `Calculator` class. Fence language is `csharp:xmldocid`; -the fence body is a single line holding the XmlDocId. - -```csharp:xmldocid -T:BeyondRoslynExample.Sample.Calculator -``` - -## Single method — `M:...` - -One fence, one method. XmlDocIds for methods include parameter types. - -```csharp:xmldocid -M:BeyondRoslynExample.Sample.Calculator.Add(System.Int32,System.Int32) -``` - -## Method body only — `,bodyonly` - -Appending `,bodyonly` strips the declaration and returns just the block -contents (or expression-body expression). - -```csharp:xmldocid,bodyonly -M:BeyondRoslynExample.Sample.Calculator.Multiply(System.Int32,System.Int32) -``` - -## A second type - -The `Greeter` class lives in the same Sample library. - -```csharp:xmldocid -T:BeyondRoslynExample.Sample.Greeter -``` - -## Multiple symbols in one fence - -List multiple XmlDocIds on separate lines inside one fence — they're -concatenated in the rendered output. - -```csharp:xmldocid -M:BeyondRoslynExample.Sample.Greeter.Greet(System.String) -M:BeyondRoslynExample.Sample.Calculator.Mean(System.Collections.Generic.IReadOnlyList{System.Int32}) -``` - -## Diff two symbols — `:xmldocid-diff` - -The `:xmldocid-diff` fence takes exactly two XmlDocIds and renders the -unified diff between their bodies. Use it in explanation-quadrant pages to -compare a "before" and "after" implementation side by side without keeping -two prose snippets in sync. Here we diff `Add` against `Multiply` — both -are two-parameter `int` methods, so the diff narrows to the operator and -parameter names. Stack the same `,bodyonly` suffix to strip the -declarations when the change you want to surface is purely inside the -method body. - -```csharp:xmldocid-diff,bodyonly -M:BeyondRoslynExample.Sample.Calculator.Add(System.Int32,System.Int32) -M:BeyondRoslynExample.Sample.Calculator.Multiply(System.Int32,System.Int32) -``` - diff --git a/examples/BeyondRoslynExample/Content/index.md b/examples/BeyondRoslynExample/Content/index.md deleted file mode 100644 index 899d5842..00000000 --- a/examples/BeyondRoslynExample/Content/index.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: Beyond Roslyn -description: How this tutorial pulls live source into rendered docs. -order: 10 ---- - -# Beyond Roslyn - -This example backs tutorial §1.4.20 of the Pennington docs. It points -`Pennington.Roslyn` at a sibling library (`BeyondRoslynExample.Sample`) via -the inner `BeyondRoslynExample.slnx` and then embeds symbols from that -library directly into markdown pages with `csharp:xmldocid` fences. - -See the [API pulls page](./api-pulls) for the fences in action. - -## What's wired - -- `AddDocSite(...)` — the same DocSite host used in tutorials 1.2.* -- `AddPenningtonRoslyn(options => options.SolutionPath = "BeyondRoslynExample.slnx")` - — turns on `:xmldocid` / `:xmldocid,bodyonly` / `:path` code-fence modifiers -- `BeyondRoslynExample.slnx` — inner slnx that registers only the Sample - library, scoping the MSBuild workspace to exactly the source we want to - fence into docs diff --git a/examples/BeyondRoslynExample/Program.cs b/examples/BeyondRoslynExample/Program.cs deleted file mode 100644 index 4b397aa0..00000000 --- a/examples/BeyondRoslynExample/Program.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Pennington.DocSite; -using Pennington.Roslyn; - -var builder = WebApplication.CreateBuilder(args); - -// Same DocSite host shape as the previous tutorials — the difference here is -// the extra `AddPenningtonRoslyn` line below, which lights up `:xmldocid`, -// `:xmldocid,bodyonly`, `:xmldocid-diff`, and `:path` code-fence modifiers. -// With those wired, doc markdown can reference real types and methods by -// their XmlDocId and Pennington will pull the current source straight into -// the rendered page. -builder.Services.AddDocSite(() => new DocSiteOptions -{ - SiteTitle = "Beyond Roslyn", - Description = "Pulling live code snippets into docs with xmldocid fences.", - GitHubUrl = "https://github.com/usepennington/pennington", - HeaderContent = """Beyond Roslyn""", - FooterContent = """
    Built with Pennington DocSite.
    """, - AdditionalRoutingAssemblies = [typeof(Program).Assembly], -}); - -// Point Pennington.Roslyn at the *inner* slnx next to this Program.cs. The -// path is resolved relative to the host's working directory (the folder that -// contains this csproj when `dotnet run` is invoked), so "BeyondRoslynExample.slnx" -// is enough — no need to walk up to the repo root. The inner slnx registers -// only the `Sample` library whose types the markdown fences reference. -// -// `AddPenningtonRoslyn` takes a `RoslynOptions` action. When `SolutionPath` is -// set it registers `RoslynCodeBlockPreprocessor`, the MSBuild workspace, the -// symbol-extraction service, and the xmldoc-HTML renderer — everything needed -// for `csharp:xmldocid` fences to resolve. -builder.Services.AddPenningtonRoslyn(roslyn => -{ - roslyn.SolutionPath = "BeyondRoslynExample.slnx"; -}); - -var app = builder.Build(); - -app.UseDocSite(); - -await app.RunDocSiteAsync(args); \ No newline at end of file diff --git a/examples/BeyondRoslynExample/README.md b/examples/BeyondRoslynExample/README.md deleted file mode 100644 index 75dd6f12..00000000 --- a/examples/BeyondRoslynExample/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# BeyondRoslynExample - -Pulling live code into docs with `:xmldocid`, `:xmldocid,bodyonly`, `:xmldocid-diff`, and `:path` fence modifiers. `AddPenningtonRoslyn` points an MSBuild workspace at the sibling `Sample/` library; markdown fences resolve real symbols from there. - -## Concepts - -- `AddPenningtonRoslyn` (MSBuild workspace, symbol extraction, xmldoc → HTML) -- `RoslynOptions.SolutionPath` — relative to the host's working directory -- `` in the csproj keeping `Sample/` out of the host's compile - -## Tutorial stages - -`Stage1_NoRoslyn.cs` → `Stage2_AddRoslyn.cs`. - -The inner `BeyondRoslynExample.slnx` plus the `Sample/` class library are part of the teaching surface — the tutorial pulls `T:BeyondRoslynExample.Sample.Calculator` and `M:...Greeter.Greet(System.String)` through `csharp:xmldocid` fences from those projects. - -## Referenced from - -- `docs/.../tutorials/beyond-basics/connect-roslyn.md` diff --git a/examples/BeyondRoslynExample/Sample/BeyondRoslynExample.Sample.csproj b/examples/BeyondRoslynExample/Sample/BeyondRoslynExample.Sample.csproj deleted file mode 100644 index 1f5de283..00000000 --- a/examples/BeyondRoslynExample/Sample/BeyondRoslynExample.Sample.csproj +++ /dev/null @@ -1,11 +0,0 @@ - - - net11.0 - preview - enable - enable - true - $(NoWarn);CS1591 - BeyondRoslynExample.Sample - - diff --git a/examples/BeyondRoslynExample/Sample/Calculator.cs b/examples/BeyondRoslynExample/Sample/Calculator.cs deleted file mode 100644 index 3123dc6f..00000000 --- a/examples/BeyondRoslynExample/Sample/Calculator.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace BeyondRoslynExample.Sample; - -/// -/// A tiny arithmetic helper used as the tutorial's xmldocid target. Nothing -/// about this class is clever — the point is that the tutorial's doc prose -/// can fence M:BeyondRoslynExample.Sample.Calculator.Add(System.Int32,System.Int32) -/// and pull the real source into rendered HTML. -/// -public sealed class Calculator -{ - /// Adds two integers. - /// First addend. - /// Second addend. - /// The sum of and . - public int Add(int a, int b) - { - return a + b; - } - - /// Multiplies two integers. - /// First factor. - /// Second factor. - /// The product of and . - public int Multiply(int a, int b) - { - return a * b; - } - - /// Returns the arithmetic mean of a non-empty sequence. - /// Values to average. Must contain at least one element. - /// Thrown if is empty. - public double Mean(IReadOnlyList values) - { - if (values.Count == 0) - { - throw new ArgumentException("At least one value is required.", nameof(values)); - } - - var total = 0L; - foreach (var v in values) - { - total += v; - } - - return (double)total / values.Count; - } -} \ No newline at end of file diff --git a/examples/BeyondRoslynExample/Sample/Greeter.cs b/examples/BeyondRoslynExample/Sample/Greeter.cs deleted file mode 100644 index 51be60dc..00000000 --- a/examples/BeyondRoslynExample/Sample/Greeter.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace BeyondRoslynExample.Sample; - -/// -/// Builds friendly greetings. Exists so the tutorial's second xmldocid fence -/// can reference a type other than . -/// -public sealed class Greeter -{ - /// The greeting prefix, e.g. "Hello" or "Bonjour". - public string Prefix { get; } - - /// Creates a greeter with the supplied . - public Greeter(string prefix) - { - Prefix = prefix; - } - - /// - /// Builds a greeting for using . - /// - /// The recipient's display name. - /// A string of the form "{Prefix}, {name}!". - public string Greet(string name) - { - return $"{Prefix}, {name}!"; - } -} \ No newline at end of file diff --git a/examples/BeyondRoslynExample/Stage1_NoRoslyn.cs b/examples/BeyondRoslynExample/Stage1_NoRoslyn.cs deleted file mode 100644 index b86947fe..00000000 --- a/examples/BeyondRoslynExample/Stage1_NoRoslyn.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace BeyondRoslynExample; - -using Pennington.DocSite; - -/// -/// Stage 1 — the pre-Roslyn host. DocSite is wired, pages render, but any -/// csharp:xmldocid fence in markdown just renders as a literal code -/// block because no / -/// -/// is registered in DI. Tutorial prose extracts the body of -/// via xmldocid,bodyonly. This class is never instantiated. -/// -public static class Stage1 -{ - /// A DocSite host with no Roslyn integration yet. - public static async Task Run(string[] args) - { - var builder = WebApplication.CreateBuilder(args); - - builder.Services.AddDocSite(() => new DocSiteOptions - { - SiteTitle = "Beyond Roslyn", - Description = "Pulling live code snippets into docs with xmldocid fences.", - GitHubUrl = "https://github.com/usepennington/pennington", - HeaderContent = """Beyond Roslyn""", - FooterContent = """
    Built with Pennington DocSite.
    """, - }); - - var app = builder.Build(); - - app.UseDocSite(); - - await app.RunDocSiteAsync(args); - } -} \ No newline at end of file diff --git a/examples/BeyondRoslynExample/Stage2_AddRoslyn.cs b/examples/BeyondRoslynExample/Stage2_AddRoslyn.cs deleted file mode 100644 index d2a0873c..00000000 --- a/examples/BeyondRoslynExample/Stage2_AddRoslyn.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace BeyondRoslynExample; - -using Pennington.DocSite; -using Pennington.Roslyn; // [!code ++] - -/// -/// Stage 2 — adds AddPenningtonRoslyn pointed at the inner -/// BeyondRoslynExample.slnx. With this one extra service registration, -/// the markdown preprocessor for :xmldocid / :xmldocid,bodyonly -/// / :xmldocid-diff / :path fence modifiers lights up and every -/// doc page that references a Sample-library symbol starts rendering real -/// source. Tutorial prose extracts the body of via -/// xmldocid,bodyonly. This class is never instantiated. -/// -public static class Stage2 -{ - /// DocSite host with Roslyn integration wired. - public static async Task Run(string[] args) - { - var builder = WebApplication.CreateBuilder(args); - - builder.Services.AddDocSite(() => new DocSiteOptions - { - SiteTitle = "Beyond Roslyn", - Description = "Pulling live code snippets into docs with xmldocid fences.", - GitHubUrl = "https://github.com/usepennington/pennington", - HeaderContent = """Beyond Roslyn""", - FooterContent = """
    Built with Pennington DocSite.
    """, - }); - - builder.Services.AddPenningtonRoslyn(roslyn => // [!code ++] - { // [!code ++] - roslyn.SolutionPath = "BeyondRoslynExample.slnx"; // [!code ++] - }); // [!code ++] - - var app = builder.Build(); - - app.UseDocSite(); - - await app.RunDocSiteAsync(args); - } -} \ No newline at end of file diff --git a/examples/BeyondTreeSitterExample/Content/index.md b/examples/BeyondTreeSitterExample/Content/index.md index ff953adf..79996106 100644 --- a/examples/BeyondTreeSitterExample/Content/index.md +++ b/examples/BeyondTreeSitterExample/Content/index.md @@ -4,9 +4,9 @@ title: Beyond Tree-sitter # Multi-language snippets with `:symbol` -`Pennington.Roslyn` can pull C#/VB declarations into docs by their XmlDocId. -`Pennington.TreeSitter` does the same idea for **any** tree-sitter-supported -language, addressing a declaration by its **name path** (`Type.Member`). +`Pennington.TreeSitter` pulls declarations into docs for **any** +tree-sitter-supported language, addressing a declaration by its **name path** +(`Type.Member`). Each fenced block uses the `:symbol` info-string. The body is one ` > ` reference per line, resolved under the configured diff --git a/examples/BeyondTreeSitterExample/Program.cs b/examples/BeyondTreeSitterExample/Program.cs index 4ef35670..07b2fc36 100644 --- a/examples/BeyondTreeSitterExample/Program.cs +++ b/examples/BeyondTreeSitterExample/Program.cs @@ -6,8 +6,8 @@ // Same DocSite host shape as the other tutorials. The extra line is // `AddPenningtonTreeSitter` below, which lights up the `:symbol` code-fence // modifier for *any* tree-sitter-supported language (Python, Rust, Go, -// TypeScript, …). Where `Pennington.Roslyn` resolves C#/VB by XmlDocId, -// tree-sitter resolves a member by name path across many languages. +// TypeScript, …). Tree-sitter resolves a member by name path across many +// languages. builder.Services.AddDocSite(() => new DocSiteOptions { SiteTitle = "Beyond Tree-sitter", diff --git a/examples/BeyondTreeSitterExample/README.md b/examples/BeyondTreeSitterExample/README.md index 101b2c71..5488ad36 100644 --- a/examples/BeyondTreeSitterExample/README.md +++ b/examples/BeyondTreeSitterExample/README.md @@ -7,12 +7,11 @@ doc markdown via the `:symbol` fence modifier. - `AddPenningtonTreeSitter(o => o.ContentRoot = "Samples")` lights up the `:symbol` fence for every tree-sitter-supported language. -- Addressing a declaration by **name path** (`Type.Member`) rather than by - XmlDocId, so non-C# languages work the same way C# does under `Pennington.Roslyn`. +- Addressing a declaration by **name path** (`Type.Member`), which works + uniformly across languages. - The fence body format: ` > ` (one per line); a bare `` embeds the whole file; `,bodyonly` returns just the body. -- `:symbol-diff` over two references emits a before/after unified diff, - the multi-language counterpart to `Pennington.Roslyn`'s `:xmldocid-diff`. +- `:symbol-diff` over two references emits a before/after unified diff. - Cross-language resolution quirks: Rust methods resolve through their `impl` block; Go/TypeScript/Python all work from the same generic resolver. @@ -31,6 +30,5 @@ dotnet run --project examples/BeyondTreeSitterExample Where it is referenced from the docs site: _(reference for `Pennington.TreeSitter`)_. -> The `:symbol` modifier is multi-language and syntactic. For C#/VB with full -> semantic resolution (XmlDocId, `inheritdoc`, required usings), use -> `Pennington.Roslyn` and its `:xmldocid` fence instead. +> The `:symbol` modifier is multi-language and syntactic — it matches +> declarations by name path, without semantic or type resolution. diff --git a/examples/CLAUDE.md b/examples/CLAUDE.md index 88822924..560c7a9c 100644 --- a/examples/CLAUDE.md +++ b/examples/CLAUDE.md @@ -3,7 +3,7 @@ ## Naming All example projects suffix `Example`. Categories: - `GettingStarted*` — baseline Pennington setup, used by tutorials -- `Beyond*` — advanced features (Roslyn, locales, custom Razor components) +- `Beyond*` — advanced features (tree-sitter fragments, locales, custom Razor components) - `DocSite*` / `BlogSite*` — template-specific (scaffold, kitchen sink, sections, authors) - `Focused*` / `Multiple*` — feature-focused (code samples, multi-source) @@ -30,24 +30,22 @@ Every example folder has a `README.md` describing its purpose, the concepts it t | `BareHostRazorPageExample` | Render a Razor component as a full response on a bare `AddPennington` host via `HtmlRenderer` + `MapGet`. | `how-to/response-pipeline/razor-page-on-bare-host.md` | | `BareHostSearchExample` | Light up the Pennington.UI search modal on a bare (non-DocSite) host — reference `Pennington.UI`, load `dewey-search.js` + `scripts.js`, add an `id="search-input"` trigger; styled by the `PenningtonApplies` safelist. Mounts the `_shared/Bramble` corpus (blog excluded). | `how-to/discovery/search-on-a-bare-host.md` | | `BeyondCustomRazorComponentExample` | Author a Razor component (`PricingCard`) and register it with Mdazor via `AddMdazorComponent()`. | `tutorials/beyond-basics/custom-razor-component.md` | -| `BeyondLocaleExample` | Add a second URL-prefixed locale to a DocSite via `ConfigureLocalization` + `Content//`. | `tutorials/beyond-basics/add-a-locale.md`, `how-to/discovery/localization.md` | -| `BeyondRoslynExample` | `AddPenningtonRoslyn` against a sibling slnx — markdown fences resolve `:xmldocid` / `:xmldocid,bodyonly` / `:xmldocid-diff` / `:path`. | `tutorials/beyond-basics/connect-roslyn.md` | -| `BeyondTreeSitterExample` | `AddPenningtonTreeSitter` against a `Samples/` folder — `:symbol` fences extract declarations by name path (`Type.Member`) across Python/Rust/Go/TypeScript, plus `,bodyonly` and whole-file forms. | _(reference for `Pennington.TreeSitter`)_ | +| `BeyondLocaleExample` | Add a second URL-prefixed locale to a DocSite via `ConfigureLocalization` + `Content//`. | `tutorials/beyond-basics/add-a-locale.md`, `how-to/discovery/localization.md` || `BeyondTreeSitterExample` | `AddPenningtonTreeSitter` against a `Samples/` folder — `:symbol` fences extract declarations by name path (`Type.Member`) across Python/Rust/Go/TypeScript, plus `,bodyonly` and whole-file forms. | _(reference for `Pennington.TreeSitter`)_ | | `BeyondTranslationAuditExample` | Wire `AddPenningtonTranslationAudit` so missing translations surface in the dev overlay and build report. | _(reference for `Pennington.TranslationAudit`)_ | | `BeyondTuiExample` | Opt the host into the dev-time TUI dashboard via `AddPenningtonTui`. Build mode no-ops. | _(reference for `Pennington.Tui`)_ | -| `BlogKitchenSinkExample` | Wide BlogSite configuration (hero, projects, socials, RSS, sitemap, JSON-LD) split across helpers for xmldocid fencing. | `how-to/feeds/rss.md`, `how-to/feeds/sitemap.md` | +| `BlogKitchenSinkExample` | Wide BlogSite configuration (hero, projects, socials, RSS, sitemap, JSON-LD) split across helpers for symbol fencing. | `how-to/feeds/rss.md`, `how-to/feeds/sitemap.md` | | `BlogSiteFirstPostExample` | Extend the BlogSite scaffold with a fully populated post exercising every `BlogSiteFrontMatter` field. | `tutorials/blogsite/first-post.md`, `reference/front-matter/keys.md` | | `BlogSiteHeroProjectsSocialsExample` | Populate `HeroContent`, `MyWork`, `Socials`, and `MainSiteLinks` with the built-in `SocialIcons` fragments. | `tutorials/blogsite/hero-projects-socials.md`, `how-to/feeds/blogsite-homepage.md` | | `BlogSiteScaffoldExample` | Smallest BlogSite — `AddBlogSite` / `UseBlogSite` / `RunBlogSiteAsync` with one post under `Content/Blog/`. | `tutorials/blogsite/scaffold.md`, `reference/blogsite/routes.md` | | `DocSiteBlogExample` | DocSite with a `Content/blog/` folder — the folder convention activates the blog index, post pages, `/blog/tags/` pages, the "Blog" header link, and `/rss.xml` with no `Program.cs` wiring. | `tutorials/docsite/add-a-blog.md` | | `DocSitePagesAndLinksExample` | Single-area DocSite with two content pages (`install`, `configure`) and a hub `index` demonstrating relative, absolute, and `uid:`-based linking, plus `Components/Index.razor` — a Razor landing page routed at `/` with `FullWidthLayout`. `snippets/markdown-{alert,tabs}-example.md` back the alerts/tabs sections of the markdown reference. | `tutorials/docsite/first-doc-page.md`, `tutorials/docsite/landing-page.md`, `reference/markdown/extensions.md` | | `DocSiteChromeOverridesExample` | Override DocSite chrome via `DocSiteOptions` + head-slot fragment + custom routed `@page` + `AdditionalRoutingAssemblies`. | `how-to/response-pipeline/override-docsite-components.md` | -| `DocSiteKitchenSinkExample` | Wide DocSite configuration (areas, locales, theming, fonts, custom front matter, custom Mdazor component) split across helpers for xmldocid fencing. | `how-to/navigation/{customize-sidebar,cross-references}.md`, `how-to/theming/{monorail-css,fonts}.md`, `how-to/pages/{front-matter,redirects}.md`, `how-to/discovery/{search,multiple-sources}.md`, `how-to/feeds/llms-txt.md`, `how-to/rich-content/ui-components-in-markdown.md`, `reference/front-matter/keys.md` | +| `DocSiteKitchenSinkExample` | Wide DocSite configuration (areas, locales, theming, fonts, custom front matter, custom Mdazor component) split across helpers for symbol fencing. | `how-to/navigation/{customize-sidebar,cross-references}.md`, `how-to/theming/{monorail-css,fonts}.md`, `how-to/pages/{front-matter,redirects}.md`, `how-to/discovery/{search,multiple-sources}.md`, `how-to/feeds/llms-txt.md`, `how-to/rich-content/ui-components-in-markdown.md`, `reference/front-matter/keys.md` | | `DocSiteScaffoldExample` | Smallest DocSite — `AddDocSite` / `UseDocSite` / `RunDocSiteAsync` with two areas. | `tutorials/docsite/scaffold.md`, `reference/host/extensions.md` | | `DocSiteSectionsExample` | Structure `Content/` into areas and subfolder-backed sections; `order:` / `section:` drive sidebar grouping. | `tutorials/docsite/sections-and-areas.md` | | `DocSiteSharedCorpusExample` | DocSite with no `Content/` of its own — mounts the shared `_shared/Bramble` corpus via a relative `ContentRootPath`, four Diátaxis areas + auto-activated blog. A site-at-scale fixture host. | _(fixture/scale host)_ | | `ExtensibilityLabExample` | Bare-host lab exercising every Pennington extension seam in one project: custom highlighter, code-block preprocessor, custom/emit-only `IContentService`, response processor, diagnostics processor, HTML rewriter, MonorailCSS customization, llms.txt opt-in, tabbed-code class override. | `how-to/markdown-pipeline/{custom-highlighter,code-block-preprocessor}.md`, `how-to/content-services/{custom-content-service,emit-generated-artifacts}.md`, `how-to/response-pipeline/{response-processor,html-rewriter}.md`, `how-to/theming/monorail-css.md`, `how-to/feeds/llms-txt.md`, `how-to/code-samples/tabbed-code.md`, `explanation/positioning/docsite-positioning.md`, `reference/diagnostics/request-context.md` | -| `FocusedCodeSamplesExample` | Console app — two implementations of a word-counter (`Monolith`/`Modular`) for narrating a refactor via xmldocid fences. | `how-to/code-samples/focused-code-samples.md` | +| `FocusedCodeSamplesExample` | Console app — two implementations of a word-counter (`Monolith`/`Modular`) for narrating a refactor via symbol fences. | `how-to/code-samples/focused-code-samples.md` | | `FusionCacheDocSiteExample` | Real-target DocSite — API reference generated from `ZiggyCreatures.FusionCache` via `AddApiMetadataFromCompiledAssembly` + `AddApiReference`. | _(reference for `Pennington.ApiMetadata.Reflection`)_ | | `GettingStartedBlazorPagesExample` | Replace the bare `MapGet` host with a Blazor Server `@page` catch-all (`MarkdownPage.razor`) that renders markdown through the same content pipeline. | `tutorials/getting-started/first-page.md` | | `GettingStartedMinimalSiteExample` | Smallest viable Pennington host — `AddPennington` + `AddMarkdownContent` + a catch-all `MapGet`. | `tutorials/getting-started/first-site.md` | @@ -61,17 +59,14 @@ Every example folder has a `README.md` describing its purpose, the concepts it t ## Staged tutorial files Examples used by step-by-step tutorials split the teaching into per-stage artifacts: -- **C# stages** live at the project root as `StageN_
    /// Service collection. diff --git a/src/Pennington.ApiMetadata.Reflection/CompiledAssemblyApiMetadataProvider.cs b/src/Pennington.ApiMetadata.Reflection/CompiledAssemblyApiMetadataProvider.cs index d5224fd0..eb41245d 100644 --- a/src/Pennington.ApiMetadata.Reflection/CompiledAssemblyApiMetadataProvider.cs +++ b/src/Pennington.ApiMetadata.Reflection/CompiledAssemblyApiMetadataProvider.cs @@ -1,7 +1,10 @@ namespace Pennington.ApiMetadata.Reflection; +using System; +using System.Collections.Generic; using System.Collections.Immutable; using System.IO; +using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -63,8 +66,11 @@ public async Task> GetMembersAsync( } /// - public Task> GetExtensionMethodsForAsync(string receiverTypeName) - => Task.FromResult(ImmutableArray.Empty); + public async Task> GetExtensionMethodsForAsync(string receiverTypeName) + { + var cat = await _catalog.Value; + return cat.Extensions.TryGetValue(receiverTypeName, out var entries) ? entries : []; + } /// public async Task GetXmldocAsync(string uid) @@ -82,21 +88,13 @@ public async Task GetXmldocAsync(string uid) private async Task LoadAsync() { - var typeBuilder = ImmutableArray.CreateBuilder(); - var typeDetails = new Dictionary(StringComparer.Ordinal); - var membersByType = new Dictionary>(StringComparer.Ordinal); - var membersByUid = new Dictionary(StringComparer.Ordinal); - var xmldocs = new Dictionary(StringComparer.Ordinal); - var assemblyPaths = new List(); foreach (var dir in _options.AssemblyDirectories) { - if (!Directory.Exists(dir)) + if (Directory.Exists(dir)) { - continue; + assemblyPaths.AddRange(Directory.EnumerateFiles(dir, "*.dll", SearchOption.TopDirectoryOnly)); } - - assemblyPaths.AddRange(Directory.EnumerateFiles(dir, "*.dll", SearchOption.TopDirectoryOnly)); } foreach (var path in _options.AssemblyFiles) { @@ -110,181 +108,458 @@ private async Task LoadAsync() return Catalog.Empty; } - var resolverPaths = BuildResolverPaths(assemblyPaths); - var resolver = new PathAssemblyResolver(resolverPaths); + var resolver = new PathAssemblyResolver(BuildResolverPaths(assemblyPaths)); using var ctx = new MetadataLoadContext(resolver); + // Pass 1: load each assembly and accumulate one global uid → raw xmldoc map. The map + // spans assemblies so can resolve against a base declared elsewhere. + var rawByUid = new Dictionary(StringComparer.Ordinal); + var loaded = new List<(Assembly Asm, string Name)>(); foreach (var dll in assemblyPaths) { Assembly asm; try { asm = ctx.LoadFromAssemblyPath(dll); } catch { continue; } - var xmlPath = Path.ChangeExtension(dll, ".xml"); - var xmldoc = XmlDocFile.Load(xmlPath, _xmlDocParser); - var assemblyName = asm.GetName().Name ?? Path.GetFileNameWithoutExtension(dll); + XmlDocFile.LoadInto(Path.ChangeExtension(dll, ".xml"), rawByUid); + loaded.Add((asm, asm.GetName().Name ?? Path.GetFileNameWithoutExtension(dll))); + } + // Pass 2: reflect every exported type against the assembled doc map. + var load = new LoadContext(rawByUid, _xmlDocParser, _highlighter); + foreach (var (asm, name) in loaded) + { Type[] types; try { types = asm.GetExportedTypes(); } catch { continue; } foreach (var t in types) { - try - { - ReflectType(t, assemblyName, xmldoc, typeBuilder, typeDetails, membersByType, membersByUid, xmldocs); - } + try { load.ReflectType(t, name); } catch { - // Skip any type whose metadata couldn't be fully resolved (e.g. a - // reference assembly is missing). One bad type shouldn't blank the - // whole provider. + // Skip any type whose metadata couldn't be fully resolved (e.g. a missing + // reference assembly). One bad type shouldn't blank the whole provider. } } } await Task.Yield(); // surface async contract without extra cost + return load.ToCatalog(); + } + + private static IEnumerable BuildResolverPaths(IEnumerable assemblyPaths) + { + // MetadataLoadContext rejects duplicate AssemblyNames, so we dedupe by the + // assembly's simple name (the filename without extension) and keep the first + // winner. Target assemblies come first, then the running .NET runtime, then + // peer shared frameworks (AspNetCore.App, WindowsDesktop.App) at their latest + // version only. + var bySimpleName = new Dictionary(StringComparer.OrdinalIgnoreCase); + + void Offer(string dllPath) + { + var simple = Path.GetFileNameWithoutExtension(dllPath); + if (!bySimpleName.ContainsKey(simple)) + { + bySimpleName[simple] = dllPath; + } + } - return new Catalog( - typeBuilder.OrderBy(t => t.FullTypeName, StringComparer.OrdinalIgnoreCase).ToImmutableArray(), - typeDetails.ToImmutableDictionary(), - membersByType.ToImmutableDictionary(kv => kv.Key, kv => kv.Value.ToImmutableArray()), - membersByUid.ToImmutableDictionary(), - xmldocs.ToImmutableDictionary()); + foreach (var path in assemblyPaths) + { + Offer(path); + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + { + foreach (var sibling in Directory.EnumerateFiles(dir, "*.dll")) + { + Offer(sibling); + } + } + } + + var runtime = RuntimeEnvironment.GetRuntimeDirectory(); + if (Directory.Exists(runtime)) + { + foreach (var dll in Directory.EnumerateFiles(runtime, "*.dll")) + { + Offer(dll); + } + } + + foreach (var frameworkDir in DiscoverLatestSharedFrameworks(runtime)) + { + foreach (var dll in Directory.EnumerateFiles(frameworkDir, "*.dll")) + { + Offer(dll); + } + } + + return bySimpleName.Values; } - private void ReflectType( - Type type, - string assemblyName, - XmlDocFile xmldoc, - ImmutableArray.Builder typeBuilder, - Dictionary typeDetails, - Dictionary> membersByType, - Dictionary membersByUid, - Dictionary xmldocs) + private static IEnumerable DiscoverLatestSharedFrameworks(string runtimeDir) { - if (ShouldSkipType(type)) + // Runtime dir looks like .../shared/Microsoft.NETCore.App/X.Y.Z + // Peer shared frameworks (AspNetCore.App, WindowsDesktop.App) sit under `shared`. + // Only yield the highest-version subdirectory of each to avoid duplicates. + var ncaDir = new DirectoryInfo(runtimeDir); + var sharedRoot = ncaDir.Parent?.Parent; + if (sharedRoot is null || !sharedRoot.Exists) { - return; + yield break; + } + + foreach (var frameworkRoot in sharedRoot.EnumerateDirectories()) + { + if (string.Equals(frameworkRoot.Name, ncaDir.Parent!.Name, StringComparison.OrdinalIgnoreCase)) + { + continue; // already covered by RuntimeEnvironment.GetRuntimeDirectory() + } + + var latest = frameworkRoot.EnumerateDirectories() + .OrderByDescending(d => d.Name, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); + if (latest is not null) + { + yield return latest.FullName; + } } + } + + /// Per-load accumulator: walks reflected types into the provider DTOs against a fixed uid → raw xmldoc map. + private sealed class LoadContext( + IReadOnlyDictionary rawByUid, + IXmlDocParser parser, + ICodeHighlighter highlighter) + { + private readonly ImmutableArray.Builder _types = ImmutableArray.CreateBuilder(); + private readonly Dictionary _typeDetails = new(StringComparer.Ordinal); + private readonly Dictionary> _membersByType = new(StringComparer.Ordinal); + private readonly Dictionary _membersByUid = new(StringComparer.Ordinal); + private readonly Dictionary _xmldocs = new(StringComparer.Ordinal); + private readonly List _extensions = []; + + public void ReflectType(Type type, string assemblyName) + { + if (ShouldSkipType(type)) + { + return; + } + + var uid = XmlDocIdFormatter.ForType(type); + if (!ShouldDocument(type, uid)) + { + return; + } + + var parsed = ResolveTypeDoc(type, uid); + _xmldocs[uid] = parsed; + + var summary = new ApiTypeSummary( + Uid: uid, + Name: DisplayTypeName(type), + Namespace: type.Namespace ?? string.Empty, + Assembly: assemblyName, + Kind: ClassifyType(type), + Summary: FirstSentence(parsed)); + _types.Add(summary); + + _typeDetails[uid] = new ApiTypeDetail( + Summary: summary, + Xmldoc: parsed, + SignatureHtml: highlighter.Highlight(SignatureFormatter.TypeDeclaration(type), "csharp"), + Inheritance: InheritanceChain(type), + Implements: ImplementedInterfaces(type), + Source: null); + + var isUnion = IsUnion(type); + var list = new List(); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var m in type.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)) + { + AddMember(list, seen, m, inheritedFrom: null, suppressUndocumented: isUnion); + } + + // Interfaces show members inherited from documented base interfaces (e.g. a recently + // split IContentService : IContentEmitter), grouped under the declaring interface. + if (type.IsInterface) + { + foreach (var baseInterface in type.GetInterfaces()) + { + if (!rawByUid.ContainsKey(XmlDocIdFormatter.ForType(baseInterface))) + { + continue; // skip undocumented framework interfaces (IDisposable, ...) + } + + foreach (var m in baseInterface.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)) + { + AddMember(list, seen, m, inheritedFrom: baseInterface, suppressUndocumented: false); + } + } + } - var uid = XmlDocIdFormatter.ForType(type); - var parsed = xmldoc.Get(uid); - xmldocs[uid] = parsed; + if (isUnion) + { + foreach (var caseMember in BuildUnionCases(type)) + { + if (seen.Add(caseMember.Uid)) + { + list.Add(caseMember); + _membersByUid[caseMember.Uid] = caseMember; + _xmldocs[caseMember.Uid] = caseMember.Xmldoc; + } + } + } - var kind = ClassifyType(type); - var summary = new ApiTypeSummary( - Uid: uid, - Name: DisplayTypeName(type), - Namespace: type.Namespace ?? string.Empty, - Assembly: assemblyName, - Kind: kind, - Summary: FirstSentence(parsed)); - typeBuilder.Add(summary); + if (list.Count > 0) + { + _membersByType[uid] = list; + } - typeDetails[uid] = new ApiTypeDetail( - Summary: summary, - Xmldoc: parsed, - SignatureHtml: _highlighter.Highlight(BuildTypeDeclaration(type), "csharp"), - Inheritance: InheritanceChain(type), - Implements: ImplementedInterfaces(type), - Source: null); + if (type is { IsAbstract: true, IsSealed: true } && type.Name.EndsWith("Extensions", StringComparison.Ordinal)) + { + CollectExtensions(type, assemblyName); + } - var list = new List(); - foreach (var m in type.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)) + foreach (var nested in type.GetNestedTypes(BindingFlags.Public)) + { + ReflectType(nested, assemblyName); + } + } + + public Catalog ToCatalog() => new( + _types.OrderBy(t => t.FullTypeName, StringComparer.OrdinalIgnoreCase).ToImmutableArray(), + _typeDetails.ToImmutableDictionary(), + _membersByType.ToImmutableDictionary(kv => kv.Key, kv => kv.Value.ToImmutableArray()), + _membersByUid.ToImmutableDictionary(), + _xmldocs.ToImmutableDictionary(), + _extensions + .GroupBy(e => e.ReceiverTypeName, StringComparer.Ordinal) + .ToImmutableDictionary( + g => g.Key, + g => g.OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(e => e.Signature.Length) + .ToImmutableArray(), + StringComparer.Ordinal)); + + private void AddMember(List list, HashSet seen, MemberInfo m, Type? inheritedFrom, bool suppressUndocumented) { ApiMember? apiMember; - try { apiMember = TryReflectMember(m, xmldoc); } - catch { continue; } - if (apiMember is null) + try { apiMember = TryReflectMember(m, inheritedFrom, suppressUndocumented); } + catch { return; } + if (apiMember is null || !seen.Add(apiMember.Uid)) { - continue; + return; } list.Add(apiMember); - membersByUid[apiMember.Uid] = apiMember; - xmldocs[apiMember.Uid] = apiMember.Xmldoc; + _membersByUid[apiMember.Uid] = apiMember; + _xmldocs[apiMember.Uid] = apiMember.Xmldoc; } - if (list.Count > 0) + private ApiMember? TryReflectMember(MemberInfo m, Type? inheritedFrom, bool suppressUndocumented) { - membersByType[uid] = list; + if (IsCompilerGenerated(m) || (m is FieldInfo { Name: "value__" })) + { + return null; + } + + if (m is MethodInfo method && ShouldSkipMethod(method)) + { + return null; + } + + var kind = ClassifyMember(m); + if (kind is null) + { + return null; + } + + var uid = XmlDocIdFormatter.ForMember(m); + if (string.IsNullOrEmpty(uid)) + { + return null; + } + + // Implicitly-declared members (record/class primary constructors, default + // constructors, synthesized union plumbing) carry no xmldoc — CS1591 guarantees + // every hand-written public member does. Skip implicitly-declared members + // without hiding genuinely-undocumented members on third-party assemblies: only drop + // undocumented constructors and undocumented members of a union. + if ((m is ConstructorInfo || suppressUndocumented) && !rawByUid.ContainsKey(uid)) + { + return null; + } + + var parsed = ResolveMemberDoc(m, uid, out var hasInheritDoc); + + var parameters = ImmutableArray.Empty; + string? returnTypeDisplay = null; + var typeDisplay = string.Empty; + var isRequired = false; + string? defaultValue = null; + + switch (m) + { + case MethodInfo mi: + parameters = BuildParameters(mi.GetParameters(), parsed); + typeDisplay = SignatureFormatter.Display(mi.ReturnType); + if (!string.Equals(mi.ReturnType.FullName, "System.Void", StringComparison.Ordinal)) + { + returnTypeDisplay = typeDisplay; + } + break; + case ConstructorInfo ci: + parameters = BuildParameters(ci.GetParameters(), parsed); + break; + case PropertyInfo pi: + typeDisplay = SignatureFormatter.Display(pi.PropertyType); + isRequired = IsRequired(pi); + break; + case FieldInfo fi: + typeDisplay = SignatureFormatter.Display(fi.FieldType); + if (fi.IsLiteral) + { + defaultValue = SignatureFormatter.FormatConstant(fi.GetRawConstantValue(), fi.FieldType); + } + break; + case EventInfo ei: + typeDisplay = SignatureFormatter.Display(ei.EventHandlerType!); + break; + } + + return new ApiMember( + Uid: uid, + Name: MemberDisplayName(m), + Kind: kind.Value, + TypeDisplay: typeDisplay, + DefaultValue: defaultValue, + IsRequired: isRequired, + HasInheritDocDirective: hasInheritDoc, + Xmldoc: parsed, + SignatureHtml: highlighter.Highlight(SignatureFormatter.MemberDeclaration(m), "csharp"), + Parameters: parameters, + ReturnTypeDisplay: returnTypeDisplay, + InheritedFromUid: inheritedFrom is null ? null : XmlDocIdFormatter.ForType(inheritedFrom), + InheritedFromName: inheritedFrom?.Name); + } + + private IEnumerable BuildUnionCases(Type type) + { + // Each case surfaces as the single parameter of a generated constructor; the compiler + // emits one such ctor per case (the case type is never the union itself). + var caseTypes = type.GetConstructors() + .Where(c => c.GetParameters().Length == 1) + .Select(c => c.GetParameters()[0].ParameterType) + .Where(ct => ct != type) + .Distinct(); + + foreach (var caseType in caseTypes) + { + var uid = XmlDocIdFormatter.ForType(caseType); + var raw = rawByUid.GetValueOrDefault(uid); + yield return new ApiMember( + Uid: uid, + Name: DisplayTypeName(caseType), + Kind: MemberKind.UnionCases, + TypeDisplay: SignatureFormatter.Display(caseType), + DefaultValue: null, + IsRequired: false, + HasInheritDocDirective: raw is not null && raw.Contains("inheritdoc", StringComparison.Ordinal), + Xmldoc: ResolveTypeDoc(caseType, uid), + SignatureHtml: highlighter.Highlight(SignatureFormatter.TypeDeclaration(caseType), "csharp"), + Parameters: ImmutableArray.Empty, + ReturnTypeDisplay: null); + } } - foreach (var nested in type.GetNestedTypes(BindingFlags.Public)) + private void CollectExtensions(Type type, string assemblyName) { - ReflectType(nested, assemblyName, xmldoc, typeBuilder, typeDetails, membersByType, membersByUid, xmldocs); + foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)) + { + if (!IsExtensionMethod(method)) + { + continue; + } + + var parameters = method.GetParameters(); + if (parameters.Length == 0) + { + continue; + } + + var receiver = StripArity(parameters[0].ParameterType.Name); + var uid = XmlDocIdFormatter.ForMember(method); + if (string.IsNullOrEmpty(receiver) || string.IsNullOrEmpty(uid)) + { + continue; + } + + _extensions.Add(new ExtensionMethodEntry( + Name: MemberDisplayName(method), + Signature: SignatureFormatter.ExtensionSignature(method), + Package: assemblyName, + Uid: uid, + ReceiverTypeName: receiver, + Xmldoc: ResolveMemberDoc(method, uid, out _))); + } } - } - private ApiMember? TryReflectMember(MemberInfo m, XmlDocFile xmldoc) - { - if (IsCompilerGenerated(m)) + private bool ShouldDocument(Type type, string uid) { - return null; + // Only document types carrying their own xmldoc, + // excluding delegates, attributes, Razor components, and the top-level Program class. + // Generated component/infrastructure types have no `///` doc, so the xmldoc check + // alone filters most of them — the base-type checks catch the documented stragglers. + if (!rawByUid.ContainsKey(uid) || type.Name == "Program") + { + return false; + } + + return !InheritsFrom(type, "System.MulticastDelegate") + && !InheritsFrom(type, "System.Attribute") + && !InheritsFrom(type, "Microsoft.AspNetCore.Components.ComponentBase"); } - if (m is MethodInfo method && ShouldSkipMethod(method)) + private static bool InheritsFrom(Type type, string baseFullName) { - return null; + for (var b = type.BaseType; b is not null; b = b.BaseType) + { + if (string.Equals(b.FullName, baseFullName, StringComparison.Ordinal)) + { + return true; + } + } + + return false; } - var uid = XmlDocIdFormatter.ForMember(m); - if (string.IsNullOrEmpty(uid)) + private ParsedXmlDoc ResolveTypeDoc(Type type, string uid) { - return null; + var raw = ReflectionInheritDocResolver.Resolve(type, rawByUid) ?? rawByUid.GetValueOrDefault(uid); + return string.IsNullOrWhiteSpace(raw) ? ParsedXmlDoc.Empty : parser.Parse(raw); } - var parsed = xmldoc.Get(uid); - var kind = ClassifyMember(m); - if (kind is null) + private ParsedXmlDoc ResolveMemberDoc(MemberInfo member, string uid, out bool hasInheritDoc) { - return null; + var rawSelf = rawByUid.GetValueOrDefault(uid); + hasInheritDoc = rawSelf is not null && rawSelf.Contains("inheritdoc", StringComparison.Ordinal); + + var resolved = ReflectionInheritDocResolver.Resolve(member, rawByUid) ?? rawSelf; + resolved = ReflectionRecordParamFallback.Resolve(resolved, member, rawByUid); + return string.IsNullOrWhiteSpace(resolved) ? ParsedXmlDoc.Empty : parser.Parse(resolved); } - var parameters = ImmutableArray.Empty; - string? returnTypeDisplay = null; - var typeDisplay = string.Empty; - var isRequired = false; + private static bool IsExtensionMethod(MethodInfo m) + => m.GetCustomAttributesData().Any(a => a.AttributeType.FullName == "System.Runtime.CompilerServices.ExtensionAttribute"); - switch (m) + private static string StripArity(string name) { - case MethodInfo mi: - parameters = BuildParameters(mi.GetParameters(), parsed); - var returnsVoid = string.Equals(mi.ReturnType.FullName, "System.Void", StringComparison.Ordinal); - if (!returnsVoid) - { - returnTypeDisplay = SignatureFormatter.Display(mi.ReturnType); - } - typeDisplay = SignatureFormatter.Display(mi.ReturnType); - break; - case ConstructorInfo ci: - parameters = BuildParameters(ci.GetParameters(), parsed); - break; - case PropertyInfo pi: - typeDisplay = SignatureFormatter.Display(pi.PropertyType); - isRequired = IsRequired(pi); - break; - case FieldInfo fi: - typeDisplay = SignatureFormatter.Display(fi.FieldType); - break; - case EventInfo ei: - typeDisplay = SignatureFormatter.Display(ei.EventHandlerType!); - break; - } - - return new ApiMember( - Uid: uid, - Name: MemberDisplayName(m), - Kind: kind.Value, - TypeDisplay: typeDisplay, - DefaultValue: null, - IsRequired: isRequired, - HasInheritDocDirective: false, - Xmldoc: parsed, - SignatureHtml: _highlighter.Highlight(SignatureFormatter.MemberDeclaration(m), "csharp"), - Parameters: parameters, - ReturnTypeDisplay: returnTypeDisplay); + var tick = name.IndexOf('`'); + return tick < 0 ? name : name[..tick]; + } } private static ImmutableArray BuildParameters(ParameterInfo[] parameters, ParsedXmlDoc parsed) @@ -308,51 +583,28 @@ private static ImmutableArray BuildParameters(ParameterInfo[] para private static bool ShouldSkipType(Type t) { - if (!(t.IsPublic || t.IsNestedPublic)) - { - return false; // GetExportedTypes already handles this; belt-and-suspenders - } // Filter out compiler-generated helper types. if (t.GetCustomAttributesData().Any(a => a.AttributeType.FullName == "System.Runtime.CompilerServices.CompilerGeneratedAttribute")) { return true; } - // Skip nested types here — the outer type enumerates them explicitly so we visit - // them with the right reflection context. Treating them as top-level doubles them up. - if (t.IsNested) - { - return true; - } - return false; + // Skip nested types here — the outer type enumerates them explicitly so we visit them + // with the right reflection context. Treating them as top-level doubles them up. + return t.IsNested; } private static bool ShouldSkipMethod(MethodInfo m) { - // Skip property/event accessors and operator backing methods — they'll be surfaced - // via the property/event itself. + // Skip property/event accessors and operators. Accessors surface via the property/event + // itself; the member table lists only ordinary methods, so operators (op_Implicit, + // op_Equality, ...) are excluded. var name = m.Name; - if (name.StartsWith("get_", StringComparison.Ordinal)) - { - return true; - } - - if (name.StartsWith("set_", StringComparison.Ordinal)) - { - return true; - } - - if (name.StartsWith("add_", StringComparison.Ordinal)) - { - return true; - } - - if (name.StartsWith("remove_", StringComparison.Ordinal)) - { - return true; - } - - return false; + return name.StartsWith("get_", StringComparison.Ordinal) + || name.StartsWith("set_", StringComparison.Ordinal) + || name.StartsWith("add_", StringComparison.Ordinal) + || name.StartsWith("remove_", StringComparison.Ordinal) + || name.StartsWith("op_", StringComparison.Ordinal); } private static bool IsCompilerGenerated(MemberInfo m) @@ -383,27 +635,42 @@ private static ApiTypeKind ClassifyType(Type t) return ApiTypeKind.Interface; } - if (t.IsValueType) + if (typeof(MulticastDelegate).IsAssignableFrom(t.BaseType)) { - return ApiTypeKind.Struct; + return ApiTypeKind.Delegate; + } + + if (IsRecord(t)) + { + return ApiTypeKind.Record; } - if (t.GetCustomAttributesData().Any(a => a.AttributeType.FullName == "System.Runtime.CompilerServices.IsReadOnlyAttribute" && t.IsValueType)) + if (t.IsValueType) { return ApiTypeKind.Struct; } - if (IsRecord(t)) + return ApiTypeKind.Class; + } + + private static bool IsUnion(Type t) + { + if (!t.IsValueType) { - return ApiTypeKind.Record; + return false; } - if (typeof(MulticastDelegate).IsAssignableFrom(t.BaseType)) + // Both the C# 15 `union` keyword and the polyfill emit [Union] on the struct and + // implement IUnion — either signal alone is enough. + if (t.GetCustomAttributesData().Any(a => + a.AttributeType.Name == "UnionAttribute" + && a.AttributeType.Namespace == "System.Runtime.CompilerServices")) { - return ApiTypeKind.Delegate; + return true; } - return ApiTypeKind.Class; + return t.GetInterfaces().Any(i => + i.Name == "IUnion" && i.Namespace == "System.Runtime.CompilerServices"); } private static bool IsRecord(Type t) @@ -411,21 +678,12 @@ private static bool IsRecord(Type t) private static string DisplayTypeName(Type t) { - if (!t.IsGenericType) - { - return t.Name; - } - + // Use the bare type name without generic-arity backtick or type-argument list. + // The full generic form lives in the signature HTML; the short name here also + // drives the page slug. var name = t.Name; var tick = name.IndexOf('`'); - if (tick < 0) - { - return name; - } - - var baseName = name[..tick]; - var args = t.GetGenericArguments(); - return baseName + "<" + string.Join(", ", args.Select(a => a.Name)) + ">"; + return tick < 0 ? name : name[..tick]; } private static string MemberDisplayName(MemberInfo m) @@ -439,21 +697,8 @@ private static string MemberDisplayName(MemberInfo m) { return mi.Name + "<" + string.Join(", ", mi.GetGenericArguments().Select(a => a.Name)) + ">"; } - return m.Name; - } - private static string BuildTypeDeclaration(Type t) - { - var kind = t.IsEnum ? "enum" - : t.IsInterface ? "interface" - : t.IsValueType ? "struct" - : IsRecord(t) ? "record" - : typeof(MulticastDelegate).IsAssignableFrom(t.BaseType) ? "delegate" - : "class"; - - var access = "public "; - var modifiers = t.IsSealed && !t.IsValueType && !t.IsEnum ? "sealed " : t.IsAbstract && !t.IsInterface ? "abstract " : string.Empty; - return access + modifiers + kind + " " + DisplayTypeName(t); + return m.Name; } private static ImmutableArray InheritanceChain(Type t) @@ -469,12 +714,7 @@ private static ImmutableArray InheritanceChain(Type t) private static ImmutableArray ImplementedInterfaces(Type t) { var ifaces = t.GetInterfaces(); - if (ifaces.Length == 0) - { - return []; - } - - return ifaces.Select(XmlDocIdFormatter.ForType).ToImmutableArray(); + return ifaces.Length == 0 ? [] : ifaces.Select(XmlDocIdFormatter.ForType).ToImmutableArray(); } private static string? FirstSentence(ParsedXmlDoc doc) @@ -502,6 +742,7 @@ private static ImmutableArray ImplementedInterfaces(Type t) break; } } + var collapsed = System.Text.RegularExpressions.Regex.Replace(sb.ToString(), @"\s+", " ").Trim(); if (collapsed.Length == 0) { @@ -512,98 +753,20 @@ private static ImmutableArray ImplementedInterfaces(Type t) return period > 0 ? collapsed[..(period + 1)] : collapsed; } - private static IEnumerable BuildResolverPaths(IEnumerable assemblyPaths) - { - // MetadataLoadContext rejects duplicate AssemblyNames, so we dedupe by the - // assembly's simple name (the filename without extension) and keep the first - // winner. Target assemblies come first, then the running .NET runtime, then - // peer shared frameworks (AspNetCore.App, WindowsDesktop.App) at their latest - // version only. - var bySimpleName = new Dictionary(StringComparer.OrdinalIgnoreCase); - - void Offer(string dllPath) - { - var simple = Path.GetFileNameWithoutExtension(dllPath); - if (!bySimpleName.ContainsKey(simple)) - { - bySimpleName[simple] = dllPath; - } - } - - foreach (var path in assemblyPaths) - { - Offer(path); - var dir = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(dir)) - { - foreach (var sibling in Directory.EnumerateFiles(dir, "*.dll")) - { - Offer(sibling); - } - } - } - - var runtime = RuntimeEnvironment.GetRuntimeDirectory(); - if (Directory.Exists(runtime)) - { - foreach (var dll in Directory.EnumerateFiles(runtime, "*.dll")) - { - Offer(dll); - } - } - - foreach (var frameworkDir in DiscoverLatestSharedFrameworks(runtime)) - { - foreach (var dll in Directory.EnumerateFiles(frameworkDir, "*.dll")) - { - Offer(dll); - } - } - - return bySimpleName.Values; - } - - private static IEnumerable DiscoverLatestSharedFrameworks(string runtimeDir) - { - // Runtime dir looks like .../shared/Microsoft.NETCore.App/X.Y.Z - // Peer shared frameworks (AspNetCore.App, WindowsDesktop.App) sit under `shared`. - // Only yield the highest-version subdirectory of each to avoid duplicates. - var ncaDir = new DirectoryInfo(runtimeDir); - var sharedRoot = ncaDir.Parent?.Parent; - if (sharedRoot is null || !sharedRoot.Exists) - { - yield break; - } - - foreach (var frameworkRoot in sharedRoot.EnumerateDirectories()) - { - if (string.Equals(frameworkRoot.Name, ncaDir.Parent!.Name, StringComparison.OrdinalIgnoreCase)) - { - // Already covered by RuntimeEnvironment.GetRuntimeDirectory(). - continue; - } - var latest = frameworkRoot.EnumerateDirectories() - .OrderByDescending(d => d.Name, StringComparer.OrdinalIgnoreCase) - .FirstOrDefault(); - if (latest is not null) - { - yield return latest.FullName; - } - } - } - private sealed record Catalog( ImmutableArray Types, ImmutableDictionary TypeDetails, ImmutableDictionary> MembersByType, ImmutableDictionary MembersByUid, - ImmutableDictionary Xmldocs) + ImmutableDictionary Xmldocs, + ImmutableDictionary> Extensions) { public static Catalog Empty { get; } = new( [], ImmutableDictionary.Empty, ImmutableDictionary>.Empty, ImmutableDictionary.Empty, - ImmutableDictionary.Empty); + ImmutableDictionary.Empty, + ImmutableDictionary>.Empty); } -} \ No newline at end of file +} diff --git a/src/Pennington.ApiMetadata.Reflection/ReflectionInheritDocResolver.cs b/src/Pennington.ApiMetadata.Reflection/ReflectionInheritDocResolver.cs new file mode 100644 index 00000000..bd42d8c8 --- /dev/null +++ b/src/Pennington.ApiMetadata.Reflection/ReflectionInheritDocResolver.cs @@ -0,0 +1,244 @@ +namespace Pennington.ApiMetadata.Reflection; + +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Xml.Linq; + +/// +/// Replaces +/// <inheritdoc/> elements in a member's raw xmldoc with the doc children merged +/// from the first base member (override chain first, then implemented interfaces) that carries +/// documentation. Child tags win over inherited tags; <param>/<typeparam> +/// merge by name. Raw XML is looked up by uid across every loaded assembly, so a member can +/// inherit docs from a base declared elsewhere. <inheritdoc cref="..."/> is left in place. +/// +internal static class ReflectionInheritDocResolver +{ + private const int MaxDepth = 8; + + /// Returns the member's resolved xmldoc, or its raw xmldoc unchanged when nothing inherits or no base resolves. when the member has no raw xmldoc at all. + public static string? Resolve(MemberInfo member, IReadOnlyDictionary rawByUid) + { + var raw = rawByUid.GetValueOrDefault(UidOf(member)); + return ResolveCore(raw, member, rawByUid, depth: 0); + } + + private static string? ResolveCore(string? rawXml, MemberInfo member, IReadOnlyDictionary rawByUid, int depth) + { + if (depth >= MaxDepth + || string.IsNullOrWhiteSpace(rawXml) + || !rawXml.Contains("inheritdoc", System.StringComparison.Ordinal)) + { + return rawXml; + } + + XDocument doc; + try + { + doc = XDocument.Parse(rawXml, LoadOptions.PreserveWhitespace); + } + catch + { + return rawXml; + } + + var root = doc.Root; + if (root is null) + { + return rawXml; + } + + var inheritElements = root.Elements("inheritdoc") + .Where(e => e.Attribute("cref") is null) + .ToList(); + if (inheritElements.Count == 0) + { + return rawXml; + } + + var baseRoot = FindBaseDocRoot(member, rawByUid, depth); + if (baseRoot is null) + { + return rawXml; + } + + var existingTags = new HashSet(root.Elements() + .Where(e => e.Name.LocalName != "inheritdoc") + .Select(e => e.Name.LocalName)); + var existingParams = new HashSet(root.Elements("param") + .Select(e => e.Attribute("name")?.Value ?? string.Empty)); + var existingTypeParams = new HashSet(root.Elements("typeparam") + .Select(e => e.Attribute("name")?.Value ?? string.Empty)); + + var toAdd = new List(); + foreach (var child in baseRoot.Elements()) + { + var name = child.Name.LocalName; + if (name == "inheritdoc") + { + continue; + } + + if (name == "param") + { + if (!existingParams.Contains(child.Attribute("name")?.Value ?? string.Empty)) + { + toAdd.Add(new XElement(child)); + } + } + else if (name == "typeparam") + { + if (!existingTypeParams.Contains(child.Attribute("name")?.Value ?? string.Empty)) + { + toAdd.Add(new XElement(child)); + } + } + else if (!existingTags.Contains(name)) + { + toAdd.Add(new XElement(child)); + } + } + + foreach (var e in inheritElements) + { + e.Remove(); + } + + foreach (var e in toAdd) + { + root.Add(e); + } + + return root.ToString(); + } + + private static XElement? FindBaseDocRoot(MemberInfo member, IReadOnlyDictionary rawByUid, int depth) + { + foreach (var candidate in GetCandidates(member)) + { + var raw = rawByUid.GetValueOrDefault(UidOf(candidate)); + if (string.IsNullOrWhiteSpace(raw)) + { + continue; + } + + var resolved = ResolveCore(raw, candidate, rawByUid, depth + 1); + if (string.IsNullOrWhiteSpace(resolved)) + { + continue; + } + + try + { + if (XDocument.Parse(resolved, LoadOptions.PreserveWhitespace).Root is { } root) + { + return root; + } + } + catch + { + // Skip a malformed base doc and try the next candidate. + } + } + + return null; + } + + private static IEnumerable GetCandidates(MemberInfo member) + { + if (member is Type type) + { + if (type.BaseType is { } bt && bt != typeof(object)) + { + yield return bt; + } + + foreach (var iface in type.GetInterfaces()) + { + yield return iface; + } + + yield break; + } + + var declaring = member.DeclaringType; + if (declaring is null) + { + yield break; + } + + // Override chain: the same member shape declared further up the base-type chain. + for (var baseType = declaring.BaseType; baseType is not null; baseType = baseType.BaseType) + { + if (FindMatch(baseType, member, declaredOnly: true) is { } baseMember) + { + yield return baseMember; + } + } + + // Implemented (or, for interfaces, inherited) interface members. + foreach (var iface in declaring.GetInterfaces()) + { + if (FindMatch(iface, member, declaredOnly: false) is { } ifaceMember) + { + yield return ifaceMember; + } + } + } + + private static MemberInfo? FindMatch(Type container, MemberInfo target, bool declaredOnly) + { + var flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; + if (declaredOnly) + { + flags |= BindingFlags.DeclaredOnly; + } + + foreach (var candidate in container.GetMembers(flags)) + { + if (candidate.MemberType != target.MemberType + || !string.Equals(candidate.Name, target.Name, System.StringComparison.Ordinal)) + { + continue; + } + + if (SignaturesMatch(candidate, target)) + { + return candidate; + } + } + + return null; + } + + private static bool SignaturesMatch(MemberInfo a, MemberInfo b) => (a, b) switch + { + (MethodInfo ma, MethodInfo mb) => ParametersMatch(ma.GetParameters(), mb.GetParameters()), + (PropertyInfo pa, PropertyInfo pb) => ParametersMatch(pa.GetIndexParameters(), pb.GetIndexParameters()), + _ => true, + }; + + private static bool ParametersMatch(ParameterInfo[] a, ParameterInfo[] b) + { + if (a.Length != b.Length) + { + return false; + } + + for (var i = 0; i < a.Length; i++) + { + // Compare by metadata name — the two parameters come from different assemblies' + // MetadataLoadContext views, so reference equality won't hold. + if (!string.Equals(a[i].ParameterType.FullName, b[i].ParameterType.FullName, System.StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + private static string UidOf(MemberInfo member) + => member is Type type ? XmlDocIdFormatter.ForType(type) : XmlDocIdFormatter.ForMember(member); +} diff --git a/src/Pennington.ApiMetadata.Reflection/ReflectionRecordParamFallback.cs b/src/Pennington.ApiMetadata.Reflection/ReflectionRecordParamFallback.cs new file mode 100644 index 00000000..f5f5b5ed --- /dev/null +++ b/src/Pennington.ApiMetadata.Reflection/ReflectionRecordParamFallback.cs @@ -0,0 +1,106 @@ +namespace Pennington.ApiMetadata.Reflection; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Xml.Linq; + +/// +/// Supplies xmldoc for positional record properties that carry none of their own. A positional +/// record property has no xmldoc of its own — its effective doc is the containing record's +/// matching <param name="..."/>. When a property has no summary and is positional, +/// this returns a synthetic <member><summary>…</summary></member> +/// built from that param. +/// +internal static class ReflectionRecordParamFallback +{ + /// Returns unchanged unless is a positional record property with no summary, in which case it returns synthetic xmldoc carrying the record's matching <param> body. + public static string? Resolve(string? resolvedXml, MemberInfo member, IReadOnlyDictionary rawByUid) + { + if (member is not PropertyInfo property || property.DeclaringType is not { } declaring) + { + return resolvedXml; + } + + if (HasSummary(resolvedXml) || !IsPositionalRecordProperty(property, declaring)) + { + return resolvedXml; + } + + var typeXml = rawByUid.GetValueOrDefault(XmlDocIdFormatter.ForType(declaring)); + if (string.IsNullOrWhiteSpace(typeXml)) + { + return resolvedXml; + } + + XDocument typeDoc; + try + { + typeDoc = XDocument.Parse(typeXml, LoadOptions.PreserveWhitespace); + } + catch + { + return resolvedXml; + } + + var paramElement = typeDoc.Root? + .Elements("param") + .FirstOrDefault(p => string.Equals(p.Attribute("name")?.Value, property.Name, StringComparison.Ordinal)); + if (paramElement is null || !paramElement.Nodes().Any()) + { + return resolvedXml; + } + + var summary = new XElement("summary", paramElement.Nodes().Select(CloneNode)); + return new XElement("member", summary).ToString(); + } + + private static bool HasSummary(string? xml) + { + if (string.IsNullOrWhiteSpace(xml)) + { + return false; + } + + try + { + var summary = XDocument.Parse(xml, LoadOptions.PreserveWhitespace).Root?.Element("summary"); + return summary is not null && summary.Nodes().Any(); + } + catch + { + return false; + } + } + + private static bool IsPositionalRecordProperty(PropertyInfo property, Type declaring) + { + // A record exposes a synthesized `$` method; positional members appear as a + // constructor parameter sharing the property's exact name (and type). + if (!declaring.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Any(m => m.Name == "$")) + { + return false; + } + + foreach (var ctor in declaring.GetConstructors()) + { + if (ctor.GetParameters().Any(p => + string.Equals(p.Name, property.Name, StringComparison.Ordinal) + && string.Equals(p.ParameterType.FullName, property.PropertyType.FullName, StringComparison.Ordinal))) + { + return true; + } + } + + return false; + } + + private static XNode CloneNode(XNode node) => node switch + { + XElement e => new XElement(e), + XText t => new XText(t), + _ => new XText(node.ToString()), + }; +} diff --git a/src/Pennington.ApiMetadata.Reflection/SignatureFormatter.cs b/src/Pennington.ApiMetadata.Reflection/SignatureFormatter.cs index 8b49d99a..d91dcdb1 100644 --- a/src/Pennington.ApiMetadata.Reflection/SignatureFormatter.cs +++ b/src/Pennington.ApiMetadata.Reflection/SignatureFormatter.cs @@ -1,5 +1,9 @@ namespace Pennington.ApiMetadata.Reflection; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; using System.Reflection; using System.Text; @@ -180,7 +184,222 @@ private static void AppendParameters(StringBuilder sb, ParameterInfo[] parameter sb.Append(Display(p.ParameterType)); } sb.Append(' ').Append(p.Name); + if (p.HasDefaultValue) + { + sb.Append(" = ").Append(FormatConstant(p.RawDefaultValue, p.ParameterType)); + } } sb.Append(')'); } + + /// + /// Renders the full type declaration line — modifiers, kind keyword, name with type + /// parameters, record positional parameters, base list, and generic constraints — + /// e.g. public sealed record Foo<T>(int A) : Bar, IBaz where T : class. + /// + public static string TypeDeclaration(Type t) + { + var sb = new StringBuilder("public "); + + if (t.IsAbstract && t.IsSealed && !t.IsEnum) + { + sb.Append("static "); + } + else if (t.IsSealed && !t.IsValueType && !t.IsEnum && !IsDelegate(t)) + { + sb.Append("sealed "); + } + else if (t.IsAbstract && !t.IsInterface) + { + sb.Append("abstract "); + } + + var isRecord = IsRecord(t); + var keyword = t.IsEnum ? "enum" + : t.IsInterface ? "interface" + : IsDelegate(t) ? "delegate" + : t.IsValueType ? (isRecord ? "record struct" : "struct") + : isRecord ? "record" : "class"; + sb.Append(keyword).Append(' ').Append(GenericTypeName(t)); + + AppendRecordParameters(sb, t, isRecord); + AppendBaseList(sb, t); + AppendConstraints(sb, t); + return sb.ToString(); + } + + /// Renders an extension method as a stand-alone signature with this on the receiver parameter, used on the receiver type's reference page. + public static string ExtensionSignature(MethodInfo m) + { + var sb = new StringBuilder(); + sb.Append(Display(m.ReturnType)).Append(' ').Append(m.Name); + if (m.IsGenericMethod) + { + sb.Append('<').Append(string.Join(", ", m.GetGenericArguments().Select(a => a.Name))).Append('>'); + } + + var parameters = m.GetParameters(); + sb.Append('('); + for (var i = 0; i < parameters.Length; i++) + { + if (i > 0) + { + sb.Append(", "); + } + + if (i == 0) + { + sb.Append("this "); + } + + sb.Append(Display(parameters[i].ParameterType)).Append(' ').Append(parameters[i].Name); + } + sb.Append(')'); + return sb.ToString(); + } + + /// Formats a compile-time constant (const field value or default parameter value) as the C# literal a reader would type. + public static string FormatConstant(object? value, Type type) + { + if (value is null) + { + return type.IsValueType && Nullable.GetUnderlyingType(type) is null ? "default" : "null"; + } + + return value switch + { + string s => "\"" + s + "\"", + bool b => b ? "true" : "false", + char c => "'" + c + "'", + _ => Convert.ToString(value, CultureInfo.InvariantCulture) ?? value.ToString() ?? string.Empty, + }; + } + + private static void AppendRecordParameters(StringBuilder sb, Type t, bool isRecord) + { + if (!isRecord) + { + return; + } + + // The positional parameters live on the primary constructor — the public ctor whose + // parameters are not the synthesized copy constructor (single same-type parameter). + var primary = t.GetConstructors() + .FirstOrDefault(c => c.GetParameters() is { Length: > 0 } ps + && !(ps.Length == 1 && ps[0].ParameterType == t)); + if (primary is null) + { + return; + } + + AppendParameters(sb, primary.GetParameters()); + } + + private static void AppendBaseList(StringBuilder sb, Type t) + { + if (t.IsEnum || t.IsInterface || IsDelegate(t)) + { + return; + } + + var bases = new List(); + if (t.BaseType is { } bt && bt != typeof(object) && bt != typeof(ValueType)) + { + bases.Add(Display(bt)); + } + + bases.AddRange(DirectInterfaces(t).Select(Display)); + if (bases.Count > 0) + { + sb.Append(" : ").Append(string.Join(", ", bases)); + } + } + + private static void AppendConstraints(StringBuilder sb, Type t) + { + if (!t.IsGenericTypeDefinition) + { + return; + } + + foreach (var arg in t.GetGenericArguments()) + { + var clause = ConstraintClause(arg); + if (clause is not null) + { + sb.Append(" where ").Append(arg.Name).Append(" : ").Append(clause); + } + } + } + + private static string? ConstraintClause(Type arg) + { + var parts = new List(); + var attrs = arg.GenericParameterAttributes; + if (attrs.HasFlag(GenericParameterAttributes.ReferenceTypeConstraint)) + { + parts.Add("class"); + } + else if (attrs.HasFlag(GenericParameterAttributes.NotNullableValueTypeConstraint)) + { + parts.Add("struct"); + } + + foreach (var c in arg.GetGenericParameterConstraints()) + { + if (c == typeof(ValueType)) + { + continue; // covered by the struct constraint above + } + + parts.Add(Display(c)); + } + + if (attrs.HasFlag(GenericParameterAttributes.DefaultConstructorConstraint) + && !attrs.HasFlag(GenericParameterAttributes.NotNullableValueTypeConstraint)) + { + parts.Add("new()"); + } + + return parts.Count > 0 ? string.Join(", ", parts) : null; + } + + private static IEnumerable DirectInterfaces(Type t) + { + // GetInterfaces() is transitive; subtract those already implied by the base type and by + // other implemented interfaces to leave only the directly-declared set. + var all = t.GetInterfaces(); + var implied = new HashSet(); + if (t.BaseType is { } bt) + { + implied.UnionWith(bt.GetInterfaces()); + } + + foreach (var i in all) + { + implied.UnionWith(i.GetInterfaces()); + } + + return all.Where(i => !implied.Contains(i)); + } + + private static string GenericTypeName(Type t) + { + if (!t.IsGenericType) + { + return t.Name; + } + + var name = t.Name; + var tick = name.IndexOf('`'); + var baseName = tick < 0 ? name : name[..tick]; + return baseName + "<" + string.Join(", ", t.GetGenericArguments().Select(Display)) + ">"; + } + + private static bool IsRecord(Type t) + => t.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Any(m => m.Name == "$"); + + private static bool IsDelegate(Type t) + => typeof(MulticastDelegate).IsAssignableFrom(t.BaseType); } \ No newline at end of file diff --git a/src/Pennington.ApiMetadata.Reflection/XmlDocFile.cs b/src/Pennington.ApiMetadata.Reflection/XmlDocFile.cs index 0f5dd19f..3663a2c8 100644 --- a/src/Pennington.ApiMetadata.Reflection/XmlDocFile.cs +++ b/src/Pennington.ApiMetadata.Reflection/XmlDocFile.cs @@ -5,28 +5,24 @@ namespace Pennington.ApiMetadata.Reflection; using System.Xml.Linq; /// -/// Indexes a Roslyn-emitted .xml xmldoc file by xmldocid, so reflection-side -/// consumers can pull parsed doc trees by the id they compute from -/// or . +/// Loads a compiler-emitted .xml xmldoc file into a uid → raw <member> map. +/// The text is kept unparsed because <inheritdoc/> resolution +/// () and the record-parameter fallback +/// () operate on the raw XML and span assemblies — +/// a single global map keyed by uid lets a member's doc resolve against a base declared in +/// another assembly before anything is parsed. /// -internal sealed class XmlDocFile +internal static class XmlDocFile { - private readonly Dictionary _byId; - - private XmlDocFile(Dictionary byId) - { - _byId = byId; - } - - /// An empty index — used when an assembly has no companion xmldoc file. - public static XmlDocFile Empty { get; } = new(new Dictionary()); - - /// Parses the xmldoc XML at . Returns if the file is missing or malformed. - public static XmlDocFile Load(string path, IXmlDocParser parser) + /// + /// Adds every <member name="..."> entry from the xmldoc at + /// into , keyed by uid. A missing or malformed file contributes nothing. + /// + public static void LoadInto(string path, IDictionary target) { if (!File.Exists(path)) { - return Empty; + return; } XDocument doc; @@ -36,16 +32,15 @@ public static XmlDocFile Load(string path, IXmlDocParser parser) } catch { - return Empty; + return; } var members = doc.Root?.Element("members")?.Elements("member"); if (members is null) { - return Empty; + return; } - var map = new Dictionary(StringComparer.Ordinal); foreach (var m in members) { var name = m.Attribute("name")?.Value; @@ -54,16 +49,7 @@ public static XmlDocFile Load(string path, IXmlDocParser parser) continue; } - // Pass the element as its own root — the parser reads summary/remarks/ - // param/etc. as direct children regardless of the root name. Re-wrapping via - // `new XElement("doc", m.Nodes())` used to detach nodes from `m` mid-iteration, - // which left subsequent members with no child content. - map[name] = parser.Parse(m.ToString()); + target[name] = m.ToString(); } - return new XmlDocFile(map); } - - /// Returns the parsed xmldoc for , or when absent. - public ParsedXmlDoc Get(string uid) - => _byId.TryGetValue(uid, out var d) ? d : ParsedXmlDoc.Empty; -} \ No newline at end of file +} diff --git a/src/Pennington.ApiMetadata.Reflection/XmlDocIdFormatter.cs b/src/Pennington.ApiMetadata.Reflection/XmlDocIdFormatter.cs index 0b391e48..a19f5980 100644 --- a/src/Pennington.ApiMetadata.Reflection/XmlDocIdFormatter.cs +++ b/src/Pennington.ApiMetadata.Reflection/XmlDocIdFormatter.cs @@ -6,7 +6,7 @@ namespace Pennington.ApiMetadata.Reflection; /// /// Produces C# xmldocid strings (e.g. T:Foo.Bar, M:Foo.Bar.Baz(System.Int32)) /// for types and members obtained through reflection. Mirrors the format emitted by the -/// Roslyn compiler into .xml doc files so the string can index directly into the +/// C# compiler into .xml doc files so the string can index directly into the /// companion xmldoc. /// internal static class XmlDocIdFormatter diff --git a/src/Pennington.ApiMetadata/IApiMetadataProvider.cs b/src/Pennington.ApiMetadata/IApiMetadataProvider.cs index 685df652..59c099f7 100644 --- a/src/Pennington.ApiMetadata/IApiMetadataProvider.cs +++ b/src/Pennington.ApiMetadata/IApiMetadataProvider.cs @@ -3,7 +3,7 @@ namespace Pennington.ApiMetadata; using System.Collections.Immutable; using System.Threading.Tasks; -/// Backend-neutral source of API documentation metadata. Implementations adapt Roslyn workspaces, DocFx ManagedReference YAML, or other sources to a single contract consumed by the API reference UI. +/// Backend-neutral source of API documentation metadata. Implementations adapt compiled assemblies, or other metadata sources, to a single contract consumed by the API reference UI. public interface IApiMetadataProvider { /// Returns every documented type the provider knows about, sorted by . diff --git a/src/Pennington.ApiMetadata/Pennington.ApiMetadata.csproj b/src/Pennington.ApiMetadata/Pennington.ApiMetadata.csproj index 4d376c41..ecf225ec 100644 --- a/src/Pennington.ApiMetadata/Pennington.ApiMetadata.csproj +++ b/src/Pennington.ApiMetadata/Pennington.ApiMetadata.csproj @@ -4,7 +4,7 @@ preview enable enable - Pluggable API metadata abstractions shared by Roslyn and DocFx-YAML backends for Pennington + Pluggable API metadata abstractions shared by Pennington's API reference backends dotnet api-docs xmldoc managed-reference diff --git a/src/Pennington.ApiMetadata/UidDisplay.cs b/src/Pennington.ApiMetadata/UidDisplay.cs index d2d5c19a..59a34bf8 100644 --- a/src/Pennington.ApiMetadata/UidDisplay.cs +++ b/src/Pennington.ApiMetadata/UidDisplay.cs @@ -1,6 +1,6 @@ namespace Pennington.ApiMetadata; -/// Formatting helpers for xmldocid strings (the T:, M:, P:… prefixed uids Roslyn emits). +/// Formatting helpers for xmldocid strings (the T:, M:, P:… prefixed uids the C# compiler emits). public static class UidDisplay { /// Returns the short, unqualified display name for a uid (e.g. T:System.Collections.Generic.List`1List), stripping the kind prefix, any parameter list, the namespace, and generic-arity markers. diff --git a/src/Pennington.Roslyn/ApiMetadata/ApiReferenceOptions.cs b/src/Pennington.Roslyn/ApiMetadata/ApiReferenceOptions.cs deleted file mode 100644 index b15fec5b..00000000 --- a/src/Pennington.Roslyn/ApiMetadata/ApiReferenceOptions.cs +++ /dev/null @@ -1,46 +0,0 @@ -namespace Pennington.Roslyn.ApiMetadata; - -using System.Reflection; -using Microsoft.CodeAnalysis; - -/// Filters applied by when walking the Roslyn workspace. -public sealed record ApiReferenceOptions -{ - /// Predicate selecting which projects contribute types. Defaults to when unset. - public Predicate? ProjectFilter { get; set; } - - /// Extra per-type inclusion predicate applied on top of the built-in rules (public, non-delegate, non-attribute, non-ComponentBase, has xmldoc). - public Predicate? TypeFilter { get; set; } - - /// Built-in project filter that excludes *.Tests / *.IntegrationTests and the entry assembly. - public static Predicate DefaultProjectFilter() - { - var entryName = Assembly.GetEntryAssembly()?.GetName().Name; - return project => - { - var name = StripTargetFrameworkSuffix(project.Name); - if (name.EndsWith(".Tests", StringComparison.Ordinal)) - { - return false; - } - - if (name.EndsWith(".IntegrationTests", StringComparison.Ordinal)) - { - return false; - } - - if (!string.IsNullOrEmpty(entryName) && name == entryName) - { - return false; - } - - return true; - }; - } - - private static string StripTargetFrameworkSuffix(string name) - { - var open = name.LastIndexOf('('); - return open >= 0 && name.EndsWith(')') ? name[..open] : name; - } -} \ No newline at end of file diff --git a/src/Pennington.Roslyn/ApiMetadata/RoslynApiMetadataExtensions.cs b/src/Pennington.Roslyn/ApiMetadata/RoslynApiMetadataExtensions.cs deleted file mode 100644 index 70dc2c72..00000000 --- a/src/Pennington.Roslyn/ApiMetadata/RoslynApiMetadataExtensions.cs +++ /dev/null @@ -1,45 +0,0 @@ -namespace Pennington.Roslyn.ApiMetadata; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Pennington.ApiMetadata; - -/// DI extension that registers a Roslyn-backed . -public static class RoslynApiMetadataExtensions -{ - /// - /// Registers as a keyed - /// under . Call once per - /// named reference tree. Requires AddPenningtonRoslyn to have been called - /// first with a configured SolutionPath, since the provider reads the live - /// workspace. - /// - /// - /// When is "default", is - /// also exposed as a non-keyed singleton so consumers can inject it via plain - /// constructor parameters without the keyed-resolve dance. - /// - /// Service collection. - /// Registration name. Pair with the matching AddApiReference(name, …) call. Defaults to "default". - /// Optional project/type filter configuration. - public static IServiceCollection AddApiMetadataFromRoslyn( - this IServiceCollection services, - string name = "default", - Action? configure = null) - { - var options = new ApiReferenceOptions(); - configure?.Invoke(options); - services.AddKeyedSingleton(name, options); - if (name == "default") - { - services.TryAddSingleton(sp => - sp.GetRequiredKeyedService("default")); - } - services.AddKeyedSingleton(name, (sp, key) => - ActivatorUtilities.CreateInstance(sp, - sp.GetRequiredKeyedService(key))); - services.AddKeyedSingleton(name, (sp, key) => - sp.GetRequiredKeyedService(key)); - return services; - } -} \ No newline at end of file diff --git a/src/Pennington.Roslyn/ApiMetadata/RoslynApiMetadataProvider.cs b/src/Pennington.Roslyn/ApiMetadata/RoslynApiMetadataProvider.cs deleted file mode 100644 index 533e2191..00000000 --- a/src/Pennington.Roslyn/ApiMetadata/RoslynApiMetadataProvider.cs +++ /dev/null @@ -1,927 +0,0 @@ -namespace Pennington.Roslyn.ApiMetadata; - -using System.Collections.Concurrent; -using System.Collections.Immutable; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Pennington.ApiMetadata; -using Pennington.Highlighting; -using Pennington.Infrastructure; -using Pennington.Roslyn.Documentation; -using Pennington.Roslyn.Symbols; -using Pennington.Roslyn.Workspace; - -/// Roslyn-backed . Walks the configured solution to produce , , and instances with pre-formatted signatures. -public sealed class RoslynApiMetadataProvider : IApiMetadataProvider -{ - private static readonly SymbolDisplayFormat ParameterTypeFormat = new( - genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, - miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes - | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier, - typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypes); - - private static readonly SymbolDisplayFormat SignatureFormat = new( - genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, - miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes - | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier - | SymbolDisplayMiscellaneousOptions.AllowDefaultLiteral, - typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypes); - - private static readonly SymbolDisplayFormat MemberTypeFormat = new( - genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, - miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes - | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier - | SymbolDisplayMiscellaneousOptions.AllowDefaultLiteral, - typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces); - - private readonly ISolutionWorkspaceService _workspace; - private readonly ISymbolExtractionService _symbolService; - private readonly IXmlDocParser _xmlDocParser; - private readonly ICodeHighlighter _highlighter; - private readonly ApiReferenceOptions _options; - - private readonly AsyncLazy> _types; - private readonly AsyncLazy>> _extensions; - private readonly ConcurrentDictionary _detailCache = new(StringComparer.Ordinal); - - /// Initializes the provider. - public RoslynApiMetadataProvider( - ISolutionWorkspaceService workspace, - ISymbolExtractionService symbolService, - IXmlDocParser xmlDocParser, - ICodeHighlighter highlighter, - ApiReferenceOptions options) - { - _workspace = workspace; - _symbolService = symbolService; - _xmlDocParser = xmlDocParser; - _highlighter = highlighter; - _options = options; - _types = new AsyncLazy>(BuildTypesAsync); - _extensions = new AsyncLazy>>(BuildExtensionsAsync); - } - - /// - public Task> GetTypesAsync() => _types.Value; - - /// - public async Task GetTypeAsync(string uid) - { - if (_detailCache.TryGetValue(uid, out var cached)) - { - return cached; - } - - var detail = await BuildDetailAsync(uid); - _detailCache[uid] = detail; - return detail; - } - - /// - public async Task> GetMembersAsync( - string typeUid, MemberKind kind, AccessFilter access, MemberOrder order) - { - var info = await _symbolService.FindSymbolAsync(typeUid); - if (info?.Symbol is not INamedTypeSymbol type) - { - return []; - } - - var matched = EnumerateMemberSymbols(type, access) - .Where(m => MatchesKind(m.Symbol, kind)) - .ToList(); - - var builder = ImmutableArray.CreateBuilder(); - foreach (var (symbol, declaringType) in matched) - { - var apiMember = await BuildMemberAsync(symbol, declaringType); - if (apiMember is not null) - { - builder.Add(apiMember); - } - } - - if (kind is MemberKind.UnionCases or MemberKind.All && IsUnion(type)) - { - foreach (var caseMember in await BuildUnionCaseMembersAsync(type)) - { - builder.Add(caseMember); - } - } - - return order switch - { - MemberOrder.Alphabetical => builder - .OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase) - .ToImmutableArray(), - _ => builder.ToImmutable(), - }; - } - - private static IEnumerable<(ISymbol Symbol, INamedTypeSymbol? InheritedFrom)> EnumerateMemberSymbols( - INamedTypeSymbol type, AccessFilter access) - { - // Walk the type itself, then any base interfaces it inherits from. The type-level - // `GetMembers()` only returns directly-declared members, so a recently-split base - // interface (e.g. IContentService -> IContentEmitter) would otherwise drop the - // inherited surface from the API page. - var seen = new HashSet(StringComparer.Ordinal); - - foreach (var member in type.GetMembers()) - { - if (!IncludeSymbol(member, access)) - { - continue; - } - - var key = member.GetDocumentationCommentId() ?? $"{member.Kind}:{member.Name}"; - if (seen.Add(key)) - { - yield return (member, null); - } - } - - if (type.TypeKind != TypeKind.Interface) - { - yield break; - } - - foreach (var baseInterface in type.AllInterfaces) - { - // Skip interfaces that come from a metadata reference rather than source — for - // example, `IDisposable` from System.Runtime when the queried interface inherits - // it transitively. Their members don't have source we can extract a signature - // from, and they aren't part of the user's API surface that this page documents. - if (baseInterface.Locations.All(l => l.IsInMetadata)) - { - continue; - } - - foreach (var member in baseInterface.GetMembers()) - { - if (!IncludeSymbol(member, access)) - { - continue; - } - - var key = member.GetDocumentationCommentId() ?? $"{member.Kind}:{member.Name}"; - if (seen.Add(key)) - { - yield return (member, baseInterface); - } - } - } - } - - private static bool IsUnion(INamedTypeSymbol type) - { - if (type.TypeKind != TypeKind.Struct) - { - return false; - } - - // Both the C# 15 `union` keyword (net11.0+) and the polyfill shim (net10.0) - // emit/declare `[Union]` on the struct and implement `IUnion`. Either signal - // alone is enough — having both is the common case. - var hasUnionAttr = type.GetAttributes() - .Any(a => a.AttributeClass?.Name == "UnionAttribute" - && a.AttributeClass.ContainingNamespace?.ToDisplayString() == "System.Runtime.CompilerServices"); - if (hasUnionAttr) - { - return true; - } - - return type.AllInterfaces.Any(i => i.Name == "IUnion" - && i.ContainingNamespace?.ToDisplayString() == "System.Runtime.CompilerServices"); - } - - private async Task> BuildUnionCaseMembersAsync(INamedTypeSymbol type) - { - // Cases surface as the parameter type of single-arg constructors. The compiler - // (and the polyfill) generate one ctor per case taking that case type as its - // sole argument, so this enumeration is stable across both shapes. - var cases = type.InstanceConstructors - .Where(c => c.Parameters.Length == 1) - .Select(c => c.Parameters[0].Type) - .OfType() - .Distinct(SymbolEqualityComparer.Default) - .OfType() - .ToList(); - - var built = new List(cases.Count); - foreach (var caseType in cases) - { - var docId = caseType.GetDocumentationCommentId(); - if (string.IsNullOrEmpty(docId)) - { - continue; - } - - var rawXml = caseType.GetDocumentationCommentXml(); - var hasInheritDoc = !string.IsNullOrWhiteSpace(rawXml) - && rawXml.Contains("inheritdoc", StringComparison.Ordinal); - var resolvedXml = InheritDocResolver.Resolve(rawXml, caseType); - resolvedXml = RecordParamFallbackResolver.Resolve(resolvedXml, caseType); - var parsedXml = _xmlDocParser.Parse(resolvedXml); - - string? signatureHtml = null; - try - { - var decl = await _symbolService.ExtractDeclarationSignatureAsync(docId); - if (!string.IsNullOrEmpty(decl)) - { - signatureHtml = _highlighter.Highlight(decl, "csharp"); - } - } - catch - { - // Best-effort signature; the case row is still useful without it. - } - - built.Add(new ApiMember( - Uid: docId, - Name: caseType.Name, - Kind: MemberKind.UnionCases, - TypeDisplay: caseType.ToDisplayString(MemberTypeFormat), - DefaultValue: null, - IsRequired: false, - HasInheritDocDirective: hasInheritDoc, - Xmldoc: parsedXml, - SignatureHtml: signatureHtml, - Parameters: ImmutableArray.Empty, - ReturnTypeDisplay: null)); - } - - return built; - } - - /// - public async Task> GetExtensionMethodsForAsync(string receiverTypeName) - { - var index = await _extensions.Value; - return index.TryGetValue(receiverTypeName, out var entries) ? entries : []; - } - - /// - public async Task GetXmldocAsync(string uid) - { - var info = await _symbolService.FindSymbolAsync(uid); - if (info is null) - { - return ParsedXmlDoc.Empty; - } - - var raw = info.Symbol.GetDocumentationCommentXml(); - var resolved = InheritDocResolver.Resolve(raw, info.Symbol); - resolved = RecordParamFallbackResolver.Resolve(resolved, info.Symbol); - return _xmlDocParser.Parse(resolved); - } - - /// - public async Task GetMemberAsync(string uid) - { - var info = await _symbolService.FindSymbolAsync(uid); - if (info?.Symbol is not { } symbol || symbol is INamedTypeSymbol) - { - return null; - } - - return await BuildMemberAsync(symbol, inheritedFrom: null); - } - - private async Task BuildDetailAsync(string uid) - { - var info = await _symbolService.FindSymbolAsync(uid); - if (info?.Symbol is not INamedTypeSymbol type) - { - return null; - } - - var xmldoc = _xmlDocParser.Parse(type.GetDocumentationCommentXml()); - - string? signatureHtml = null; - try - { - var decl = await _symbolService.ExtractDeclarationSignatureAsync(uid); - if (!string.IsNullOrEmpty(decl)) - { - signatureHtml = _highlighter.Highlight(decl, "csharp"); - } - } - catch - { - // Declaration extraction is best-effort; leave signatureHtml null on failure. - } - - var inheritance = ImmutableArray.CreateBuilder(); - for (var b = type.BaseType; b is not null; b = b.BaseType) - { - if (b.GetDocumentationCommentId() is { Length: > 0 } id) - { - inheritance.Add(id); - } - } - - var implements = ImmutableArray.CreateBuilder(); - foreach (var iface in type.Interfaces) - { - if (iface.GetDocumentationCommentId() is { Length: > 0 } id) - { - implements.Add(id); - } - } - - return new ApiTypeDetail( - Summary: BuildSummary(type, xmldoc), - Xmldoc: xmldoc, - SignatureHtml: signatureHtml, - Inheritance: inheritance.ToImmutable(), - Implements: implements.ToImmutable(), - Source: null); - } - - private async Task BuildMemberAsync(ISymbol symbol, INamedTypeSymbol? inheritedFrom = null) - { - var kind = ClassifyMemberKind(symbol); - if (kind is null) - { - return null; - } - - var docId = symbol.GetDocumentationCommentId() ?? string.Empty; - var rawXml = symbol.GetDocumentationCommentXml(); - var hasInheritDoc = !string.IsNullOrWhiteSpace(rawXml) - && rawXml.Contains("inheritdoc", StringComparison.Ordinal); - var resolvedXml = InheritDocResolver.Resolve(rawXml, symbol); - resolvedXml = RecordParamFallbackResolver.Resolve(resolvedXml, symbol); - var parsedXml = _xmlDocParser.Parse(resolvedXml); - - string name; - string typeDisplay; - string? defaultValue; - bool isRequired; - switch (symbol) - { - case IPropertySymbol p: - name = p.Name; - typeDisplay = p.Type.ToDisplayString(MemberTypeFormat); - defaultValue = ExtractPropertyDefault(p); - isRequired = p.IsRequired; - break; - case IFieldSymbol f: - name = f.Name; - typeDisplay = f.Type.ToDisplayString(MemberTypeFormat); - defaultValue = ExtractFieldDefault(f); - isRequired = f.IsRequired; - break; - case IMethodSymbol m: - name = FormatMethodName(m); - typeDisplay = FormatMethodTypeDisplay(m); - defaultValue = null; - isRequired = false; - break; - case IEventSymbol e: - name = e.Name; - typeDisplay = e.Type.ToDisplayString(MemberTypeFormat); - defaultValue = null; - isRequired = false; - break; - default: - return null; - } - - string? signatureHtml = null; - try - { - var decl = await _symbolService.ExtractDeclarationSignatureAsync(docId); - if (!string.IsNullOrEmpty(decl)) - { - signatureHtml = _highlighter.Highlight(decl, "csharp"); - } - } - catch - { - // Signature extraction is best-effort. - } - - var parameters = ImmutableArray.Empty; - string? returnType = null; - if (symbol is IMethodSymbol method) - { - if (method.Parameters.Length > 0) - { - var paramBuilder = ImmutableArray.CreateBuilder(method.Parameters.Length); - foreach (var p in method.Parameters) - { - var description = parsedXml.Params.TryGetValue(p.Name, out var nodes) ? nodes : []; - paramBuilder.Add(new ApiParameter(p.Name, FormatParameterType(p), description)); - } - parameters = paramBuilder.ToImmutable(); - } - if (kind == MemberKind.Methods && !method.ReturnsVoid) - { - returnType = method.ReturnType.ToDisplayString(ParameterTypeFormat); - } - } - - return new ApiMember( - Uid: docId, - Name: name, - Kind: kind.Value, - TypeDisplay: typeDisplay, - DefaultValue: defaultValue, - IsRequired: isRequired, - HasInheritDocDirective: hasInheritDoc, - Xmldoc: parsedXml, - SignatureHtml: signatureHtml, - Parameters: parameters, - ReturnTypeDisplay: returnType, - InheritedFromUid: inheritedFrom?.GetDocumentationCommentId(), - InheritedFromName: inheritedFrom?.Name); - } - - private async Task> BuildTypesAsync() - { - var projects = await GetFilteredProjectsAsync(); - var seen = new HashSet(StringComparer.Ordinal); - var builder = ImmutableArray.CreateBuilder(); - - foreach (var project in projects) - { - var compilation = await _workspace.GetCompilationAsync(project); - if (compilation is null) - { - continue; - } - - foreach (var type in EnumerateTypes(compilation.Assembly.GlobalNamespace, includeNested: true)) - { - if (!ShouldInclude(type)) - { - continue; - } - - if (_options.TypeFilter is { } extra && !extra(type)) - { - continue; - } - - var xmldoc = type.GetDocumentationCommentXml(); - if (string.IsNullOrWhiteSpace(xmldoc)) - { - continue; - } - - var docId = type.GetDocumentationCommentId(); - if (string.IsNullOrEmpty(docId)) - { - continue; - } - - if (!seen.Add(docId)) - { - continue; - } - - builder.Add(new ApiTypeSummary( - Uid: docId, - Name: type.Name, - Namespace: type.ContainingNamespace.ToDisplayString(), - Assembly: project.AssemblyName ?? project.Name, - Kind: ClassifyKind(type), - Summary: ExtractSummarySentence(xmldoc))); - } - } - - return builder - .OrderBy(t => t.FullTypeName, StringComparer.OrdinalIgnoreCase) - .ToImmutableArray(); - } - - private async Task>> BuildExtensionsAsync() - { - var projects = await GetFilteredProjectsAsync(); - var collected = new List(); - - foreach (var project in projects) - { - var compilation = await _workspace.GetCompilationAsync(project); - if (compilation is null) - { - continue; - } - - foreach (var type in EnumerateTypes(compilation.Assembly.GlobalNamespace, includeNested: false) - .Where(t => t.IsStatic - && t.DeclaredAccessibility == Accessibility.Public - && t.Name.EndsWith("Extensions", StringComparison.Ordinal))) - { - foreach (var member in type.GetMembers()) - { - if (member is not IMethodSymbol { IsExtensionMethod: true } method) - { - continue; - } - - if (method.DeclaredAccessibility != Accessibility.Public) - { - continue; - } - - if (method.Parameters.Length == 0) - { - continue; - } - - var receiverName = method.Parameters[0].Type.Name; - if (string.IsNullOrEmpty(receiverName)) - { - continue; - } - - var docId = method.GetDocumentationCommentId(); - if (string.IsNullOrEmpty(docId)) - { - continue; - } - - collected.Add(new ExtensionMethodEntry( - Name: FormatMethodName(method), - Signature: FormatExtensionSignature(method), - Package: project.AssemblyName ?? project.Name, - Uid: docId, - ReceiverTypeName: receiverName, - Xmldoc: _xmlDocParser.Parse(method.GetDocumentationCommentXml()))); - } - } - } - - return collected - .GroupBy(e => e.ReceiverTypeName, StringComparer.Ordinal) - .ToImmutableDictionary( - g => g.Key, - g => g.OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase) - .ThenBy(e => e.Signature.Length) - .ToImmutableArray(), - StringComparer.Ordinal); - } - - private Task> GetFilteredProjectsAsync() - { - var effective = _options.ProjectFilter ?? ApiReferenceOptions.DefaultProjectFilter(); - return _workspace.GetProjectsAsync(p => effective(p)); - } - - private ApiTypeSummary BuildSummary(INamedTypeSymbol type, ParsedXmlDoc xmldoc) - { - var docId = type.GetDocumentationCommentId() ?? string.Empty; - var assembly = type.ContainingAssembly?.Name ?? string.Empty; - string? summary = null; - if (xmldoc.HasSummary) - { - // Use a flattened plain-text form of the summary for list/header rendering. - summary = ExtractSummarySentence(type.GetDocumentationCommentXml()); - } - - return new ApiTypeSummary( - Uid: docId, - Name: type.Name, - Namespace: type.ContainingNamespace?.ToDisplayString() ?? string.Empty, - Assembly: assembly, - Kind: ClassifyKind(type), - Summary: summary); - } - - private static bool ShouldInclude(INamedTypeSymbol type) - { - if (type.DeclaredAccessibility != Accessibility.Public) - { - return false; - } - - if (type.IsImplicitlyDeclared) - { - return false; - } - - if (type.TypeKind is TypeKind.Delegate or TypeKind.Error or TypeKind.Module) - { - return false; - } - - if (IsTopLevelStatementsProgram(type)) - { - return false; - } - - if (InheritsFrom(type, "System.Attribute")) - { - return false; - } - - if (InheritsFrom(type, "Microsoft.AspNetCore.Components.ComponentBase")) - { - return false; - } - - return true; - } - - private static bool IsTopLevelStatementsProgram(INamedTypeSymbol type) - => type.Name == "Program" && type.ContainingNamespace.IsGlobalNamespace; - - private static bool InheritsFrom(INamedTypeSymbol type, string fullyQualifiedBase) - { - for (var current = type.BaseType; current is not null; current = current.BaseType) - { - if (current.ToDisplayString() == fullyQualifiedBase) - { - return true; - } - } - return false; - } - - private static ApiTypeKind ClassifyKind(INamedTypeSymbol type) - { - if (type.IsRecord) - { - return ApiTypeKind.Record; - } - - return type.TypeKind switch - { - TypeKind.Interface => ApiTypeKind.Interface, - TypeKind.Struct => ApiTypeKind.Struct, - TypeKind.Enum => ApiTypeKind.Enum, - TypeKind.Delegate => ApiTypeKind.Delegate, - _ => ApiTypeKind.Class, - }; - } - - private static IEnumerable EnumerateTypes(INamespaceSymbol root, bool includeNested) - { - var queue = new Queue(); - queue.Enqueue(root); - - while (queue.Count > 0) - { - foreach (var member in queue.Dequeue().GetMembers()) - { - if (member is INamespaceSymbol ns) - { - queue.Enqueue(ns); - } - else if (member is INamedTypeSymbol type) - { - yield return type; - if (includeNested) - { - foreach (var nested in type.GetTypeMembers()) - { - yield return nested; - } - } - } - } - } - } - - private static string FormatParameterType(IParameterSymbol p) - { - var prefix = p.RefKind switch - { - RefKind.Ref => "ref ", - RefKind.Out => "out ", - RefKind.In => "in ", - _ => string.Empty, - }; - var typeText = p.Type.ToDisplayString(ParameterTypeFormat); - var suffix = p.HasExplicitDefaultValue ? " (optional)" : string.Empty; - return $"{prefix}{typeText}{suffix}"; - } - - private static string FormatMethodName(IMethodSymbol method) => method.TypeParameters.Length == 0 - ? method.Name - : $"{method.Name}<{string.Join(", ", method.TypeParameters.Select(t => t.Name))}>"; - - private static string FormatExtensionSignature(IMethodSymbol method) - { - var returnText = method.ReturnsVoid ? "void" : method.ReturnType.ToDisplayString(SignatureFormat); - var parameters = method.Parameters.Select((p, i) => - { - var prefix = i == 0 ? "this " : string.Empty; - var typeText = p.Type.ToDisplayString(SignatureFormat); - var suffix = p.HasExplicitDefaultValue ? " = …" : string.Empty; - return $"{prefix}{typeText}{suffix}"; - }); - return $"{returnText} {FormatMethodName(method)}({string.Join(", ", parameters)})"; - } - - private static string? ExtractSummarySentence(string? xmlDoc) - { - if (string.IsNullOrWhiteSpace(xmlDoc)) - { - return null; - } - - try - { - var doc = System.Xml.Linq.XDocument.Parse(xmlDoc); - var summary = doc.Root?.Element("summary")?.Value; - if (string.IsNullOrWhiteSpace(summary)) - { - return null; - } - - var collapsed = System.Text.RegularExpressions.Regex.Replace(summary, @"\s+", " ").Trim(); - var period = collapsed.IndexOf('.'); - return period > 0 ? collapsed[..(period + 1)] : collapsed; - } - catch - { - return null; - } - } - - private static bool IncludeSymbol(ISymbol symbol, AccessFilter access) - { - if (symbol.IsImplicitlyDeclared) - { - return false; - } - - if (symbol is IMethodSymbol method) - { - switch (method.MethodKind) - { - case MethodKind.PropertyGet: - case MethodKind.PropertySet: - case MethodKind.EventAdd: - case MethodKind.EventRemove: - case MethodKind.EventRaise: - case MethodKind.StaticConstructor: - case MethodKind.Destructor: - return false; - } - } - - return access switch - { - AccessFilter.Public => symbol.DeclaredAccessibility == Accessibility.Public, - AccessFilter.Protected => symbol.DeclaredAccessibility is Accessibility.Protected - or Accessibility.ProtectedOrInternal, - AccessFilter.PublicAndProtected => symbol.DeclaredAccessibility is Accessibility.Public - or Accessibility.Protected - or Accessibility.ProtectedOrInternal, - _ => true, - }; - } - - private static bool MatchesKind(ISymbol symbol, MemberKind kind) => kind switch - { - MemberKind.Properties => symbol is IPropertySymbol, - MemberKind.Fields => symbol is IFieldSymbol, - MemberKind.Methods => symbol is IMethodSymbol m && m.MethodKind == MethodKind.Ordinary, - MemberKind.Constructors => symbol is IMethodSymbol { MethodKind: MethodKind.Constructor }, - MemberKind.Events => symbol is IEventSymbol, - MemberKind.All => true, - _ => false, - }; - - private static MemberKind? ClassifyMemberKind(ISymbol symbol) => symbol switch - { - IPropertySymbol => MemberKind.Properties, - IFieldSymbol => MemberKind.Fields, - IMethodSymbol { MethodKind: MethodKind.Constructor } => MemberKind.Constructors, - IMethodSymbol => MemberKind.Methods, - IEventSymbol => MemberKind.Events, - _ => null, - }; - - private static string FormatMethodTypeDisplay(IMethodSymbol method) - { - var paramsText = string.Join(", ", method.Parameters.Select(p => - { - var typeText = p.Type.ToDisplayString(ParameterTypeFormat); - var prefix = p.RefKind switch - { - RefKind.Ref => "ref ", - RefKind.Out => "out ", - RefKind.In => "in ", - _ => string.Empty, - }; - var suffix = p.HasExplicitDefaultValue ? $" = {FormatConstant(p.ExplicitDefaultValue)}" : string.Empty; - return $"{prefix}{typeText} {p.Name}{suffix}"; - })); - - if (method.MethodKind == MethodKind.Constructor) - { - return $"{method.ContainingType.Name}({paramsText})"; - } - - var returnText = method.ReturnsVoid ? "void" : method.ReturnType.ToDisplayString(ParameterTypeFormat); - return $"{returnText} {FormatMethodName(method)}({paramsText})"; - } - - private static string? ExtractPropertyDefault(IPropertySymbol property) - { - foreach (var reference in property.DeclaringSyntaxReferences) - { - var syntax = reference.GetSyntax(); - - if (syntax is PropertyDeclarationSyntax propertyDecl) - { - if (propertyDecl.Initializer?.Value is { } propInit) - { - return propInit.ToString(); - } - - if (property.ContainingType.TypeKind == TypeKind.Interface) - { - if (propertyDecl.ExpressionBody?.Expression is LiteralExpressionSyntax literal) - { - return literal.ToString(); - } - return null; - } - - if (propertyDecl.ExpressionBody is not null) - { - return null; - } - - return FallbackClrDefault(property); - } - - if (syntax is ParameterSyntax parameterSyntax) - { - if (parameterSyntax.Default?.Value is { } paramDefault) - { - return paramDefault.ToString(); - } - return FallbackClrDefault(property); - } - } - - return null; - } - - private static string? FallbackClrDefault(IPropertySymbol property) - { - if (property.IsRequired) - { - return null; - } - - if (property.NullableAnnotation == NullableAnnotation.Annotated) - { - return "null"; - } - - return property.Type.SpecialType switch - { - SpecialType.System_Boolean => "false", - SpecialType.System_SByte - or SpecialType.System_Byte - or SpecialType.System_Int16 - or SpecialType.System_UInt16 - or SpecialType.System_Int32 - or SpecialType.System_UInt32 - or SpecialType.System_Int64 - or SpecialType.System_UInt64 - or SpecialType.System_Single - or SpecialType.System_Double - or SpecialType.System_Decimal => "0", - _ => null, - }; - } - - private static string? ExtractFieldDefault(IFieldSymbol field) - { - foreach (var reference in field.DeclaringSyntaxReferences) - { - if (reference.GetSyntax() is VariableDeclaratorSyntax declarator - && declarator.Initializer?.Value is { } init) - { - return init.ToString(); - } - } - - if (field.HasConstantValue) - { - return FormatConstant(field.ConstantValue); - } - - return null; - } - - private static string FormatConstant(object? value) => value switch - { - null => "null", - string s => $"\"{s}\"", - bool b => b ? "true" : "false", - char c => $"'{c}'", - _ => value.ToString() ?? "null", - }; -} \ No newline at end of file diff --git a/src/Pennington.Roslyn/Documentation/InheritDocResolver.cs b/src/Pennington.Roslyn/Documentation/InheritDocResolver.cs deleted file mode 100644 index e0ed0596..00000000 --- a/src/Pennington.Roslyn/Documentation/InheritDocResolver.cs +++ /dev/null @@ -1,190 +0,0 @@ -namespace Pennington.Roslyn.Documentation; - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Xml.Linq; -using Microsoft.CodeAnalysis; - -/// -/// Resolves <inheritdoc/> tags in xmldoc XML by walking the symbol's -/// inheritance chain (base overrides first, then implemented interface members) -/// and merging the first resolved base's doc children into the child XML. -/// Child tags win over inherited tags. -/// -internal static class InheritDocResolver -{ - private const int MaxDepth = 8; - - /// - /// Returns with <inheritdoc/> elements replaced by - /// doc children merged from the first base member with xmldoc. Returns the input - /// unchanged if there is no inheritdoc tag, no base resolves, or parsing fails. - /// <inheritdoc cref="..."/> is left in place (out of scope). - /// - public static string? Resolve(string? rawXml, ISymbol symbol) - => ResolveCore(rawXml, symbol, depth: 0); - - private static string? ResolveCore(string? rawXml, ISymbol symbol, int depth) - { - if (depth >= MaxDepth - || string.IsNullOrWhiteSpace(rawXml) - || !rawXml.Contains("inheritdoc", StringComparison.Ordinal)) - { - return rawXml; - } - - XDocument doc; - try - { - doc = XDocument.Parse(rawXml, LoadOptions.PreserveWhitespace); - } - catch - { - return rawXml; - } - - var root = doc.Root; - if (root is null) - { - return rawXml; - } - - var inheritElements = root.Elements("inheritdoc") - .Where(e => e.Attribute("cref") is null) - .ToList(); - - if (inheritElements.Count == 0) - { - return rawXml; - } - - var baseRoot = FindBaseDocRoot(symbol, depth); - if (baseRoot is null) - { - return rawXml; - } - - var existingTags = new HashSet(root.Elements() - .Where(e => e.Name.LocalName != "inheritdoc") - .Select(e => e.Name.LocalName)); - - var existingParams = new HashSet(root.Elements("param") - .Select(e => e.Attribute("name")?.Value ?? string.Empty)); - - var existingTypeParams = new HashSet(root.Elements("typeparam") - .Select(e => e.Attribute("name")?.Value ?? string.Empty)); - - var toAdd = new List(); - foreach (var child in baseRoot.Elements()) - { - var name = child.Name.LocalName; - if (name == "inheritdoc") - { - continue; - } - - if (name == "param") - { - var p = child.Attribute("name")?.Value ?? string.Empty; - if (!existingParams.Contains(p)) - { - toAdd.Add(new XElement(child)); - } - } - else if (name == "typeparam") - { - var p = child.Attribute("name")?.Value ?? string.Empty; - if (!existingTypeParams.Contains(p)) - { - toAdd.Add(new XElement(child)); - } - } - else if (!existingTags.Contains(name)) - { - toAdd.Add(new XElement(child)); - } - } - - foreach (var e in inheritElements) - { - e.Remove(); - } - - foreach (var e in toAdd) - { - root.Add(e); - } - - return root.ToString(); - } - - private static XElement? FindBaseDocRoot(ISymbol symbol, int depth) - { - foreach (var candidate in GetCandidates(symbol)) - { - var xml = candidate.GetDocumentationCommentXml(); - if (string.IsNullOrWhiteSpace(xml)) - { - continue; - } - - var resolved = ResolveCore(xml, candidate, depth + 1); - if (string.IsNullOrWhiteSpace(resolved)) - { - continue; - } - - XDocument doc; - try - { - doc = XDocument.Parse(resolved, LoadOptions.PreserveWhitespace); - } - catch - { - continue; - } - - if (doc.Root is { } root) - { - return root; - } - } - - return null; - } - - private static IEnumerable GetCandidates(ISymbol symbol) - { - ISymbol? overridden = symbol switch - { - IMethodSymbol m => m.OverriddenMethod, - IPropertySymbol p => p.OverriddenProperty, - IEventSymbol e => e.OverriddenEvent, - _ => null, - }; - - if (overridden is not null) - { - yield return overridden; - } - - var containingType = symbol.ContainingType; - if (containingType is null) - { - yield break; - } - - foreach (var iface in containingType.AllInterfaces) - { - foreach (var iMember in iface.GetMembers()) - { - var impl = containingType.FindImplementationForInterfaceMember(iMember); - if (impl is not null && SymbolEqualityComparer.Default.Equals(impl, symbol)) - { - yield return iMember; - } - } - } - } -} \ No newline at end of file diff --git a/src/Pennington.Roslyn/Documentation/RecordParamFallbackResolver.cs b/src/Pennington.Roslyn/Documentation/RecordParamFallbackResolver.cs deleted file mode 100644 index 2e5c6df8..00000000 --- a/src/Pennington.Roslyn/Documentation/RecordParamFallbackResolver.cs +++ /dev/null @@ -1,116 +0,0 @@ -namespace Pennington.Roslyn.Documentation; - -using System.Linq; -using System.Xml.Linq; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -/// -/// For positional record properties, the property's own xmldoc is empty — the -/// effective doc lives on the containing record's <param name="..."/> -/// tag. This resolver swaps the empty property xmldoc for a synthesized -/// <member><summary>...</summary></member> document -/// built from the matching param. -/// -internal static class RecordParamFallbackResolver -{ - /// - /// Returns unchanged unless the symbol is a - /// positional record property with no <summary> of its own; in that - /// case, returns a synthetic xmldoc carrying the containing record's - /// <param> body as the property summary. - /// - public static string? Resolve(string? resolvedXml, ISymbol symbol) - { - if (symbol is not IPropertySymbol property) - { - return resolvedXml; - } - - if (HasSummary(resolvedXml)) - { - return resolvedXml; - } - - if (!IsPositionalRecordProperty(property)) - { - return resolvedXml; - } - - var typeXml = property.ContainingType.GetDocumentationCommentXml(); - if (string.IsNullOrWhiteSpace(typeXml)) - { - return resolvedXml; - } - - XDocument typeDoc; - try - { - typeDoc = XDocument.Parse(typeXml, LoadOptions.PreserveWhitespace); - } - catch - { - return resolvedXml; - } - - var paramElement = typeDoc.Root? - .Elements("param") - .FirstOrDefault(p => string.Equals(p.Attribute("name")?.Value, property.Name, StringComparison.Ordinal)); - - if (paramElement is null || !paramElement.Nodes().Any()) - { - return resolvedXml; - } - - var summary = new XElement("summary", paramElement.Nodes().Select(CloneNode)); - var member = new XElement("member", summary); - return member.ToString(); - } - - private static bool HasSummary(string? xml) - { - if (string.IsNullOrWhiteSpace(xml)) - { - return false; - } - - XDocument doc; - try - { - doc = XDocument.Parse(xml, LoadOptions.PreserveWhitespace); - } - catch - { - return false; - } - - var summary = doc.Root?.Element("summary"); - return summary is not null && summary.Nodes().Any(); - } - - private static bool IsPositionalRecordProperty(IPropertySymbol property) - { - if (property.ContainingType is not { IsRecord: true }) - { - return false; - } - - foreach (var reference in property.DeclaringSyntaxReferences) - { - if (reference.GetSyntax() is ParameterSyntax) - { - return true; - } - } - - return false; - } - - private static XNode CloneNode(XNode node) - => node switch - { - XElement e => new XElement(e), - XText t => new XText(t), - _ => new XText(node.ToString()), - }; -} \ No newline at end of file diff --git a/src/Pennington.Roslyn/Highlighting/RoslynHighlighter.cs b/src/Pennington.Roslyn/Highlighting/RoslynHighlighter.cs deleted file mode 100644 index 83d4629a..00000000 --- a/src/Pennington.Roslyn/Highlighting/RoslynHighlighter.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace Pennington.Roslyn.Highlighting; - -using Pennington.Highlighting; - -/// -/// Roslyn-based code highlighter for C# and VB. Priority 100 (beats TextMate at 50). -/// Uses AdhocWorkspace + Classifier API -- no solution workspace needed. -/// -public sealed class RoslynHighlighter : ICodeHighlighter -{ - private readonly SyntaxHighlighter _highlighter; - - /// Creates a new highlighter that delegates to the supplied . - public RoslynHighlighter(SyntaxHighlighter highlighter) - { - _highlighter = highlighter; - } - - /// - public IReadOnlySet SupportedLanguages { get; } = - new HashSet { "csharp", "cs", "c#", "vb", "vbnet" }; - - /// - public int Priority => 100; - - /// - public string Highlight(string code, string language) - { - var lang = language.ToLowerInvariant() switch - { - "vb" or "vbnet" => SyntaxHighlighter.Language.VisualBasic, - _ => SyntaxHighlighter.Language.CSharp - }; - - return _highlighter.Highlight(code, lang); - } -} \ No newline at end of file diff --git a/src/Pennington.Roslyn/Highlighting/SyntaxHighlighter.cs b/src/Pennington.Roslyn/Highlighting/SyntaxHighlighter.cs deleted file mode 100644 index 236d4001..00000000 --- a/src/Pennington.Roslyn/Highlighting/SyntaxHighlighter.cs +++ /dev/null @@ -1,259 +0,0 @@ -namespace Pennington.Roslyn.Highlighting; - -using System.Net; -using System.Text; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Classification; -using Microsoft.CodeAnalysis.Text; -using Pennington.Infrastructure; - -/// -/// Roslyn Classifier API wrapper that produces HTML with hljs-* and roslyn-* CSS classes. -/// -public sealed class SyntaxHighlighter : IDisposable -{ - private readonly AdhocWorkspace _adHocWorkspace; - private readonly Project _csharpProject; - private readonly Project _vbProject; - private bool _disposed; - - /// Source language accepted by the highlighter. - public enum Language - { - /// C# source code. - CSharp, - /// Visual Basic source code. - VisualBasic, - } - - /// Creates a new highlighter backed by a fresh with C# and VB projects. - public SyntaxHighlighter() - { - _adHocWorkspace = new AdhocWorkspace(); - _csharpProject = _adHocWorkspace.CurrentSolution.AddProject("csProjectName", "assemblyName", LanguageNames.CSharp); - _vbProject = _adHocWorkspace.CurrentSolution.AddProject("vbProjectName", "assemblyName", LanguageNames.VisualBasic); - } - - /// Classifies the supplied code using Roslyn and returns an HTML <pre><code> block with hljs- and roslyn- CSS classes applied. - public string Highlight(string codeContent, Language language = Language.CSharp) - { - var project = language switch - { - Language.CSharp => _csharpProject, - Language.VisualBasic => _vbProject, - _ => throw new NotSupportedException($"Language {language} is not supported.") - }; - - var lang = language switch - { - Language.CSharp => "csharp", - Language.VisualBasic => "vb", - _ => "text" - }; - - var highlightedCode = AsyncHelpers.RunSync(() => HighlightContent(codeContent, project)); - return $"""
    {highlightedCode}
    """; - } - - private static async Task HighlightContent(string codeContent, Project project) - { - var filename = $"name.{codeContent.GetHashCode()}.{Environment.CurrentManagedThreadId}.cs"; - var document = project.AddDocument(filename, codeContent); - var text = await document.GetTextAsync(); - var textBounds = TextSpan.FromBounds(0, text.Length); - return await HighlightTextSpan(document, textBounds, text); - } - - private static async Task HighlightTextSpan(Document document, TextSpan textSpan, SourceText fullText) - { - var targetText = fullText.GetSubText(textSpan); - var classifiedSpans = await Classifier.GetClassifiedSpansAsync(document, textSpan); - var adjustedSpans = AdjustClassifiedSpans(textSpan, classifiedSpans); - var ranges = CreateRangesFromSpans(targetText, adjustedSpans); - ranges = FillGaps(targetText, ranges); - return BuildHighlightedOutput(ranges); - } - - private static IEnumerable AdjustClassifiedSpans(TextSpan textSpan, - IEnumerable classifiedSpans) - { - return classifiedSpans.Select(span => - { - var adjustedStart = span.TextSpan.Start - textSpan.Start; - var length = span.TextSpan.Length; - var adjustedSpan = new TextSpan(adjustedStart, length); - return new ClassifiedSpan(span.ClassificationType, adjustedSpan); - }); - } - - private static IEnumerable CreateRangesFromSpans(SourceText targetText, - IEnumerable adjustedSpans) - { - return adjustedSpans.Select(span => - { - var rangeText = targetText.GetSubText(span.TextSpan).ToString(); - return new Range(span, rangeText); - }); - } - - private static string BuildHighlightedOutput(IEnumerable ranges) - { - var sb = new StringBuilder(); - - foreach (var range in ranges) - { - var cssClass = ClassificationTypeToHighlightJsClass(range.ClassificationType); - if (string.IsNullOrWhiteSpace(cssClass)) - { - sb.Append(WebUtility.HtmlEncode(range.Text)); - } - else - { - sb.Append($"""{WebUtility.HtmlEncode(range.Text)}"""); - } - } - - return sb.ToString(); - } - - private static IEnumerable FillGaps(SourceText text, IEnumerable ranges) - { - const string whitespaceClassification = ""; - var current = 0; - Range? previous = null; - - foreach (var range in ranges) - { - var start = range.TextSpan.Start; - if (start > current) - { - yield return new Range(whitespaceClassification, TextSpan.FromBounds(current, start), text); - } - - if (previous == null || range.TextSpan != previous.TextSpan) - { - yield return range; - } - - previous = range; - current = range.TextSpan.End; - } - - if (current < text.Length) - { - yield return new Range(whitespaceClassification, TextSpan.FromBounds(current, text.Length), text); - } - } - - private sealed class Range - { - private readonly ClassifiedSpan _classifiedSpan; - public string Text { get; } - - public Range(ClassifiedSpan classifiedSpan, string text) - { - _classifiedSpan = classifiedSpan; - Text = text; - } - - public Range(string classification, TextSpan span, SourceText text) - : this(classification, span, text.GetSubText(span).ToString()) - { - } - - private Range(string classification, TextSpan span, string text) - : this(new ClassifiedSpan(classification, span), text) - { - } - - public string ClassificationType => _classifiedSpan.ClassificationType; - - public TextSpan TextSpan => _classifiedSpan.TextSpan; - } - - private static string ClassificationTypeToHighlightJsClass(string classificationType) - { - return classificationType switch - { - // Variables and identifiers - ClassificationTypeNames.Identifier => "function", - ClassificationTypeNames.LocalName or ClassificationTypeNames.ParameterName => "variable", - - // Properties and constants - ClassificationTypeNames.PropertyName or ClassificationTypeNames.EnumMemberName - or ClassificationTypeNames.FieldName => "attr", - - // Types and classes - ClassificationTypeNames.ClassName or ClassificationTypeNames.StructName - or ClassificationTypeNames.RecordClassName or ClassificationTypeNames.RecordStructName - or ClassificationTypeNames.InterfaceName or ClassificationTypeNames.DelegateName - or ClassificationTypeNames.EnumName or ClassificationTypeNames.ModuleName => "type", - - // Type parameters - ClassificationTypeNames.TypeParameterName => "type", - - // Methods and functions - ClassificationTypeNames.MethodName or ClassificationTypeNames.ExtensionMethodName => "function", - - // Comments - ClassificationTypeNames.Comment => "comment", - - // Keywords - ClassificationTypeNames.Keyword or ClassificationTypeNames.ControlKeyword - or ClassificationTypeNames.PreprocessorKeyword => "keyword", - - // Strings - ClassificationTypeNames.StringLiteral or ClassificationTypeNames.VerbatimStringLiteral => "string", - - // Numbers - ClassificationTypeNames.NumericLiteral => "number", - - // Operators - ClassificationTypeNames.Operator or ClassificationTypeNames.StringEscapeCharacter => "operator", - - // Punctuation - ClassificationTypeNames.Punctuation => "punctuation", - ClassificationTypeNames.StaticSymbol => string.Empty, - - // XML Documentation comments - ClassificationTypeNames.XmlDocCommentComment or ClassificationTypeNames.XmlDocCommentDelimiter - or ClassificationTypeNames.XmlDocCommentName or ClassificationTypeNames.XmlDocCommentText - or ClassificationTypeNames.XmlDocCommentAttributeName - or ClassificationTypeNames.XmlDocCommentAttributeQuotes - or ClassificationTypeNames.XmlDocCommentAttributeValue - or ClassificationTypeNames.XmlDocCommentEntityReference - or ClassificationTypeNames.XmlDocCommentProcessingInstruction - or ClassificationTypeNames.XmlDocCommentCDataSection => "comment", - - _ => classificationType.ToLower().Replace(" ", "-") - }; - } - - /// Disposes the underlying . - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - private void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - if (disposing) - { - _adHocWorkspace.Dispose(); - } - - _disposed = true; - } - - /// Finalizer that releases the underlying workspace if was not called. - ~SyntaxHighlighter() - { - Dispose(false); - } -} \ No newline at end of file diff --git a/src/Pennington.Roslyn/Pennington.Roslyn.csproj b/src/Pennington.Roslyn/Pennington.Roslyn.csproj deleted file mode 100644 index a9c318a4..00000000 --- a/src/Pennington.Roslyn/Pennington.Roslyn.csproj +++ /dev/null @@ -1,32 +0,0 @@ - - - net10.0;net11.0 - preview - enable - enable - Roslyn-based code analysis and highlighting for Pennington - dotnet roslyn syntax-highlighting code-analysis - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Pennington.Roslyn/Preprocessing/RoslynCodeBlockPreprocessor.cs b/src/Pennington.Roslyn/Preprocessing/RoslynCodeBlockPreprocessor.cs deleted file mode 100644 index 0c4664ca..00000000 --- a/src/Pennington.Roslyn/Preprocessing/RoslynCodeBlockPreprocessor.cs +++ /dev/null @@ -1,442 +0,0 @@ -namespace Pennington.Roslyn.Preprocessing; - -using System.Net; -using Diagnostics; -using Highlighting; -using Markdown.Extensions; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Pennington.Highlighting; -using Pennington.Infrastructure; -using Symbols; - -/// -/// Preprocesses code blocks with :xmldocid, :path, and :xmldocid-diff modifiers. -/// -public sealed class RoslynCodeBlockPreprocessor : ICodeBlockPreprocessor -{ - private readonly ISymbolExtractionService _symbolService; - private readonly SyntaxHighlighter _highlighter; - private readonly HighlightingService _highlightingService; - private readonly RoslynOptions _options; - private readonly IHttpContextAccessor _httpContextAccessor; - - /// Creates a new preprocessor wired to the symbol extraction service, Roslyn highlighter, shared highlighting service, options, and per-request diagnostics accessor. - public RoslynCodeBlockPreprocessor( - ISymbolExtractionService symbolService, - SyntaxHighlighter highlighter, - HighlightingService highlightingService, - RoslynOptions options, - IHttpContextAccessor httpContextAccessor) - { - _symbolService = symbolService; - _highlighter = highlighter; - _highlightingService = highlightingService; - _options = options; - _httpContextAccessor = httpContextAccessor; - } - - private DiagnosticContext? Diagnostics - => _httpContextAccessor.HttpContext?.RequestServices.GetService(); - - /// - public int Priority => 100; - - /// - public CodeBlockPreprocessResult? TryProcess(string code, string languageId) - { - var (baseLanguage, modifier) = ParseLanguageId(languageId); - if (modifier is null) - { - return null; - } - - if (modifier.StartsWith("xmldocid", StringComparison.Ordinal) && !IsRoslynLanguage(baseLanguage)) - { - Diagnostics?.AddError( - $":xmldocid fence requires a C# or VB language tag, got '{baseLanguage}'. " + - "To embed non-C# content (markdown, razor, html, css, etc.), use ':path' with a file reference."); - return null; - } - - return modifier switch - { - "xmldocid" => ProcessXmlDocId(baseLanguage, code, bodyOnly: false, includeUsings: false), - "xmldocid,bodyonly" => ProcessXmlDocId(baseLanguage, code, bodyOnly: true, includeUsings: false), - "xmldocid,usings" => ProcessXmlDocId(baseLanguage, code, bodyOnly: false, includeUsings: true), - "xmldocid,bodyonly,usings" => ProcessXmlDocId(baseLanguage, code, bodyOnly: true, includeUsings: true), - "xmldocid-diff" => ProcessXmlDocIdDiff(baseLanguage, code), - "xmldocid-diff,bodyonly" => ProcessXmlDocIdDiff(baseLanguage, code, bodyOnly: true), - "path" => ProcessPath(baseLanguage, code), - _ => null - }; - } - - private static bool IsRoslynLanguage(string baseLanguage) => - baseLanguage.ToLowerInvariant() is "csharp" or "cs" or "vb" or "vbnet"; - - internal static (string baseLanguage, string? modifier) ParseLanguageId(string languageId) - { - var trimmed = languageId.Trim(); - const string xmlDocIdDiffMarker = ":xmldocid-diff"; - const string xmlDocIdMarker = ":xmldocid"; - const string pathMarker = ":path"; - - // Check for :xmldocid-diff first (before :xmldocid) to avoid false prefix match - if (trimmed.Contains(xmlDocIdDiffMarker, StringComparison.OrdinalIgnoreCase)) - { - var baseIndex = trimmed.IndexOf(xmlDocIdDiffMarker, StringComparison.OrdinalIgnoreCase); - var baseLanguage = trimmed[..baseIndex]; - var modifierPart = trimmed.Contains(",bodyonly", StringComparison.OrdinalIgnoreCase) - ? "xmldocid-diff,bodyonly" - : "xmldocid-diff"; - return (baseLanguage, modifierPart); - } - - if (trimmed.Contains(xmlDocIdMarker, StringComparison.OrdinalIgnoreCase)) - { - var baseIndex = trimmed.IndexOf(xmlDocIdMarker, StringComparison.OrdinalIgnoreCase); - var baseLanguage = trimmed[..baseIndex]; - var afterMarker = trimmed[(baseIndex + xmlDocIdMarker.Length)..]; - var modifierPart = NormalizeXmlDocIdModifier(afterMarker); - return (baseLanguage, modifierPart); - } - - if (trimmed.Contains(pathMarker, StringComparison.OrdinalIgnoreCase)) - { - var baseIndex = trimmed.IndexOf(pathMarker, StringComparison.OrdinalIgnoreCase); - return (trimmed[..baseIndex], "path"); - } - - return (trimmed, null); - } - - /// - /// Recognises optional flags bodyonly and usings after :xmldocid in any order - /// (e.g. :xmldocid,usings,bodyonly) and returns a normalised modifier string. Bodyonly is - /// emitted before usings so the dispatch switch stays exhaustive without listing every - /// permutation. - /// - private static string NormalizeXmlDocIdModifier(string afterMarker) - { - var bodyOnly = false; - var usings = false; - - foreach (var rawFlag in afterMarker.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - if (rawFlag.Equals("bodyonly", StringComparison.OrdinalIgnoreCase)) - { - bodyOnly = true; - } - else if (rawFlag.Equals("usings", StringComparison.OrdinalIgnoreCase)) - { - usings = true; - } - } - - return (bodyOnly, usings) switch - { - (false, false) => "xmldocid", - (true, false) => "xmldocid,bodyonly", - (false, true) => "xmldocid,usings", - (true, true) => "xmldocid,bodyonly,usings", - }; - } - - private CodeBlockPreprocessResult? ProcessXmlDocId(string baseLanguage, string code, bool bodyOnly, bool includeUsings) - { - try - { - var xmlDocIds = code.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - var htmlFragments = new List(); - var aggregatedUsings = new List(); - var seenUsings = new HashSet(StringComparer.Ordinal); - - foreach (var xmlDocId in xmlDocIds) - { - string fragment; - if (includeUsings) - { - var result = AsyncHelpers.RunSync(() => _symbolService.ExtractCodeFragmentWithUsingsAsync(xmlDocId, bodyOnly)); - fragment = result.Fragment; - foreach (var directive in result.Usings) - { - if (seenUsings.Add(directive)) - { - aggregatedUsings.Add(directive); - } - } - } - else - { - fragment = AsyncHelpers.RunSync(() => _symbolService.ExtractCodeFragmentAsync(xmlDocId, bodyOnly)); - } - - if (string.IsNullOrEmpty(fragment)) - { - Diagnostics?.AddWarning($"Unresolved xmldocid: {xmlDocId}"); - var errorComment = $"""// Error: Symbol not found for '{WebUtility.HtmlEncode(xmlDocId)}'"""; - htmlFragments.Add(errorComment); - continue; - } - - var lang = DetectHighlighterLanguage(baseLanguage); - var highlighted = _highlighter.Highlight(fragment, lang); - var innerHtml = ExtractInnerCodeHtml(highlighted); - htmlFragments.Add(innerHtml); - } - - var bodyHtml = string.Join("\n\n", htmlFragments); - - string combinedHtml; - if (includeUsings && aggregatedUsings.Count > 0) - { - var usingsBlock = string.Join("\n", aggregatedUsings); - var lang = DetectHighlighterLanguage(baseLanguage); - var highlightedUsings = _highlighter.Highlight(usingsBlock, lang); - var usingsHtml = ExtractInnerCodeHtml(highlightedUsings); - combinedHtml = usingsHtml + "\n\n" + bodyHtml; - } - else - { - combinedHtml = bodyHtml; - } - - var wrappedHtml = $"""
    {combinedHtml}
    """; - - return new CodeBlockPreprocessResult(wrappedHtml, baseLanguage, SkipTransform: false); - } - catch (Exception ex) - { - Diagnostics?.AddError($"Error processing xmldocid: {ex.Message}"); - var errorHtml = $"
    {WebUtility.HtmlEncode(code)}
    "; - return new CodeBlockPreprocessResult(errorHtml, baseLanguage, SkipTransform: true); - } - } - - private CodeBlockPreprocessResult? ProcessXmlDocIdDiff(string baseLanguage, string code, bool bodyOnly = false) - { - try - { - var xmlDocIds = code.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - if (xmlDocIds.Length != 2) - { - Diagnostics?.AddError($"xmldocid-diff requires exactly 2 XmlDocIds, got {xmlDocIds.Length}"); - var errorHtml = $"
    {WebUtility.HtmlEncode(code)}
    "; - return new CodeBlockPreprocessResult(errorHtml, baseLanguage, SkipTransform: true); - } - - var fragment1 = AsyncHelpers.RunSync(() => _symbolService.ExtractCodeFragmentAsync(xmlDocIds[0], bodyOnly)); - var fragment2 = AsyncHelpers.RunSync(() => _symbolService.ExtractCodeFragmentAsync(xmlDocIds[1], bodyOnly)); - - // Handle errors - var errors = new List(); - if (string.IsNullOrEmpty(fragment1)) - { - errors.Add($"Symbol not found: {xmlDocIds[0]}"); - } - - if (string.IsNullOrEmpty(fragment2)) - { - errors.Add($"Symbol not found: {xmlDocIds[1]}"); - } - - if (errors.Count > 0) - { - foreach (var error in errors) - { - Diagnostics?.AddWarning($"Unresolved xmldocid-diff: {error}"); - } - - var errorHtml = string.Join("\n", errors.Select(e => - $"""// {WebUtility.HtmlEncode(e)}""")); - return new CodeBlockPreprocessResult( - $"
    {errorHtml}
    ", - baseLanguage, - SkipTransform: true); - } - - // Highlight both fragments - var lang = DetectHighlighterLanguage(baseLanguage); - var highlighted1 = _highlighter.Highlight(fragment1, lang); - var highlighted2 = _highlighter.Highlight(fragment2, lang); - - var html1 = ExtractInnerCodeHtml(highlighted1); - var html2 = ExtractInnerCodeHtml(highlighted2); - - var diffResult = ComputeAndRenderDiff(html1, html2, fragment1, fragment2); - - var preClass = diffResult.HasDifferences ? " class=\"has-diff\"" : ""; - var wrappedHtml = $"{diffResult.Html}"; - - return new CodeBlockPreprocessResult(wrappedHtml, baseLanguage, SkipTransform: true); - } - catch (Exception ex) - { - Diagnostics?.AddError($"Error processing xmldocid-diff: {ex.Message}"); - var errorHtml = $"
    {WebUtility.HtmlEncode(code)}
    "; - return new CodeBlockPreprocessResult(errorHtml, baseLanguage, SkipTransform: true); - } - } - - private CodeBlockPreprocessResult? ProcessPath(string baseLanguage, string code) - { - try - { - var relativePath = code.Trim(); - - // Validate path to prevent directory traversal - if (relativePath.Contains("..") || Path.IsPathRooted(relativePath)) - { - Diagnostics?.AddError($"Invalid file path: {relativePath}"); - var errorHtml = $"
    {WebUtility.HtmlEncode(code)}
    "; - return new CodeBlockPreprocessResult(errorHtml, baseLanguage, SkipTransform: true); - } - - if (string.IsNullOrEmpty(_options.SolutionPath)) - { - Diagnostics?.AddError("Solution path not configured for :path code block"); - var errorHtml = $"
    {WebUtility.HtmlEncode(code)}
    "; - return new CodeBlockPreprocessResult(errorHtml, baseLanguage, SkipTransform: true); - } - - // Path.GetDirectoryName returns empty for bare filenames ("foo.slnx") with - // no directory component. Normalize via GetFullPath so a bare SolutionPath — - // which the runtime resolves against the process CWD — produces the same - // directory we'd use at runtime. - var solutionDir = Path.GetDirectoryName(Path.GetFullPath(_options.SolutionPath)); - if (string.IsNullOrEmpty(solutionDir)) - { - Diagnostics?.AddError("Solution directory not found for :path code block"); - var errorHtml = $"
    {WebUtility.HtmlEncode(code)}
    "; - return new CodeBlockPreprocessResult(errorHtml, baseLanguage, SkipTransform: true); - } - - var fullPath = Path.Combine(solutionDir, relativePath); - if (!File.Exists(fullPath)) - { - Diagnostics?.AddWarning($"File not found for :path code block: {relativePath}"); - var errorHtml = $"
    {WebUtility.HtmlEncode(code)}
    "; - return new CodeBlockPreprocessResult(errorHtml, baseLanguage, SkipTransform: true); - } - - var content = File.ReadAllText(fullPath); - var highlighted = _highlightingService.Highlight(content, baseLanguage); - - return new CodeBlockPreprocessResult(highlighted, baseLanguage, SkipTransform: false); - } - catch (Exception ex) - { - Diagnostics?.AddError($"Error loading file for :path code block: {ex.Message}"); - var errorHtml = $"
    {WebUtility.HtmlEncode(code)}
    "; - return new CodeBlockPreprocessResult(errorHtml, baseLanguage, SkipTransform: true); - } - } - - private static DiffRenderResult ComputeAndRenderDiff( - string highlightedHtml1, - string highlightedHtml2, - string plainText1, - string plainText2) - { - var htmlLines1 = SplitNewLines(highlightedHtml1); - var htmlLines2 = SplitNewLines(highlightedHtml2); - - var differ = new DiffPlex.Differ(); - var diffResult = differ.CreateLineDiffs(plainText1, plainText2, ignoreWhitespace: true); - - var outputLines = new List(); - var processedLinesA = 0; - var hasDifferences = diffResult.DiffBlocks.Count > 0; - - foreach (var diffBlock in diffResult.DiffBlocks) - { - // Add unchanged lines before this diff block - while (processedLinesA < diffBlock.DeleteStartA) - { - if (processedLinesA < htmlLines1.Length) - { - outputLines.Add($"""{htmlLines1[processedLinesA]}"""); - } - - processedLinesA++; - } - - // Add deleted lines (from first snippet) - for (var i = 0; i < diffBlock.DeleteCountA; i++) - { - var lineIndex = diffBlock.DeleteStartA + i; - if (lineIndex < htmlLines1.Length) - { - outputLines.Add($"""{htmlLines1[lineIndex]}"""); - } - } - - // Add inserted lines (from second snippet) - for (var i = 0; i < diffBlock.InsertCountB; i++) - { - var lineIndex = diffBlock.InsertStartB + i; - if (lineIndex < htmlLines2.Length) - { - outputLines.Add($"""{htmlLines2[lineIndex]}"""); - } - } - - processedLinesA += diffBlock.DeleteCountA; - } - - // Add any remaining unchanged lines from the end - while (processedLinesA < htmlLines1.Length) - { - outputLines.Add($"""{htmlLines1[processedLinesA]}"""); - processedLinesA++; - } - - return new DiffRenderResult(string.Join("\n", outputLines), hasDifferences); - } - - private static SyntaxHighlighter.Language DetectHighlighterLanguage(string baseLanguage) => - baseLanguage.ToLowerInvariant() switch - { - "vb" or "vbnet" => SyntaxHighlighter.Language.VisualBasic, - _ => SyntaxHighlighter.Language.CSharp, - }; - - /// - /// Extracts the inner HTML content from between pre/code tags. - /// - private static string ExtractInnerCodeHtml(string html) - { - // The SyntaxHighlighter produces:
    ...
    - // We need to find the content between the opening code tag and the closing tags. - const string closeTag = "
    "; - - var codeTagStart = html.IndexOf("', codeTagStart) + 1; - if (contentStart == 0) - { - return html; - } - - var endIndex = html.IndexOf(closeTag, contentStart, StringComparison.Ordinal); - if (endIndex == -1) - { - return html; - } - - return html[contentStart..endIndex]; - } - - private static string[] SplitNewLines(string s) - { - return s.ReplaceLineEndings(Environment.NewLine).Split(Environment.NewLine); - } - - private sealed record DiffRenderResult(string Html, bool HasDifferences); -} \ No newline at end of file diff --git a/src/Pennington.Roslyn/RoslynExtensions.cs b/src/Pennington.Roslyn/RoslynExtensions.cs deleted file mode 100644 index 8379e971..00000000 --- a/src/Pennington.Roslyn/RoslynExtensions.cs +++ /dev/null @@ -1,53 +0,0 @@ -namespace Pennington.Roslyn; - -using Highlighting; -using Markdown.Extensions; -using Microsoft.Extensions.DependencyInjection; -using Pennington.ApiMetadata; -using Pennington.Highlighting; -using Preprocessing; -using Symbols; -using Workspace; - -/// Dependency injection extensions for registering the Pennington Roslyn integration. -public static class RoslynExtensions -{ - /// Add Roslyn-based code analysis and highlighting. - public static IServiceCollection AddPenningtonRoslyn(this IServiceCollection services, Action? configure = null) - { - var options = new RoslynOptions(); - configure?.Invoke(options); - services.AddSingleton(options); - - // Always register basic highlighter (works without solution) - services.AddSingleton(); - services.AddSingleton(); - - // If solution path configured, register workspace + symbols + preprocessor - if (!string.IsNullOrEmpty(options.SolutionPath)) - { - services.AddSingleton(); - services.AddSingleton(sp => - { - var symbolService = ActivatorUtilities.CreateInstance(sp); - if (sp.GetRequiredService() is SolutionWorkspaceService sws) - { - sws.SymbolExtractionService = symbolService; - } - - return symbolService; - }); - if (options.EnableCodeFragmentFences) - { - services.AddSingleton(); - } - - services.AddSingleton(); - services.AddSingleton(); - - services.AddHostedService(); - } - - return services; - } -} \ No newline at end of file diff --git a/src/Pennington.Roslyn/RoslynOptions.cs b/src/Pennington.Roslyn/RoslynOptions.cs deleted file mode 100644 index f7aa2153..00000000 --- a/src/Pennington.Roslyn/RoslynOptions.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Pennington.Roslyn; - -/// Configuration for Roslyn integration. -public sealed class RoslynOptions -{ - /// Path to a .sln, .slnx, or .slnf file. If null, only basic highlighting is enabled. - public string? SolutionPath { get; set; } - - /// Optional project filter. - public ProjectFilter? ProjectFilter { get; set; } - - /// When true (the default), registers the :xmldocid/:path code-block preprocessor. Set false to keep the workspace and API-metadata services for reflection while delegating code-fragment fences to another preprocessor (for example tree-sitter :symbol). - public bool EnableCodeFragmentFences { get; set; } = true; -} - -/// Filter for which projects to analyze. -public record ProjectFilter -{ - /// Project names to include; when non-null, only these projects are analyzed. - public HashSet? IncludedProjects { get; init; } - /// Project names to exclude from analysis. - public HashSet? ExcludedProjects { get; init; } -} \ No newline at end of file diff --git a/src/Pennington.Roslyn/Symbols/CodeFragmentExtractor.cs b/src/Pennington.Roslyn/Symbols/CodeFragmentExtractor.cs deleted file mode 100644 index 11b70c68..00000000 --- a/src/Pennington.Roslyn/Symbols/CodeFragmentExtractor.cs +++ /dev/null @@ -1,175 +0,0 @@ -namespace Pennington.Roslyn.Symbols; - -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -/// -/// Extracts code text from a syntax node. -/// When bodyOnly=false, returns the full declaration text. -/// When bodyOnly=true, returns expression body or block body content for methods, -/// or content between braces for types. -/// -internal static class CodeFragmentExtractor -{ - /// - /// Extracts the declaration signature (no body, no accessor list) for a method, constructor, operator, - /// conversion operator, or property-style event. Falls back to the full node text for any other kind. - /// - public static Task ExtractSignatureAsync(SyntaxNode node, string fullText) - { - var end = node switch - { - MethodDeclarationSyntax m => m.ConstraintClauses.Count > 0 - ? m.ConstraintClauses[^1].Span.End - : m.ParameterList?.Span.End ?? m.Identifier.Span.End, - ConstructorDeclarationSyntax c => c.Initializer?.Span.End ?? c.ParameterList.Span.End, - OperatorDeclarationSyntax o => o.ParameterList.Span.End, - ConversionOperatorDeclarationSyntax cv => cv.ParameterList.Span.End, - EventDeclarationSyntax ev => ev.Identifier.Span.End, - _ => -1, - }; - - if (end < 0) - { - return Task.FromResult(ToStringWithLineIndent(node, fullText)); - } - - var spanStart = node.SpanStart; - var lineStart = spanStart; - while (lineStart > 0 && fullText[lineStart - 1] is ' ' or '\t') - { - lineStart--; - } - - return Task.FromResult(fullText[lineStart..end] + ";"); - } - - /// - /// Extracts a code fragment from the given syntax node. - /// When is false, leading comments/xmldoc - /// attached to the node as leading trivia are stripped — useful when the caller - /// renders parsed xmldoc separately and does not want a duplicate comment block. - /// - public static Task ExtractCodeFragmentAsync(SyntaxNode node, string fullText, bool bodyOnly, bool includeLeadingTrivia = true) - { - if (!bodyOnly) - { - return Task.FromResult(includeLeadingTrivia ? node.ToFullString() : ToStringWithLineIndent(node, fullText)); - } - - var body = ExtractBody(node); - return Task.FromResult(body ?? (includeLeadingTrivia ? node.ToFullString() : ToStringWithLineIndent(node, fullText))); - } - - // When leading trivia is stripped, node.ToString() drops the first line's - // leading whitespace too, leaving the first line at column 0 while subsequent - // lines keep their source indent. Walk back over ' '/'\t' (stopping at a newline - // or any non-whitespace so same-line xmldoc/comments stay excluded) and prepend - // that whitespace so every line shares a baseline for downstream dedent. - private static string ToStringWithLineIndent(SyntaxNode node, string fullText) - { - var spanStart = node.SpanStart; - var lineStart = spanStart; - while (lineStart > 0 && fullText[lineStart - 1] is ' ' or '\t') - { - lineStart--; - } - - return fullText[lineStart..spanStart] + node.ToString(); - } - - private static string? ExtractBody(SyntaxNode node) - { - return node switch - { - MethodDeclarationSyntax method => ExtractMethodBody(method), - ConstructorDeclarationSyntax constructor => ExtractBlockBody(constructor.Body), - DestructorDeclarationSyntax destructor => ExtractBlockBody(destructor.Body), - AccessorDeclarationSyntax accessor => ExtractBlockBody(accessor.Body), - OperatorDeclarationSyntax op => ExtractMethodLikeBody(op.Body, op.ExpressionBody), - ConversionOperatorDeclarationSyntax conv => ExtractMethodLikeBody(conv.Body, conv.ExpressionBody), - PropertyDeclarationSyntax property => ExtractPropertyBody(property), - TypeDeclarationSyntax typeDecl => ExtractBraceContent(typeDecl.OpenBraceToken, typeDecl.CloseBraceToken), - EnumDeclarationSyntax enumDecl => ExtractBraceContent(enumDecl.OpenBraceToken, enumDecl.CloseBraceToken), - NamespaceDeclarationSyntax nsDecl => ExtractBraceContent(nsDecl.OpenBraceToken, nsDecl.CloseBraceToken), - _ => null, - }; - } - - private static string? ExtractMethodBody(MethodDeclarationSyntax method) - { - if (method.ExpressionBody is { } expressionBody) - { - return expressionBody.Expression.ToFullString().TrimEnd(); - } - - return ExtractBlockBody(method.Body); - } - - private static string? ExtractMethodLikeBody(BlockSyntax? body, ArrowExpressionClauseSyntax? expressionBody) - { - if (expressionBody is not null) - { - return expressionBody.Expression.ToFullString().TrimEnd(); - } - - return ExtractBlockBody(body); - } - - private static string? ExtractBlockBody(BlockSyntax? block) - { - if (block is null) - { - return null; - } - - // Return everything between the braces, trimming outer whitespace - var openBrace = block.OpenBraceToken; - var closeBrace = block.CloseBraceToken; - - var start = openBrace.Span.End; - var end = closeBrace.SpanStart; - - if (end <= start) - { - return string.Empty; - } - - var fullText = block.SyntaxTree.GetText().ToString(); - return fullText[start..end].TrimEnd(); - } - - private static string? ExtractPropertyBody(PropertyDeclarationSyntax property) - { - if (property.ExpressionBody is { } expressionBody) - { - return expressionBody.Expression.ToFullString().TrimEnd(); - } - - if (property.AccessorList is not null) - { - return property.AccessorList.ToString(); - } - - return null; - } - - private static string? ExtractBraceContent(SyntaxToken openBrace, SyntaxToken closeBrace) - { - if (openBrace.IsMissing || closeBrace.IsMissing) - { - return null; - } - - var start = openBrace.Span.End; - var end = closeBrace.SpanStart; - - if (end <= start) - { - return string.Empty; - } - - var fullText = openBrace.SyntaxTree!.GetText().ToString(); - return fullText[start..end].TrimEnd(); - } -} \ No newline at end of file diff --git a/src/Pennington.Roslyn/Symbols/CodeFragmentResult.cs b/src/Pennington.Roslyn/Symbols/CodeFragmentResult.cs deleted file mode 100644 index 1b443454..00000000 --- a/src/Pennington.Roslyn/Symbols/CodeFragmentResult.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Pennington.Roslyn.Symbols; - -using System.Collections.Immutable; - -/// The dedented source-text fragment for a symbol plus the file-local using directives the fragment depends on. -/// Dedented source text for the symbol's declaration or body. -/// Required file-local using directives in their original source order, each as the verbatim directive text (e.g. using System.Text;). -public sealed record CodeFragmentResult(string Fragment, ImmutableList Usings); \ No newline at end of file diff --git a/src/Pennington.Roslyn/Symbols/ISymbolExtractionService.cs b/src/Pennington.Roslyn/Symbols/ISymbolExtractionService.cs deleted file mode 100644 index ce5b113d..00000000 --- a/src/Pennington.Roslyn/Symbols/ISymbolExtractionService.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace Pennington.Roslyn.Symbols; - -using Microsoft.CodeAnalysis; - -/// Extracts symbol metadata and source fragments from the configured Roslyn , keyed by xmldocid. -public interface ISymbolExtractionService -{ - /// Walks the given projects and returns a map of xmldocid to for every documented symbol. Callers pass the workspace's filtered project set (one per multi-targeted csproj) so symbols aren't extracted once per target framework. - Task> ExtractSymbolsAsync(IEnumerable projects); - - /// Returns the for the given xmldocid, or if no symbol matches. - Task FindSymbolAsync(string xmlDocId); - - /// Returns the source-code fragment for the symbol identified by , optionally limited to the member body and optionally including leading trivia (comments/attributes). - Task ExtractCodeFragmentAsync(string xmlDocId, bool bodyOnly = false, bool includeLeadingTrivia = true); - - /// Returns the source-code fragment for the symbol identified by together with the file-local using directives the fragment depends on. is empty when the symbol cannot be resolved. - Task ExtractCodeFragmentWithUsingsAsync(string xmlDocId, bool bodyOnly = false, bool includeLeadingTrivia = true); - - /// Returns the declaration signature (no body, no accessor list) for the member identified by . Falls back to the full declaration for symbols that have no separable body. - Task ExtractDeclarationSignatureAsync(string xmlDocId); - - /// Clears any cached symbol lookups so the next query re-walks the solution. - void ClearCache(); - - /// Triggers the symbol table walk if it has not already run. Safe to call concurrently with real queries — both share one task. - Task WarmupAsync(CancellationToken cancellationToken = default); -} \ No newline at end of file diff --git a/src/Pennington.Roslyn/Symbols/RequiredUsingsAnalyzer.cs b/src/Pennington.Roslyn/Symbols/RequiredUsingsAnalyzer.cs deleted file mode 100644 index 1cf9e1d8..00000000 --- a/src/Pennington.Roslyn/Symbols/RequiredUsingsAnalyzer.cs +++ /dev/null @@ -1,265 +0,0 @@ -namespace Pennington.Roslyn.Symbols; - -using System.Collections.Immutable; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -/// -/// Computes the subset of file-local using directives that a code fragment actually depends on. -/// Goal: turn an extracted method body or type declaration into a copy-pasteable sample by -/// prepending only the imports the body's referenced symbols need — not the file's full using block. -/// -internal static class RequiredUsingsAnalyzer -{ - /// - /// Returns the subset of 's file-local using directives - /// (no global using directives, no implicit usings) that the symbols referenced inside - /// require, preserving the directives' original source order. - /// - public static ImmutableList Analyze( - SyntaxNode fragmentNode, - SemanticModel semanticModel, - CompilationUnitSyntax fileRoot) - { - if (fragmentNode.SyntaxTree != fileRoot.SyntaxTree) - { - return ImmutableList.Empty; - } - - var candidates = CollectFileLocalUsings(fileRoot); - if (candidates.Count == 0) - { - return ImmutableList.Empty; - } - - var ambientNamespaces = CollectAmbientNamespaces(fragmentNode); - var referencedNamespaces = new HashSet(StringComparer.Ordinal); - var referencedStaticTypes = new HashSet(StringComparer.Ordinal); - var referencedAliases = new HashSet(StringComparer.Ordinal); - - // Walk only SimpleNameSyntax nodes (IdentifierName + GenericName). These are the - // smallest source positions where the author wrote a name, so: - // - PredefinedTypeSyntax (`string`, `int`) is naturally skipped, avoiding spurious - // `using System;` from `string` keyword usage. - // - The SimpleName is the right node for the "is this reference qualified?" check - // used to gate `using static`. - // - GetAliasInfo on an alias-qualified identifier returns the IAliasSymbol, which - // GetSymbolInfo would otherwise resolve straight to the underlying type. - foreach (var name in fragmentNode.DescendantNodesAndSelf().OfType()) - { - var alias = semanticModel.GetAliasInfo(name); - if (alias is not null) - { - referencedAliases.Add(alias.Name); - continue; - } - - RecordSymbol(semanticModel.GetSymbolInfo(name).Symbol, name, ambientNamespaces, referencedNamespaces, referencedStaticTypes); - RecordType(semanticModel.GetTypeInfo(name).Type, ambientNamespaces, referencedNamespaces); - } - - var keep = ImmutableList.CreateBuilder(); - foreach (var directive in candidates) - { - if (IsRequired(directive, referencedNamespaces, referencedStaticTypes, referencedAliases)) - { - keep.Add(directive); - } - } - - return keep.ToImmutable(); - } - - private static List CollectFileLocalUsings(CompilationUnitSyntax fileRoot) - { - var list = new List(); - - foreach (var directive in fileRoot.Usings) - { - if (!directive.GlobalKeyword.IsKind(SyntaxKind.GlobalKeyword)) - { - list.Add(directive); - } - } - - // Usings can also live inside file-scoped or block-form namespaces. - foreach (var ns in fileRoot.DescendantNodes().OfType()) - { - foreach (var directive in ns.Usings) - { - if (!directive.GlobalKeyword.IsKind(SyntaxKind.GlobalKeyword)) - { - list.Add(directive); - } - } - } - - return list; - } - - private static HashSet CollectAmbientNamespaces(SyntaxNode fragmentNode) - { - // Symbols declared in the same namespace as the fragment don't need a using. - // Walk every BaseNamespaceDeclarationSyntax that contains the fragment and - // record its full name plus any ancestor namespaces (a nested `namespace A.B { namespace C { ... } }` - // makes both A.B and A.B.C ambient). - var ambient = new HashSet(StringComparer.Ordinal); - for (var current = fragmentNode.Parent; current is not null; current = current.Parent) - { - if (current is BaseNamespaceDeclarationSyntax ns) - { - ambient.Add(ns.Name.ToString()); - } - } - - return ambient; - } - - private static void RecordSymbol( - ISymbol? symbol, - SimpleNameSyntax referenceNode, - HashSet ambientNamespaces, - HashSet referencedNamespaces, - HashSet referencedStaticTypes) - { - if (symbol is null) - { - return; - } - - switch (symbol) - { - case INamedTypeSymbol type: - RecordType(type, ambientNamespaces, referencedNamespaces); - break; - case IMethodSymbol method: - if (method.IsExtensionMethod) - { - var origin = method.ReducedFrom ?? method; - AddNamespace(origin.ContainingNamespace, ambientNamespaces, referencedNamespaces); - } - - if (method.IsStatic && !method.IsExtensionMethod && IsUnqualifiedReference(referenceNode)) - { - AddStaticType(method.ContainingType, referencedStaticTypes); - } - - foreach (var typeArg in method.TypeArguments) - { - RecordType(typeArg, ambientNamespaces, referencedNamespaces); - } - - break; - case IPropertySymbol property when property.IsStatic && IsUnqualifiedReference(referenceNode): - AddStaticType(property.ContainingType, referencedStaticTypes); - break; - case IFieldSymbol field when field.IsStatic && IsUnqualifiedReference(referenceNode): - AddStaticType(field.ContainingType, referencedStaticTypes); - break; - case IEventSymbol ev when ev.IsStatic && IsUnqualifiedReference(referenceNode): - AddStaticType(ev.ContainingType, referencedStaticTypes); - break; - } - } - - private static void RecordType(ITypeSymbol? type, HashSet ambientNamespaces, HashSet referencedNamespaces) - { - if (type is null) - { - return; - } - - switch (type) - { - case INamedTypeSymbol named: - var outermost = OutermostContainingType(named); - AddNamespace(outermost.ContainingNamespace, ambientNamespaces, referencedNamespaces); - foreach (var typeArg in named.TypeArguments) - { - RecordType(typeArg, ambientNamespaces, referencedNamespaces); - } - - break; - case IArrayTypeSymbol array: - RecordType(array.ElementType, ambientNamespaces, referencedNamespaces); - break; - case IPointerTypeSymbol pointer: - RecordType(pointer.PointedAtType, ambientNamespaces, referencedNamespaces); - break; - } - } - - private static INamedTypeSymbol OutermostContainingType(INamedTypeSymbol type) - { - var current = type; - while (current.ContainingType is { } parent) - { - current = parent; - } - - return current; - } - - private static void AddNamespace(INamespaceSymbol? ns, HashSet ambientNamespaces, HashSet referencedNamespaces) - { - if (ns is null || ns.IsGlobalNamespace) - { - return; - } - - var name = ns.ToDisplayString(); - if (ambientNamespaces.Contains(name)) - { - return; - } - - referencedNamespaces.Add(name); - } - - private static void AddStaticType(INamedTypeSymbol? type, HashSet referencedStaticTypes) - { - if (type is null) - { - return; - } - - referencedStaticTypes.Add(type.ToDisplayString()); - } - - private static bool IsUnqualifiedReference(SimpleNameSyntax referenceNode) - { - // A reference is "unqualified" — and therefore needs `using static` to bind — when the - // simple name isn't the right-hand side of a `Type.Member` access. `Type.Method()` already - // qualifies the member, so it doesn't drive a static-using requirement. - return referenceNode.Parent is not MemberAccessExpressionSyntax memberAccess - || memberAccess.Name != referenceNode; - } - - private static bool IsRequired( - UsingDirectiveSyntax directive, - HashSet referencedNamespaces, - HashSet referencedStaticTypes, - HashSet referencedAliases) - { - var target = directive.Name?.ToString(); - if (string.IsNullOrEmpty(target)) - { - return false; - } - - if (directive.Alias is { Name: { } aliasName }) - { - return referencedAliases.Contains(aliasName.Identifier.ValueText); - } - - if (directive.StaticKeyword.IsKind(SyntaxKind.StaticKeyword)) - { - return referencedStaticTypes.Contains(target); - } - - // Plain `using X.Y;` — exact namespace match. C# does not flow `using X.Y;` to `X.Y.Sub`, - // so we don't expand prefixes either. - return referencedNamespaces.Contains(target); - } -} \ No newline at end of file diff --git a/src/Pennington.Roslyn/Symbols/SymbolExtractionService.cs b/src/Pennington.Roslyn/Symbols/SymbolExtractionService.cs deleted file mode 100644 index 6fce4bb3..00000000 --- a/src/Pennington.Roslyn/Symbols/SymbolExtractionService.cs +++ /dev/null @@ -1,426 +0,0 @@ -namespace Pennington.Roslyn.Symbols; - -using System.Collections.Concurrent; -using System.Collections.Immutable; -using Infrastructure; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Text; -using Microsoft.Extensions.Logging; -using Utilities; -using Workspace; - -/// -/// Extracts all symbols from a Roslyn Solution and enables lookup by XML documentation ID. -/// Uses for lazy one-time symbol table loading with retry on failure. -/// -internal sealed class SymbolExtractionService : ISymbolExtractionService -{ - private readonly ISolutionWorkspaceService _workspaceService; - private readonly ILogger _logger; - private readonly AsyncLazy> _symbolsLazy; - - public SymbolExtractionService( - ISolutionWorkspaceService workspaceService, - ILogger logger) - { - ArgumentNullException.ThrowIfNull(workspaceService); - ArgumentNullException.ThrowIfNull(logger); - - _workspaceService = workspaceService; - _logger = logger; - _symbolsLazy = new AsyncLazy>(LoadAllSymbolsAsync); - } - - public async Task> ExtractSymbolsAsync(IEnumerable projects) - { - var symbols = new ConcurrentDictionary(StringComparer.Ordinal); - var documentByPath = new ConcurrentDictionary(StringComparer.Ordinal); - var fallbackByProject = new ConcurrentDictionary(); - - await Parallel.ForEachAsync(projects, async (project, ct) => - { - var compilation = await project.GetCompilationAsync(ct); - if (compilation is null) - { - _logger.LogWarning("Failed to get compilation for project {ProjectName}", project.Name); - return; - } - - await Parallel.ForEachAsync(project.Documents, async (document, ct2) => - { - try - { - if (document.FilePath is { Length: > 0 } path) - { - documentByPath[path] = (document, project); - fallbackByProject.TryAdd(project.Id, (document, project)); - } - await ExtractDocumentSymbolsAsync(document, compilation, project, symbols, ct2); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to extract symbols from {DocumentPath}", document.FilePath); - } - }); - - // Fallback pass — walk compilation symbols for types the syntax-only - // pass missed (e.g. C# 15 unions whose declaration node is not a - // TypeDeclarationSyntax, members synthesized from primary-constructor - // parameter lists like record `#ctor` overloads, or Razor-generated - // component classes whose syntax tree path is not a user document). - try - { - await ExtractCompilationSymbolsAsync(compilation, documentByPath, fallbackByProject, project, symbols); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to extract compilation-level symbols for {ProjectName}", project.Name); - } - }); - - _logger.LogDebug("Extracted {Count} symbols from solution", symbols.Count); - return symbols; - } - - private async Task ExtractCompilationSymbolsAsync( - Compilation compilation, - ConcurrentDictionary documentByPath, - ConcurrentDictionary fallbackByProject, - Project currentProject, - ConcurrentDictionary symbols) - { - var visited = new HashSet(SymbolEqualityComparer.Default); - var namespaceQueue = new Queue(); - var typeQueue = new Queue(); - namespaceQueue.Enqueue(compilation.Assembly.GlobalNamespace); - - while (namespaceQueue.Count > 0) - { - var ns = namespaceQueue.Dequeue(); - foreach (var member in ns.GetMembers()) - { - switch (member) - { - case INamespaceSymbol child: - namespaceQueue.Enqueue(child); - break; - case INamedTypeSymbol type when visited.Add(type): - typeQueue.Enqueue(type); - break; - } - } - } - - while (typeQueue.Count > 0) - { - var type = typeQueue.Dequeue(); - await TryAddSymbolFromCompilationAsync(type, documentByPath, fallbackByProject, currentProject, symbols); - foreach (var typeMember in type.GetMembers()) - { - if (typeMember is INamedTypeSymbol nested) - { - if (visited.Add(nested)) - { - typeQueue.Enqueue(nested); - } - - continue; - } - await TryAddSymbolFromCompilationAsync(typeMember, documentByPath, fallbackByProject, currentProject, symbols); - } - } - } - - private async Task TryAddSymbolFromCompilationAsync( - ISymbol symbol, - ConcurrentDictionary documentByPath, - ConcurrentDictionary fallbackByProject, - Project currentProject, - ConcurrentDictionary symbols) - { - var docId = symbol.GetDocumentationCommentId(); - if (string.IsNullOrEmpty(docId)) - { - return; - } - - var normalizedId = XmlDocIdNormalizer.Normalize(docId); - if (symbols.ContainsKey(normalizedId)) - { - return; - } - - var syntaxRef = symbol.DeclaringSyntaxReferences.FirstOrDefault(); - var path = syntaxRef?.SyntaxTree.FilePath; - SyntaxNode node; - TextSpan span; - - if (syntaxRef is not null && !string.IsNullOrEmpty(path) && documentByPath.TryGetValue(path, out var entry)) - { - node = await syntaxRef.GetSyntaxAsync(); - span = node.Span; - } - else if (syntaxRef is not null && fallbackByProject.TryGetValue(currentProject.Id, out entry)) - { - // Compiler-synthesized symbols (e.g. record primary constructors whose - // syntax reference points to the record declaration but whose path - // lookup misses) register with a placeholder span; downstream source - // extraction will no-op against it. - node = await syntaxRef.GetSyntaxAsync(); - span = default; - } - else - { - return; - } - - var sourceText = await entry.Document.GetTextAsync(); - - var info = new SymbolInfo( - Symbol: symbol, - Document: entry.Document, - SyntaxNode: node, - SourceText: sourceText, - TextSpan: span, - Project: entry.Project - ); - - symbols.TryAdd(normalizedId, info); - } - - public async Task FindSymbolAsync(string xmlDocId) - { - var normalizedId = XmlDocIdNormalizer.Normalize(xmlDocId); - var symbols = await _symbolsLazy.Value; - - if (symbols.TryGetValue(normalizedId, out var symbolInfo)) - { - return symbolInfo; - } - - _logger.LogTrace("Symbol not found for xmlDocId: {XmlDocId} (normalized: {NormalizedId})", xmlDocId, normalizedId); - return null; - } - - public async Task ExtractCodeFragmentAsync(string xmlDocId, bool bodyOnly = false, bool includeLeadingTrivia = true) - { - var symbolInfo = await FindSymbolAsync(xmlDocId); - if (symbolInfo is null) - { - _logger.LogWarning("Cannot extract code fragment — symbol not found: {XmlDocId}", xmlDocId); - return string.Empty; - } - - var root = await symbolInfo.Document.GetSyntaxRootAsync(); - if (root is null) - { - return string.Empty; - } - - // Find the node in the current syntax tree by its span - var node = root.FindNode(symbolInfo.TextSpan); - if (node is null) - { - return string.Empty; - } - - var sourceText = await symbolInfo.Document.GetTextAsync(); - var fullText = sourceText.ToString(); - - var fragment = await CodeFragmentExtractor.ExtractCodeFragmentAsync(node, fullText, bodyOnly, includeLeadingTrivia); - return TextFormatter.NormalizeIndents(fragment); - } - - public async Task ExtractCodeFragmentWithUsingsAsync(string xmlDocId, bool bodyOnly = false, bool includeLeadingTrivia = true) - { - var symbolInfo = await FindSymbolAsync(xmlDocId); - if (symbolInfo is null) - { - _logger.LogWarning("Cannot extract code fragment with usings — symbol not found: {XmlDocId}", xmlDocId); - return new CodeFragmentResult(string.Empty, ImmutableList.Empty); - } - - var root = await symbolInfo.Document.GetSyntaxRootAsync(); - if (root is null) - { - return new CodeFragmentResult(string.Empty, ImmutableList.Empty); - } - - var node = symbolInfo.TextSpan.Length > 0 ? root.FindNode(symbolInfo.TextSpan) : null; - if (node is null) - { - return new CodeFragmentResult(string.Empty, ImmutableList.Empty); - } - - var sourceText = await symbolInfo.Document.GetTextAsync(); - var fullText = sourceText.ToString(); - - var fragment = await CodeFragmentExtractor.ExtractCodeFragmentAsync(node, fullText, bodyOnly, includeLeadingTrivia); - var dedented = TextFormatter.NormalizeIndents(fragment); - - var usings = await ResolveRequiredUsingsAsync(symbolInfo, root, node); - return new CodeFragmentResult(dedented, usings); - } - - private async Task> ResolveRequiredUsingsAsync(SymbolInfo symbolInfo, SyntaxNode root, SyntaxNode fragmentNode) - { - if (root is not CompilationUnitSyntax compilationUnit) - { - return ImmutableList.Empty; - } - - var compilation = await symbolInfo.Project.GetCompilationAsync(); - if (compilation is null) - { - return ImmutableList.Empty; - } - - var semanticModel = compilation.GetSemanticModel(fragmentNode.SyntaxTree); - var directives = RequiredUsingsAnalyzer.Analyze(fragmentNode, semanticModel, compilationUnit); - return directives.Select(d => d.ToString().Trim()).ToImmutableList(); - } - - public async Task ExtractDeclarationSignatureAsync(string xmlDocId) - { - var symbolInfo = await FindSymbolAsync(xmlDocId); - if (symbolInfo is null) - { - _logger.LogWarning("Cannot extract declaration signature — symbol not found: {XmlDocId}", xmlDocId); - return string.Empty; - } - - var root = await symbolInfo.Document.GetSyntaxRootAsync(); - if (root is null) - { - return string.Empty; - } - - var node = root.FindNode(symbolInfo.TextSpan); - if (node is null) - { - return string.Empty; - } - - var sourceText = await symbolInfo.Document.GetTextAsync(); - var fullText = sourceText.ToString(); - - var fragment = await CodeFragmentExtractor.ExtractSignatureAsync(node, fullText); - return TextFormatter.NormalizeIndents(fragment); - } - - public void ClearCache() - { - _logger.LogDebug("Clearing symbol extraction cache"); - _symbolsLazy.Reset(); - } - - public async Task WarmupAsync(CancellationToken cancellationToken = default) - { - await _symbolsLazy.Value.WaitAsync(cancellationToken); - } - - private async Task> LoadAllSymbolsAsync() - { - _logger.LogDebug("Loading all symbols from workspace"); - - var projects = (await _workspaceService.GetProjectsAsync()).ToList(); - - if (projects.Count == 0) - { - _logger.LogWarning("No projects available from workspace service"); - return new Dictionary(); - } - - return await ExtractSymbolsAsync(projects); - } - - private async Task ExtractDocumentSymbolsAsync( - Document document, - Compilation compilation, - Project project, - ConcurrentDictionary symbols, - CancellationToken ct) - { - var syntaxRoot = await document.GetSyntaxRootAsync(ct); - if (syntaxRoot is null) - { - return; - } - - var semanticModel = compilation.GetSemanticModel(syntaxRoot.SyntaxTree); - var sourceText = await document.GetTextAsync(ct); - - // Extract type declarations and their members - foreach (var typeDecl in syntaxRoot.DescendantNodes().OfType()) - { - AddSymbol(typeDecl, semanticModel, document, sourceText, project, symbols); - - // Extract members within the type - foreach (var member in typeDecl.Members) - { - AddSymbol(member, semanticModel, document, sourceText, project, symbols); - } - } - - // Extract delegate declarations - foreach (var delegateDecl in syntaxRoot.DescendantNodes().OfType()) - { - AddSymbol(delegateDecl, semanticModel, document, sourceText, project, symbols); - } - - // Extract enum declarations and their members - foreach (var enumDecl in syntaxRoot.DescendantNodes().OfType()) - { - AddSymbol(enumDecl, semanticModel, document, sourceText, project, symbols); - - foreach (var member in enumDecl.Members) - { - AddSymbol(member, semanticModel, document, sourceText, project, symbols); - } - } - - // Extract global statements (top-level programs) - foreach (var globalStatement in syntaxRoot.DescendantNodes().OfType()) - { - AddSymbol(globalStatement, semanticModel, document, sourceText, project, symbols); - } - } - - private void AddSymbol( - SyntaxNode node, - SemanticModel semanticModel, - Document document, - SourceText sourceText, - Project project, - ConcurrentDictionary symbols) - { - var symbol = semanticModel.GetDeclaredSymbol(node); - if (symbol is null) - { - return; - } - - var docId = symbol.GetDocumentationCommentId(); - if (string.IsNullOrEmpty(docId)) - { - return; - } - - var normalizedId = XmlDocIdNormalizer.Normalize(docId); - - var info = new SymbolInfo( - Symbol: symbol, - Document: document, - SyntaxNode: node, - SourceText: sourceText, - TextSpan: node.Span, - Project: project - ); - - if (!symbols.TryAdd(normalizedId, info)) - { - _logger.LogTrace("Duplicate symbol ID: {DocId}", normalizedId); - } - } -} \ No newline at end of file diff --git a/src/Pennington.Roslyn/Symbols/SymbolExtractionWarmupService.cs b/src/Pennington.Roslyn/Symbols/SymbolExtractionWarmupService.cs deleted file mode 100644 index 1676a6e0..00000000 --- a/src/Pennington.Roslyn/Symbols/SymbolExtractionWarmupService.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace Pennington.Roslyn.Symbols; - -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -/// Background service that kicks off the one-time symbol table walk so the first page request doesn't pay the cold-load cost. -internal sealed class SymbolExtractionWarmupService : BackgroundService -{ - private readonly ISymbolExtractionService _symbolService; - private readonly ILogger _logger; - - public SymbolExtractionWarmupService( - ISymbolExtractionService symbolService, - ILogger logger) - { - _symbolService = symbolService; - _logger = logger; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - var sw = System.Diagnostics.Stopwatch.StartNew(); - try - { - await _symbolService.WarmupAsync(stoppingToken); - sw.Stop(); - _logger.LogInformation("Symbol extraction warmup completed in {ElapsedMs}ms", sw.ElapsedMilliseconds); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - // App is shutting down before warmup finished — nothing to do. - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Symbol extraction warmup failed; first request will retry the walk"); - } - } -} \ No newline at end of file diff --git a/src/Pennington.Roslyn/Symbols/SymbolInfo.cs b/src/Pennington.Roslyn/Symbols/SymbolInfo.cs deleted file mode 100644 index e0232ba2..00000000 --- a/src/Pennington.Roslyn/Symbols/SymbolInfo.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Pennington.Roslyn.Symbols; - -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Text; - -/// Information about an extracted symbol. -/// The resolved Roslyn symbol. -/// Roslyn document that declares the symbol. -/// Declaration syntax node for the symbol. -/// Full source text of the containing document. -/// Span within covering the declaration. -/// Roslyn project the symbol belongs to. -public record SymbolInfo( - ISymbol Symbol, - Document Document, - SyntaxNode SyntaxNode, - SourceText SourceText, - TextSpan TextSpan, - Project Project -); \ No newline at end of file diff --git a/src/Pennington.Roslyn/Symbols/XmlDocIdNormalizer.cs b/src/Pennington.Roslyn/Symbols/XmlDocIdNormalizer.cs deleted file mode 100644 index d8ed631a..00000000 --- a/src/Pennington.Roslyn/Symbols/XmlDocIdNormalizer.cs +++ /dev/null @@ -1,150 +0,0 @@ -namespace Pennington.Roslyn.Symbols; - -using System.Text; - -/// -/// Normalizes XML documentation IDs by stripping namespace prefixes from parameter types. -/// E.g., M:Type.Method(System.String,System.Int32) becomes M:Type.Method(String,Int32). -/// Handles generic parameters, nested delimiters, arrays, ref params, etc. -/// -internal static class XmlDocIdNormalizer -{ - /// - /// Normalizes an XML documentation ID by stripping namespace prefixes from parameter types. - /// Only modifies method-like IDs (those with parenthesized parameter lists). - /// - public static string Normalize(string xmlDocId) - { - if (string.IsNullOrEmpty(xmlDocId)) - { - return xmlDocId; - } - - // Only process IDs that have parameter lists (methods, constructors, operators, etc.) - var parenIndex = xmlDocId.IndexOf('('); - if (parenIndex < 0) - { - return xmlDocId; - } - - var prefix = xmlDocId[..parenIndex]; - var paramsPart = xmlDocId[parenIndex..]; - - var normalized = NormalizeParameters(paramsPart); - return prefix + normalized; - } - - private static string NormalizeParameters(string paramsPart) - { - var sb = new StringBuilder(paramsPart.Length); - var i = 0; - - while (i < paramsPart.Length) - { - var ch = paramsPart[i]; - - switch (ch) - { - case '(' or ')' or ',' or '[' or ']' or '@' or '*': - sb.Append(ch); - i++; - break; - - case '{': - sb.Append('{'); - i++; - break; - - case '}': - sb.Append('}'); - i++; - break; - - case '`': - // Generic parameter reference like `0, `1, ``0, ``1 - sb.Append(ch); - i++; - // Consume additional backticks - while (i < paramsPart.Length && paramsPart[i] == '`') - { - sb.Append(paramsPart[i]); - i++; - } - // Consume digits - while (i < paramsPart.Length && char.IsDigit(paramsPart[i])) - { - sb.Append(paramsPart[i]); - i++; - } - break; - - default: - // This is a type name — read the full qualified name and strip the namespace - var (typeName, newIndex) = ReadTypeName(paramsPart, i); - sb.Append(StripNamespace(typeName)); - i = newIndex; - break; - } - } - - return sb.ToString(); - } - - private static (string TypeName, int NewIndex) ReadTypeName(string text, int start) - { - var i = start; - var depth = 0; - - while (i < text.Length) - { - var ch = text[i]; - - if (ch == '{') - { - depth++; - i++; - } - else if (ch == '}') - { - depth--; - i++; - } - else if (depth == 0 && (ch is ',' or ')' or '[' or ']' or '@' or '*')) - { - break; - } - else - { - i++; - } - } - - return (text[start..i], i); - } - - private static string StripNamespace(string qualifiedType) - { - // Handle generic types with curly braces: System.Collections.Generic.List{System.String} - var braceIndex = qualifiedType.IndexOf('{'); - if (braceIndex >= 0) - { - var outerType = qualifiedType[..braceIndex]; - var innerPart = qualifiedType[braceIndex..]; - return StripNamespaceFromSimpleType(outerType) + NormalizeParameters(innerPart); - } - - return StripNamespaceFromSimpleType(qualifiedType); - } - - private static string StripNamespaceFromSimpleType(string typeName) - { - // Don't strip generic parameter references like `0 - if (typeName.Length > 0 && typeName[0] == '`') - { - return typeName; - } - - var lastDot = typeName.LastIndexOf('.'); - return lastDot >= 0 ? typeName[(lastDot + 1)..] : typeName; - } -} \ No newline at end of file diff --git a/src/Pennington.Roslyn/Utilities/TextFormatter.cs b/src/Pennington.Roslyn/Utilities/TextFormatter.cs deleted file mode 100644 index 647a17cb..00000000 --- a/src/Pennington.Roslyn/Utilities/TextFormatter.cs +++ /dev/null @@ -1,84 +0,0 @@ -namespace Pennington.Roslyn.Utilities; - -/// -/// Text formatting utilities for code fragments. -/// -internal static class TextFormatter -{ - /// - /// Strips common leading whitespace from all non-empty lines, - /// preserving relative indentation between lines, and trims - /// leading/trailing blank lines. - /// - public static string NormalizeIndents(string code) - { - if (string.IsNullOrEmpty(code)) - { - return code; - } - - var lines = code.Split('\n'); - var minIndent = int.MaxValue; - var first = -1; - var last = -1; - - // Find the minimum indentation and the first/last non-empty lines - for (var i = 0; i < lines.Length; i++) - { - var line = lines[i]; - if (string.IsNullOrWhiteSpace(line)) - { - continue; - } - - if (first < 0) - { - first = i; - } - - last = i; - - var indent = 0; - foreach (var ch in line) - { - if (ch is ' ' or '\t') - { - indent++; - } - else - { - break; - } - } - - minIndent = Math.Min(minIndent, indent); - } - - if (first < 0) - { - // No non-empty lines — return unchanged so callers can distinguish - return code; - } - - // Dedent (when minIndent > 0) and trim leading/trailing blank lines. - // Body-only extraction starts with "\n..." and ends with the - // close-brace indent, so stripping the surrounding blanks produces a - // clean block. - var kept = last - first + 1; - var result = new string[kept]; - for (var i = 0; i < kept; i++) - { - var line = lines[first + i]; - if (string.IsNullOrWhiteSpace(line)) - { - result[i] = string.Empty; - } - else - { - result[i] = minIndent > 0 && line.Length > minIndent ? line[minIndent..] : line; - } - } - - return string.Join('\n', result); - } -} \ No newline at end of file diff --git a/src/Pennington.Roslyn/Workspace/ISolutionWorkspaceService.cs b/src/Pennington.Roslyn/Workspace/ISolutionWorkspaceService.cs deleted file mode 100644 index 5431c624..00000000 --- a/src/Pennington.Roslyn/Workspace/ISolutionWorkspaceService.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace Pennington.Roslyn.Workspace; - -using Microsoft.CodeAnalysis; - -/// -/// Manages a Roslyn Solution loaded from an MSBuild workspace. -/// Supports incremental document updates and full solution reloads. -/// -public interface ISolutionWorkspaceService : IDisposable -{ - /// - /// Loads a solution from the specified path. If already loaded, - /// applies any pending document updates and returns the cached solution. - /// - Task LoadSolutionAsync(string solutionPath); - - /// - /// Gets projects from the loaded solution with optional filtering. - /// - Task> GetProjectsAsync(Func? filter = null); - - /// - /// Gets the compilation for a specific project, with caching. - /// - Task GetCompilationAsync(Project project); - - /// - /// Invalidates the cached solution, forcing a full reload on next access. - /// Clears all caches and pending updates. - /// - void InvalidateSolution(); - - /// - /// Queues a document update for deferred processing. - /// The update is applied on the next call. - /// - void UpdateDocument(string filePath); -} \ No newline at end of file diff --git a/src/Pennington.Roslyn/Workspace/SolutionWorkspaceService.cs b/src/Pennington.Roslyn/Workspace/SolutionWorkspaceService.cs deleted file mode 100644 index 9ba732fc..00000000 --- a/src/Pennington.Roslyn/Workspace/SolutionWorkspaceService.cs +++ /dev/null @@ -1,579 +0,0 @@ -namespace Pennington.Roslyn.Workspace; - -using System.Collections.Concurrent; -using System.Text; -using Infrastructure; -using Microsoft.Build.Locator; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.MSBuild; -using Microsoft.CodeAnalysis.Text; -using Microsoft.Extensions.Logging; -using Symbols; - -/// -/// Implementation of that manages an MSBuild workspace, -/// supports deferred document updates, and integrates with file watching. -/// -internal sealed class SolutionWorkspaceService : ISolutionWorkspaceService -{ - private readonly ILogger _logger; - private readonly RoslynOptions _options; - private readonly Lock _lock = new(); - - private MSBuildWorkspace? _workspace; - private Solution? _solution; - private readonly ConcurrentDictionary _compilationCache = new(); - private readonly ConcurrentQueue _pendingUpdates = new(); - private bool _isDisposed; - - internal ISymbolExtractionService? SymbolExtractionService { get; set; } - - static SolutionWorkspaceService() - { - if (!MSBuildLocator.IsRegistered) - { - var instance = MSBuildLocator.QueryVisualStudioInstances() - .OrderByDescending(x => x.Version) - .FirstOrDefault(); - - if (instance is not null) - { - MSBuildLocator.RegisterInstance(instance); - } - else - { - MSBuildLocator.RegisterDefaults(); - } - } - } - - public SolutionWorkspaceService( - RoslynOptions options, - IFileWatcher fileWatcher, - ILogger logger) - { - ArgumentNullException.ThrowIfNull(options); - ArgumentNullException.ThrowIfNull(fileWatcher); - ArgumentNullException.ThrowIfNull(logger); - - _options = options; - _logger = logger; - - if (string.IsNullOrWhiteSpace(_options.SolutionPath)) - { - throw new ArgumentException("Solution path must be specified in options", nameof(options)); - } - - RegisterFileWatching(fileWatcher); - } - - public async Task LoadSolutionAsync(string solutionPath) - { - ObjectDisposedException.ThrowIf(_isDisposed, this); - - lock (_lock) - { - if (_solution is not null && _workspace is not null) - { - ApplyPendingUpdates(); - return _solution; - } - } - - _logger.LogDebug("Loading solution from {SolutionPath}", solutionPath); - - // Use each project's real obj/ (no BaseIntermediateOutputPath redirect). A single - // redirected obj is shared by every project in the solution (the SDK derives - // IntermediateOutputPath from BaseIntermediateOutputPath + config + TFM, with no - // project-name component), so the parallel design-time builds collide there AND the - // fresh temp obj has no NuGet restore assets (project.assets.json / nuget.g.props). - // When a project loses that race it loads with ZERO metadata references — every - // framework/package type becomes an error type, producing silently degraded API - // reference output. The real per-project obj is isolated and already restored by the - // build that precedes the crawl, so references resolve deterministically. - var workspace = MSBuildWorkspace.Create(); - workspace.RegisterWorkspaceFailedHandler(args => - { - var diagnostic = args.Diagnostic; - - // [Warning] kind from MSBuildWorkspace is its own "informational" tier — - // typically "found project reference without a matching metadata reference" - // emitted while parallel multi-target evaluation is in flight; Roslyn falls - // back to source for that single reference. Real config mistakes (a project - // reference pointing at a missing csproj, etc.) would have already failed - // `dotnet build` before the crawl. - if (diagnostic.Kind == WorkspaceDiagnosticKind.Warning) - { - _logger.LogDebug("Workspace warning (suppressed): {Diagnostic}", diagnostic); - return; - } - - _logger.LogWarning("Workspace failed: {Diagnostic}", diagnostic); - }); - - try - { - var solution = await workspace.OpenSolutionAsync(solutionPath); - - lock (_lock) - { - _workspace?.Dispose(); - _workspace = workspace; - _solution = solution; - _compilationCache.Clear(); - } - - _logger.LogDebug("Successfully loaded solution with {ProjectCount} projects", - solution.Projects.Count()); - return solution; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to load solution from {SolutionPath}", solutionPath); - workspace.Dispose(); - throw; - } - } - - public async Task> GetProjectsAsync(Func? filter = null) - { - ObjectDisposedException.ThrowIf(_isDisposed, this); - - var solution = await LoadSolutionAsync(_options.SolutionPath!); - var projects = solution.Projects; - - if (filter is not null) - { - projects = projects.Where(filter); - } - - if (_options.ProjectFilter is not null) - { - projects = ApplyProjectFilter(projects, _options.ProjectFilter); - } - - projects = SelectMostRecentTargetFramework(projects); - - return projects.ToList(); - } - - public async Task GetCompilationAsync(Project project) - { - ObjectDisposedException.ThrowIf(_isDisposed, this); - - if (_compilationCache.TryGetValue(project.Id, out var cachedCompilation)) - { - _logger.LogTrace("Compilation cache HIT for project {ProjectName} ({ProjectId})", - project.Name, project.Id); - return cachedCompilation; - } - - _logger.LogTrace("Compilation cache MISS for project {ProjectName} ({ProjectId}) - compiling", - project.Name, project.Id); - - try - { - _logger.LogDebug("Compiling project {ProjectName}", project.Name); - var compilation = await project.GetCompilationAsync(); - - if (compilation is not null) - { - _compilationCache.TryAdd(project.Id, compilation); - _logger.LogTrace("Compilation cached for project {ProjectName} ({ProjectId})", - project.Name, project.Id); - } - - return compilation; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to compile project {ProjectName}", project.Name); - return null; - } - } - - public void InvalidateSolution() - { - _logger.LogWarning("InvalidateSolution called - clearing {QueuedCount} pending updates", - _pendingUpdates.Count); - - lock (_lock) - { - var cachedProjectCount = _compilationCache.Count; - var queuedCount = _pendingUpdates.Count; - - _logger.LogTrace("Invalidating solution cache (clearing {Count} cached compilations, {QueuedCount} queued updates)", - cachedProjectCount, queuedCount); - - _solution = null; - _workspace?.Dispose(); - _workspace = null; - _compilationCache.Clear(); - _pendingUpdates.Clear(); - - _logger.LogTrace("Solution cache invalidated, workspace disposed"); - } - - SymbolExtractionService?.ClearCache(); - } - - public void UpdateDocument(string filePath) - { - _logger.LogTrace("UpdateDocument called for {FilePath}", filePath); - - _pendingUpdates.Enqueue(filePath); - - _logger.LogTrace("Enqueued document update for {FilePath} (queue depth: {Count})", - filePath, _pendingUpdates.Count); - - // Symbol cache holds SymbolInfo with snapshot Document references. Those - // snapshots are frozen to the pre-edit solution; clear so the next - // extraction re-queries from the patched solution. - SymbolExtractionService?.ClearCache(); - } - - private void ApplyPendingUpdates() - { - // Must be called within _lock - - if (_solution is null) - { - _logger.LogTrace("No solution loaded, clearing pending updates queue"); - _pendingUpdates.Clear(); - return; - } - - if (_pendingUpdates.IsEmpty) - { - return; - } - - _logger.LogTrace("Applying {Count} pending document updates", _pendingUpdates.Count); - - // Dequeue all pending updates and deduplicate by file path - var updatesByPath = new Dictionary(StringComparer.OrdinalIgnoreCase); - - while (_pendingUpdates.TryDequeue(out var filePath)) - { - updatesByPath[filePath] = true; - } - - _logger.LogTrace("Deduplicated to {Count} unique file(s)", updatesByPath.Count); - - // Apply updates to solution - var updatedSolution = _solution; - var invalidatedProjects = new HashSet(); - var successCount = 0; - - foreach (var filePath in updatesByPath.Keys) - { - try - { - var documentIds = _solution.GetDocumentIdsWithFilePath(filePath); - - if (documentIds.IsEmpty) - { - _logger.LogTrace("File {FilePath} not found in solution during deferred update", filePath); - continue; - } - - // Read file content with sharing enabled (file may be locked by editor) - using var fileStream = new FileStream( - filePath, - FileMode.Open, - FileAccess.Read, - FileShare.ReadWrite); - using var reader = new StreamReader(fileStream, Encoding.UTF8); - var fileContent = reader.ReadToEnd(); - var newText = SourceText.From(fileContent, Encoding.UTF8); - - foreach (var docId in documentIds) - { - var document = _solution.GetDocument(docId); - _logger.LogTrace("Applying deferred update to document in project {ProjectName} for {FilePath}", - document?.Project.Name ?? "Unknown", filePath); - - updatedSolution = updatedSolution.WithDocumentText(docId, newText); - invalidatedProjects.Add(docId.ProjectId); - } - - successCount++; - } - catch (FileNotFoundException) - { - _logger.LogTrace("File {FilePath} not found during update (may have been deleted)", filePath); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to apply deferred update for {FilePath}, invalidating solution", filePath); - - // On unexpected failure, invalidate entire solution for safety - _solution = null; - _workspace?.Dispose(); - _workspace = null; - _compilationCache.Clear(); - return; - } - } - - // Commit the batched update - if (successCount > 0) - { - _solution = updatedSolution; - - foreach (var projectId in invalidatedProjects) - { - _compilationCache.TryRemove(projectId, out _); - } - - _logger.LogTrace("Successfully applied {Count} deferred document update(s), invalidated {ProjectCount} project compilation(s)", - successCount, invalidatedProjects.Count); - } - else - { - _logger.LogTrace("No updates were successfully applied"); - } - } - - private void RegisterFileWatching(IFileWatcher fileWatcher) - { - var solutionDir = Path.GetDirectoryName(Path.GetFullPath(_options.SolutionPath!)); - if (string.IsNullOrEmpty(solutionDir)) - { - return; - } - - // Watch for project file changes - always invalidate - fileWatcher.AddPathWatch(solutionDir, "*.csproj", (path, changeType) => - { - _logger.LogTrace("Project file watcher triggered: {ChangeType} for {Path}", changeType, path); - _logger.LogDebug("Project file changed: {Path}", path); - InvalidateSolution(); - }); - - // Watch for solution file changes - invalidate only if it matches our configured path - fileWatcher.AddPathWatch(solutionDir, "*.sln", (path, changeType) => - { - _logger.LogTrace("Solution file watcher triggered: {ChangeType} for {Path}", changeType, path); - - if (path.Equals(Path.GetFullPath(_options.SolutionPath!), StringComparison.OrdinalIgnoreCase)) - { - _logger.LogDebug("Solution file changed: {Path}", path); - InvalidateSolution(); - } - else - { - _logger.LogTrace("Solution file changed but not the configured solution, ignoring: {Path}", path); - } - }); - - fileWatcher.AddPathWatch(solutionDir, "*.slnx", (path, changeType) => - { - _logger.LogTrace("Solution file watcher triggered: {ChangeType} for {Path}", changeType, path); - - if (path.Equals(Path.GetFullPath(_options.SolutionPath!), StringComparison.OrdinalIgnoreCase)) - { - _logger.LogDebug("Solution file changed: {Path}", path); - InvalidateSolution(); - } - else - { - _logger.LogTrace("Solution file changed but not the configured solution, ignoring: {Path}", path); - } - }); - - // Watch for C# source file changes - smart handling based on change type - fileWatcher.AddPathWatch(solutionDir, "*.cs", (path, changeType) => - { - _logger.LogTrace("C# source file watcher triggered: {ChangeType} for {Path}", changeType, path); - - if (IsGeneratedOrBuildOutput(path)) - { - _logger.LogTrace("Ignoring generated or build-output file: {Path}", path); - return; - } - - switch (changeType) - { - case WatcherChangeTypes.Changed: - _logger.LogTrace("File content changed - calling UpdateDocument"); - UpdateDocument(path); - break; - - case WatcherChangeTypes.Created: - case WatcherChangeTypes.Deleted: - case WatcherChangeTypes.Renamed: - _logger.LogTrace("Structural change detected - calling InvalidateSolution"); - InvalidateSolution(); - break; - } - }); - } - - /// - /// Returns true for `.cs` paths that should be ignored by the source watcher — - /// MSBuild output directories (`obj/`, `bin/`) and typical generated files. - /// Prevents rebuild bursts from thrashing the symbol cache. - /// - private static bool IsGeneratedOrBuildOutput(string path) - { - var normalized = path.Replace('\\', '/'); - - foreach (var segment in normalized.Split('/', StringSplitOptions.RemoveEmptyEntries)) - { - if (segment.Equals("obj", StringComparison.OrdinalIgnoreCase) || - segment.Equals("bin", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - - var fileName = Path.GetFileName(normalized); - return fileName.EndsWith(".g.cs", StringComparison.OrdinalIgnoreCase) - || fileName.EndsWith(".Designer.cs", StringComparison.OrdinalIgnoreCase) - || fileName.EndsWith(".AssemblyInfo.cs", StringComparison.OrdinalIgnoreCase) - || fileName.EndsWith(".AssemblyAttributes.cs", StringComparison.OrdinalIgnoreCase); - } - - private static IEnumerable ApplyProjectFilter(IEnumerable projects, ProjectFilter filter) - { - if (filter.IncludedProjects is { Count: > 0 }) - { - projects = projects.Where(p => filter.IncludedProjects.Contains(p.Name)); - } - - if (filter.ExcludedProjects is { Count: > 0 }) - { - projects = projects.Where(p => !filter.ExcludedProjects.Contains(p.Name)); - } - - return projects; - } - - // MSBuildWorkspace yields one Project per TFM for multi-targeted csproj files - // (same FilePath, Name suffixed as "Foo(net11.0)"). Picking all of them double- - // extracts symbols and can drag in down-level TFMs like net45 whose APIs diverge - // from the modern build. Collapse each group to the most recent TFM. - private IEnumerable SelectMostRecentTargetFramework(IEnumerable projects) - { - var grouped = projects - .GroupBy(p => p.FilePath ?? p.Id.ToString(), StringComparer.OrdinalIgnoreCase); - - foreach (var group in grouped) - { - var candidates = group.ToList(); - if (candidates.Count == 1) - { - yield return candidates[0]; - continue; - } - - var ranked = candidates - .Select(p => (Project: p, Tfm: ExtractTargetFramework(p))) - .OrderByDescending(x => TargetFrameworkRank(x.Tfm)) - .ToList(); - - var winner = ranked[0]; - var skipped = string.Join(", ", ranked.Skip(1).Select(x => x.Tfm ?? "?")); - _logger.LogDebug( - "Multi-target project {ProjectFile}: selected {SelectedTfm}, skipped {SkippedTfms}", - group.Key, - winner.Tfm ?? "?", - skipped); - - yield return winner.Project; - } - } - - // Roslyn names a multi-target project's Project instance as "{Name}({tfm})". - // Single-target projects have no suffix, in which case the TFM is unknown here. - private static string? ExtractTargetFramework(Project project) - { - var name = project.Name; - var open = name.LastIndexOf('('); - if (open < 0 || !name.EndsWith(')')) - { - return null; - } - - return name[(open + 1)..^1]; - } - - // Ranks TFMs so the newest modern .NET wins, and legacy .NET Framework - // (net45/net48/etc.) can never beat a modern TFM even when it sorts later - // alphabetically. - private static long TargetFrameworkRank(string? tfm) - { - if (string.IsNullOrEmpty(tfm)) - { - return 0; - } - - var dash = tfm.IndexOf('-'); - if (dash >= 0) - { - tfm = tfm[..dash]; - } - - if (!tfm.StartsWith("net", StringComparison.OrdinalIgnoreCase)) - { - return 0; - } - - var rest = tfm[3..]; - - if (rest.StartsWith("standard", StringComparison.OrdinalIgnoreCase)) - { - return 20_000 + ParseDottedVersion(rest["standard".Length..]); - } - - if (rest.StartsWith("coreapp", StringComparison.OrdinalIgnoreCase)) - { - return 30_000 + ParseDottedVersion(rest["coreapp".Length..]); - } - - // "netX.Y" form (.NET 5+) has a dot; .NET Framework uses "net45"/"net472". - if (rest.Contains('.')) - { - return 40_000 + ParseDottedVersion(rest); - } - - if (int.TryParse(rest, out var frameworkVersion)) - { - return 10_000 + frameworkVersion; - } - - return 0; - } - - private static int ParseDottedVersion(string value) - { - return Version.TryParse(value, out var version) - ? version.Major * 1000 + version.Minor * 10 + Math.Max(version.Build, 0) - : 0; - } - - public void Dispose() - { - if (_isDisposed) - { - return; - } - - lock (_lock) - { - // MSBuildWorkspace.Dispose() can throw on Windows when assembly handles - // are still mapped; swallow so disposal still completes. - try - { - _workspace?.Dispose(); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error disposing MSBuild workspace"); - } - _compilationCache.Clear(); - _isDisposed = true; - } - } -} \ No newline at end of file diff --git a/src/Pennington.UI/Components/CodeBlock.razor b/src/Pennington.UI/Components/CodeBlock.razor index d8e71356..3e6f84e5 100644 --- a/src/Pennington.UI/Components/CodeBlock.razor +++ b/src/Pennington.UI/Components/CodeBlock.razor @@ -13,13 +13,12 @@ /// /// The language id passed to . A bare language /// (e.g. csharp, python) goes straight to the highlighter; appending a - /// modifier — csharp:xmldocid, csharp:xmldocid,bodyonly, csharp:path, - /// or csharp:xmldocid-diff — routes through the registered - /// chain (e.g. - /// Pennington.Roslyn.Preprocessing.RoslynCodeBlockPreprocessor when - /// AddPenningtonRoslyn is wired). The Code parameter or - /// ChildContent supplies the body the preprocessor operates on - /// (XmlDocId for :xmldocid, file path for :path). + /// modifier — csharp:symbol, csharp:symbol,bodyonly, + /// or csharp:symbol-diff — routes through the registered + /// chain (e.g. the tree-sitter + /// preprocessor when AddPenningtonTreeSitter is wired). The Code + /// parameter or ChildContent supplies the body the preprocessor operates + /// on (a file > Type.Member reference for :symbol). /// [Parameter, EditorRequired] public string Language { get; set; } = string.Empty; diff --git a/src/Pennington.UI/wwwroot/spa-engine.js b/src/Pennington.UI/wwwroot/spa-engine.js index 2787b6bc..a90a4718 100644 --- a/src/Pennington.UI/wwwroot/spa-engine.js +++ b/src/Pennington.UI/wwwroot/spa-engine.js @@ -85,7 +85,7 @@ // gate keeps fast/cached navigations silent. The trickle is fake — the // engine has no body-progress signal, just an atomic fetch — but a // diminishing-return curve plus a 100% snap on commit reads honestly at - // varying durations (e.g. 100ms vs a 5–10s Roslyn cold-start). + // varying durations (e.g. 100ms vs a multi-second cold start). const progressBar = { _outer: null, _bar: null, diff --git a/src/Pennington/Content/RedirectContentService.cs b/src/Pennington/Content/RedirectContentService.cs index 7ee59194..2603ccc8 100644 --- a/src/Pennington/Content/RedirectContentService.cs +++ b/src/Pennington/Content/RedirectContentService.cs @@ -121,7 +121,7 @@ private async Task> LoadMappingsAsync() // IContentService.GetRedirectSourcesAsync returns empty without doing any // work, so services that have no redirects don't pay discovery costs here // (the old shape iterated each service's DiscoverAsync which, for example, - // forced the Roslyn workspace to load for the auto-generated API reference). + // forced the API-reference backend to build its full type index). using var scope = _serviceProvider.CreateScope(); var services = scope.ServiceProvider.GetServices(); foreach (var service in services) diff --git a/tests/Pennington.IntegrationTests/DocsSite/ApiReferenceComponentTests.cs b/tests/Pennington.IntegrationTests/DocsSite/ApiReferenceComponentTests.cs deleted file mode 100644 index 8544b443..00000000 --- a/tests/Pennington.IntegrationTests/DocsSite/ApiReferenceComponentTests.cs +++ /dev/null @@ -1,57 +0,0 @@ -namespace Pennington.IntegrationTests.DocsSite; - -using Infrastructure; - -[Collection(DocsTestServerCollection.Name)] -public class ApiReferenceComponentTests -{ - private readonly HttpClient _client; - - public ApiReferenceComponentTests(DocsWebApplicationFactory factory) - { - _client = factory.CreateClient(); - } - - [Fact(Skip = "reference/options/roslyn-options page is in draft; un-skip when it's published")] - public async Task RoslynOptionsPage_ApiMemberTable_Renders_Both_Properties() - { - var response = await _client.GetAsync("/reference/options/roslyn-options/", TestContext.Current.CancellationToken); - response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK); - - var content = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - content.ShouldNotContain("Page not found"); - content.ShouldNotContain("diag-error"); - content.ShouldContain("SolutionPath"); - content.ShouldContain("ProjectFilter"); - } - - [Fact(Skip = "reference/options/roslyn-options page is in draft; un-skip when it's published")] - public async Task RoslynOptionsPage_ApiMemberTable_Pulls_Description_From_XmlDoc() - { - var response = await _client.GetAsync("/reference/options/roslyn-options/", TestContext.Current.CancellationToken); - var content = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - - content.ShouldContain("Path to .sln or .slnx file"); - } - - [Fact(Skip = "reference/options/roslyn-options page is in draft; un-skip when it's published")] - public async Task RoslynOptionsPage_ApiMemberTable_Uses_Definition_List() - { - var response = await _client.GetAsync("/reference/options/roslyn-options/", TestContext.Current.CancellationToken); - var content = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - - content.ShouldContain("Name"); - } - - [Fact(Skip = "reference/options/roslyn-options page is in draft; un-skip when it's published")] - public async Task RoslynOptionsPage_Renders_Nullable_Type_Symbol() - { - var response = await _client.GetAsync("/reference/options/roslyn-options/", TestContext.Current.CancellationToken); - var content = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - - content.ShouldContain("string?"); - } -} \ No newline at end of file diff --git a/tests/Pennington.IntegrationTests/DocsSite/XmlDocIdFenceSweepSmokeTest.cs b/tests/Pennington.IntegrationTests/DocsSite/XmlDocIdFenceSweepSmokeTest.cs deleted file mode 100644 index 8e7ab09a..00000000 --- a/tests/Pennington.IntegrationTests/DocsSite/XmlDocIdFenceSweepSmokeTest.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace Pennington.IntegrationTests.DocsSite; - -using Infrastructure; - -/// -/// Belt-and-suspenders smoke test for the 2026-04-13 fence-syntax migration: -/// confirms a sweep-converted page's csharp:xmldocid fence actually resolves and -/// renders non-empty source instead of the silent empty block the old attribute form produced. -/// -public class XmlDocIdFenceSweepSmokeTest : IClassFixture -{ - private readonly HttpClient _client; - - public XmlDocIdFenceSweepSmokeTest(DocsWebApplicationFactory factory) - { - _client = factory.CreateClient(); - } - - [Fact(Skip = "reference/options/pennington-options page is in draft; un-skip when it's published")] - public async Task PenningtonOptionsPage_DeclarationFence_Renders_Real_Source() - { - var response = await _client.GetAsync("/reference/options/pennington-options/", TestContext.Current.CancellationToken); - response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK); - - var content = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - - content.ShouldNotContain("Page not found"); - - // A working xmldocid fence renders classified source between
     tags.
    -        // The broken attribute form used to render 
    — empty. - content.ShouldContain("PenningtonOptions"); - content.ShouldContain("SiteTitle"); - content.ShouldContain("ContentRootPath"); - } -} \ No newline at end of file diff --git a/tests/Pennington.IntegrationTests/Infrastructure/DocsWebApplicationFactory.cs b/tests/Pennington.IntegrationTests/Infrastructure/DocsWebApplicationFactory.cs index d5f5f7c7..8ba076aa 100644 --- a/tests/Pennington.IntegrationTests/Infrastructure/DocsWebApplicationFactory.cs +++ b/tests/Pennington.IntegrationTests/Infrastructure/DocsWebApplicationFactory.cs @@ -17,8 +17,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) builder.UseContentRoot(docsProjectPath); - // Ensure relative paths in the docs project's configuration (e.g., RoslynOptions.SolutionPath = "../../Pennington.slnx") - // resolve the same way they would under `dotnet run` from the docs folder. + // Ensure relative paths in the docs project's configuration resolve the same way + // they would under `dotnet run` from the docs folder. Directory.SetCurrentDirectory(docsProjectPath); builder.ConfigureLogging(logging => diff --git a/tests/Pennington.IntegrationTests/Pennington.IntegrationTests.csproj b/tests/Pennington.IntegrationTests/Pennington.IntegrationTests.csproj index 8df758bf..6c37e86e 100644 --- a/tests/Pennington.IntegrationTests/Pennington.IntegrationTests.csproj +++ b/tests/Pennington.IntegrationTests/Pennington.IntegrationTests.csproj @@ -18,6 +18,5 @@ - diff --git a/tests/Pennington.Roslyn.Tests/ApiMetadata/ApiReferenceOptionsRegistrationTests.cs b/tests/Pennington.Roslyn.Tests/ApiMetadata/ApiReferenceOptionsRegistrationTests.cs deleted file mode 100644 index f152c9d9..00000000 --- a/tests/Pennington.Roslyn.Tests/ApiMetadata/ApiReferenceOptionsRegistrationTests.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Pennington.Roslyn.Tests.ApiMetadata; - -using Microsoft.Extensions.DependencyInjection; -using Pennington.Roslyn.ApiMetadata; - -public sealed class ApiReferenceOptionsRegistrationTests -{ - [Fact] - public void Default_name_registers_non_keyed_alias_pointing_at_keyed_instance() - { - var services = new ServiceCollection(); - services.AddApiMetadataFromRoslyn(); - - var provider = services.BuildServiceProvider(); - - var unkeyed = provider.GetRequiredService(); - var keyed = provider.GetRequiredKeyedService("default"); - - unkeyed.ShouldBeSameAs(keyed); - } - - [Fact] - public void Non_default_name_does_not_register_non_keyed_alias() - { - var services = new ServiceCollection(); - services.AddApiMetadataFromRoslyn("custom"); - - var provider = services.BuildServiceProvider(); - - provider.GetService().ShouldBeNull(); - provider.GetRequiredKeyedService("custom").ShouldNotBeNull(); - } -} \ No newline at end of file diff --git a/tests/Pennington.Roslyn.Tests/Documentation/InheritDocResolverTests.cs b/tests/Pennington.Roslyn.Tests/Documentation/InheritDocResolverTests.cs deleted file mode 100644 index be49608c..00000000 --- a/tests/Pennington.Roslyn.Tests/Documentation/InheritDocResolverTests.cs +++ /dev/null @@ -1,242 +0,0 @@ -namespace Pennington.Roslyn.Tests.Documentation; - -using System.Linq; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Pennington.Roslyn.Documentation; -using Xunit; - -public sealed class InheritDocResolverTests -{ - private static (Compilation Compilation, INamedTypeSymbol Type) Compile(string source, string typeName) - { - var tree = CSharpSyntaxTree.ParseText( - source, - new CSharpParseOptions(LanguageVersion.Preview, documentationMode: DocumentationMode.Parse)); - - var compilation = CSharpCompilation.Create( - "Fixture", - [tree], - [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - - var type = compilation.GetTypeByMetadataName(typeName) - ?? throw new InvalidOperationException($"Type {typeName} not found"); - - return (compilation, type); - } - - [Fact] - public void Resolves_Summary_From_Implemented_Interface_Member() - { - const string source = """ - namespace Fixtures; - - public interface IThing - { - /// The thing's name. - string Name { get; } - } - - public sealed record Thing : IThing - { - /// - public string Name { get; init; } = ""; - } - """; - - var (_, type) = Compile(source, "Fixtures.Thing"); - var property = type.GetMembers("Name").OfType().Single(); - - var resolved = InheritDocResolver.Resolve( - property.GetDocumentationCommentXml(cancellationToken: TestContext.Current.CancellationToken), - property); - - resolved.ShouldNotBeNull(); - resolved.ShouldContain("The thing's name."); - resolved.ShouldNotContain("Interface summary.
    - string Name { get; } - } - - public sealed record Thing : IThing - { - /// Record summary. - /// - public string Name { get; init; } = ""; - } - """; - - var (_, type) = Compile(source, "Fixtures.Thing"); - var property = type.GetMembers("Name").OfType().Single(); - - var resolved = InheritDocResolver.Resolve( - property.GetDocumentationCommentXml(cancellationToken: TestContext.Current.CancellationToken), - property); - - resolved.ShouldNotBeNull(); - resolved.ShouldContain("Record summary."); - resolved.ShouldNotContain("Interface summary."); - } - - [Fact] - public void Returns_Input_Unchanged_When_No_Inheritdoc() - { - const string source = """ - namespace Fixtures; - - public sealed record Thing - { - /// Just a name. - public string Name { get; init; } = ""; - } - """; - - var (_, type) = Compile(source, "Fixtures.Thing"); - var property = type.GetMembers("Name").OfType().Single(); - var raw = property.GetDocumentationCommentXml(cancellationToken: TestContext.Current.CancellationToken); - - var resolved = InheritDocResolver.Resolve(raw, property); - - resolved.ShouldBe(raw); - } - - [Fact] - public void Leaves_Inheritdoc_With_Cref_Alone() - { - const string source = """ - namespace Fixtures; - - public interface IThing - { - /// Interface summary. - string Name { get; } - } - - public sealed record Thing : IThing - { - /// - public string Name { get; init; } = ""; - } - """; - - var (_, type) = Compile(source, "Fixtures.Thing"); - var property = type.GetMembers("Name").OfType().Single(); - var raw = property.GetDocumentationCommentXml(cancellationToken: TestContext.Current.CancellationToken); - - var resolved = InheritDocResolver.Resolve(raw, property); - - // cref variant out of scope — returned unchanged. - resolved.ShouldBe(raw); - } - - [Fact] - public void Returns_Input_When_Base_Has_No_Docs() - { - const string source = """ - namespace Fixtures; - - public interface IThing - { - string Name { get; } - } - - public sealed record Thing : IThing - { - /// - public string Name { get; init; } = ""; - } - """; - - var (_, type) = Compile(source, "Fixtures.Thing"); - var property = type.GetMembers("Name").OfType().Single(); - var raw = property.GetDocumentationCommentXml(cancellationToken: TestContext.Current.CancellationToken); - - var resolved = InheritDocResolver.Resolve(raw, property); - - // No base xmldoc found → falls through unchanged, preserving existing - // no-summary signal for callers. - resolved.ShouldBe(raw); - } - - [Fact] - public void Resolves_Through_Two_Levels_Of_Inheritdoc() - { - const string source = """ - namespace Fixtures; - - public interface IBase - { - /// Root summary. - string Name { get; } - } - - public abstract class Middle : IBase - { - /// - public abstract string Name { get; } - } - - public sealed class Thing : Middle - { - /// - public override string Name => ""; - } - """; - - var (_, type) = Compile(source, "Fixtures.Thing"); - var property = type.GetMembers("Name").OfType().Single(); - - var resolved = InheritDocResolver.Resolve( - property.GetDocumentationCommentXml(cancellationToken: TestContext.Current.CancellationToken), - property); - - resolved.ShouldNotBeNull(); - resolved.ShouldContain("Root summary."); - resolved.ShouldNotContain("Does a thing. - /// The input value. - /// The result. - int Do(int value); - } - - public sealed class Thing : IThing - { - /// - public int Do(int value) => value; - } - """; - - var (_, type) = Compile(source, "Fixtures.Thing"); - var method = type.GetMembers("Do").OfType().Single(); - - var resolved = InheritDocResolver.Resolve( - method.GetDocumentationCommentXml(cancellationToken: TestContext.Current.CancellationToken), - method); - - resolved.ShouldNotBeNull(); - resolved.ShouldContain("Does a thing."); - resolved.ShouldContain("The input value."); - resolved.ShouldContain("The result."); - } -} \ No newline at end of file diff --git a/tests/Pennington.Roslyn.Tests/Documentation/RoslynApiMetadataProviderTests.cs b/tests/Pennington.Roslyn.Tests/Documentation/RoslynApiMetadataProviderTests.cs deleted file mode 100644 index 70f5ecb4..00000000 --- a/tests/Pennington.Roslyn.Tests/Documentation/RoslynApiMetadataProviderTests.cs +++ /dev/null @@ -1,473 +0,0 @@ -namespace Pennington.Roslyn.Tests.Documentation; - -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Text; -using Microsoft.Extensions.Logging.Abstractions; -using Pennington.ApiMetadata; -using Pennington.Highlighting; -using Pennington.Roslyn.ApiMetadata; -using Pennington.Roslyn.Symbols; -using Pennington.Roslyn.Workspace; - -public sealed class RoslynApiMetadataProviderTests -{ - private const string Source = """ - namespace Fixtures; - - public sealed class Options - { - /// The site's title. - public string Title { get; set; } = "Untitled"; - - /// Canonical base URL, or null to disable feeds. - public string? BaseUrl { get; set; } - - /// The locale code used when none is specified. - public string DefaultLocale { get; init; } = "en"; - - /// Registers the widget. - /// Widget name. - /// True if the widget was new. - public bool Register(string name) => true; - } - - public interface IExample - { - /// The count. - int Count { get; } - - /// Does something. - void Do(); - } - - public sealed record RequiredOptions - { - public required string Name { get; init; } - public required string Description { get; init; } - public bool EnableFeature { get; init; } - public int RetryCount { get; init; } - public string? Nickname { get; init; } - public string Description2 => "computed"; - } - - public interface IDefaults - { - bool IsDraft => false; - int Priority { get; } - } - - /// One auto-discovered public type, slugged for the route segment. - /// Slug used as the {key} route segment. - /// XmlDocId of the type. - /// Short type name without namespace. - public sealed record PositionalEntry(string Slug, string XmlDocId, string TypeName); - - /// Mixed positional + body — only the param-backed ones get the fallback. - /// The receiver name. - public sealed record MixedEntry(string Name) - { - /// Has its own summary already. - public string Description { get; init; } = string.Empty; - - public string Untouched { get; init; } = string.Empty; - } - - public interface IBaseEmitter - { - /// Emits a thing. - void Emit(); - - /// The base count. - int BaseCount { get; } - } - - public interface IDerivedService : IBaseEmitter - { - /// Discovers things. - void Discover(); - - /// The derived label. - string Label { get; } - } - - /// Case A. - public record CaseA(int X); - /// Case B. - public record CaseB(string Y); - - /// A two-case union. - [System.Runtime.CompilerServices.Union] - public readonly struct UnionAlpha : System.Runtime.CompilerServices.IUnion - { - public object? Value { get; } - public UnionAlpha(CaseA value) { Value = value; } - public UnionAlpha(CaseB value) { Value = value; } - public static implicit operator UnionAlpha(CaseA v) => new(v); - public static implicit operator UnionAlpha(CaseB v) => new(v); - } - """; - - private const string UnionPolyfillSource = """ - namespace System.Runtime.CompilerServices - { - public interface IUnion; - - [System.AttributeUsage(System.AttributeTargets.Struct)] - public sealed class UnionAttribute : System.Attribute; - } - """; - - private static RoslynApiMetadataProvider BuildProvider() - { - var workspace = new AdhocWorkspace(); - var projectId = ProjectId.CreateNewId(); - var project = workspace.AddProject(ProjectInfo.Create( - projectId, - VersionStamp.Create(), - "Fixture", - "Fixture", - LanguageNames.CSharp, - compilationOptions: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary), - parseOptions: new CSharpParseOptions(LanguageVersion.Preview, documentationMode: DocumentationMode.Parse), - metadataReferences: - [ - MetadataReference.CreateFromFile(typeof(object).Assembly.Location), - ])); - - var documentId = DocumentId.CreateNewId(projectId); - workspace.AddDocument(DocumentInfo.Create( - documentId, - "Fixture.cs", - loader: TextLoader.From(TextAndVersion.Create(SourceText.From(Source), VersionStamp.Create())))); - - var polyfillId = DocumentId.CreateNewId(projectId); - workspace.AddDocument(DocumentInfo.Create( - polyfillId, - "UnionPolyfill.cs", - loader: TextLoader.From(TextAndVersion.Create(SourceText.From(UnionPolyfillSource), VersionStamp.Create())))); - - var workspaceService = new StubWorkspaceService(workspace.CurrentSolution); - var symbolService = new SymbolExtractionService(workspaceService, NullLogger.Instance); - - return new RoslynApiMetadataProvider( - workspaceService, - symbolService, - new XmlDocParser(), - new NullHighlighter(), - new ApiReferenceOptions()); - } - - [Fact] - public async Task Enumerates_Public_Properties_Alphabetically() - { - var provider = BuildProvider(); - - var members = await provider.GetMembersAsync( - "T:Fixtures.Options", - MemberKind.Properties, - AccessFilter.Public, - MemberOrder.Alphabetical); - - members.Select(m => m.Name).ShouldBe(["BaseUrl", "DefaultLocale", "Title"]); - } - - [Fact] - public async Task Extracts_Property_Defaults_From_Initializer() - { - var provider = BuildProvider(); - - var members = await provider.GetMembersAsync( - "T:Fixtures.Options", - MemberKind.Properties, - AccessFilter.Public, - MemberOrder.Alphabetical); - - var title = members.Single(m => m.Name == "Title"); - title.DefaultValue.ShouldBe("\"Untitled\""); - - var baseUrl = members.Single(m => m.Name == "BaseUrl"); - baseUrl.DefaultValue.ShouldBe("null"); - } - - [Fact] - public async Task Formats_Nullable_Type_Display() - { - var provider = BuildProvider(); - - var members = await provider.GetMembersAsync( - "T:Fixtures.Options", - MemberKind.Properties, - AccessFilter.Public, - MemberOrder.Alphabetical); - - members.Single(m => m.Name == "BaseUrl").TypeDisplay.ShouldBe("string?"); - members.Single(m => m.Name == "Title").TypeDisplay.ShouldBe("string"); - } - - [Fact] - public async Task Parses_Summary_Xmldoc_Into_Member() - { - var provider = BuildProvider(); - - var members = await provider.GetMembersAsync( - "T:Fixtures.Options", - MemberKind.Properties, - AccessFilter.Public, - MemberOrder.Alphabetical); - - var title = members.Single(m => m.Name == "Title"); - title.Xmldoc.HasSummary.ShouldBeTrue(); - title.Xmldoc.Summary[0].ShouldBeCase().Text.ShouldBe("The site's title."); - } - - [Fact] - public async Task Enumerates_Methods_Excluding_Property_Accessors() - { - var provider = BuildProvider(); - - var methods = await provider.GetMembersAsync( - "T:Fixtures.Options", - MemberKind.Methods, - AccessFilter.Public, - MemberOrder.Alphabetical); - - methods.Select(m => m.Name).ShouldBe(["Register"]); - methods[0].TypeDisplay.ShouldBe("bool Register(string name)"); - } - - [Fact] - public async Task Returns_Empty_For_Unresolved_Type() - { - var provider = BuildProvider(); - - var members = await provider.GetMembersAsync( - "T:Does.Not.Exist", - MemberKind.Properties, - AccessFilter.Public, - MemberOrder.Alphabetical); - - members.ShouldBeEmpty(); - } - - [Fact] - public async Task Interface_Members_Are_Enumerable() - { - var provider = BuildProvider(); - - var props = await provider.GetMembersAsync( - "T:Fixtures.IExample", - MemberKind.Properties, - AccessFilter.Public, - MemberOrder.Alphabetical); - - props.Single().Name.ShouldBe("Count"); - - var methods = await provider.GetMembersAsync( - "T:Fixtures.IExample", - MemberKind.Methods, - AccessFilter.Public, - MemberOrder.Alphabetical); - - methods.Single().Name.ShouldBe("Do"); - } - - [Fact] - public async Task Required_Property_Is_Marked_Required_With_Null_Default() - { - var provider = BuildProvider(); - - var members = await provider.GetMembersAsync( - "T:Fixtures.RequiredOptions", - MemberKind.Properties, - AccessFilter.Public, - MemberOrder.Alphabetical); - - var name = members.Single(m => m.Name == "Name"); - name.IsRequired.ShouldBeTrue(); - name.DefaultValue.ShouldBeNull(); - } - - [Fact] - public async Task Auto_Property_Without_Initializer_Falls_Back_To_Clr_Default() - { - var provider = BuildProvider(); - - var members = await provider.GetMembersAsync( - "T:Fixtures.RequiredOptions", - MemberKind.Properties, - AccessFilter.Public, - MemberOrder.Alphabetical); - - members.Single(m => m.Name == "EnableFeature").DefaultValue.ShouldBe("false"); - members.Single(m => m.Name == "RetryCount").DefaultValue.ShouldBe("0"); - members.Single(m => m.Name == "Nickname").DefaultValue.ShouldBe("null"); - } - - [Fact] - public async Task Concrete_Expression_Bodied_Property_Reports_No_Default() - { - var provider = BuildProvider(); - - var members = await provider.GetMembersAsync( - "T:Fixtures.RequiredOptions", - MemberKind.Properties, - AccessFilter.Public, - MemberOrder.Alphabetical); - - members.Single(m => m.Name == "Description2").DefaultValue.ShouldBeNull(); - } - - [Fact] - public async Task Interface_Expression_Bodied_Literal_Is_Reported_As_Default() - { - var provider = BuildProvider(); - - var members = await provider.GetMembersAsync( - "T:Fixtures.IDefaults", - MemberKind.Properties, - AccessFilter.Public, - MemberOrder.Alphabetical); - - members.Single(m => m.Name == "IsDraft").DefaultValue.ShouldBe("false"); - members.Single(m => m.Name == "Priority").DefaultValue.ShouldBeNull(); - } - - [Fact] - public async Task Positional_Record_Property_Inherits_Summary_From_Param() - { - var provider = BuildProvider(); - - var members = await provider.GetMembersAsync( - "T:Fixtures.PositionalEntry", - MemberKind.Properties, - AccessFilter.Public, - MemberOrder.Alphabetical); - - var slug = members.Single(m => m.Name == "Slug"); - slug.Xmldoc.HasSummary.ShouldBeTrue(); - slug.Xmldoc.Summary[0].ShouldBeCase().Text.ShouldBe("Slug used as the {key} route segment."); - - var typeName = members.Single(m => m.Name == "TypeName"); - typeName.Xmldoc.HasSummary.ShouldBeTrue(); - typeName.Xmldoc.Summary[0].ShouldBeCase().Text.ShouldBe("Short type name without namespace."); - } - - [Fact] - public async Task Interface_Members_Include_Inherited_From_Base_Interfaces() - { - var provider = BuildProvider(); - - var all = await provider.GetMembersAsync( - "T:Fixtures.IDerivedService", - MemberKind.All, - AccessFilter.Public, - MemberOrder.Alphabetical); - - // Direct members + base interface members should both appear. - all.Select(m => m.Name).ShouldContain("Discover"); - all.Select(m => m.Name).ShouldContain("Label"); - all.Select(m => m.Name).ShouldContain("Emit"); - all.Select(m => m.Name).ShouldContain("BaseCount"); - - var emit = all.Single(m => m.Name == "Emit"); - emit.InheritedFromName.ShouldBe("IBaseEmitter"); - emit.InheritedFromUid.ShouldBe("T:Fixtures.IBaseEmitter"); - - var discover = all.Single(m => m.Name == "Discover"); - discover.InheritedFromName.ShouldBeNull(); - discover.InheritedFromUid.ShouldBeNull(); - } - - [Fact] - public async Task Interface_Methods_Filter_Includes_Inherited_Methods() - { - var provider = BuildProvider(); - - var methods = await provider.GetMembersAsync( - "T:Fixtures.IDerivedService", - MemberKind.Methods, - AccessFilter.Public, - MemberOrder.Alphabetical); - - methods.Select(m => m.Name).ShouldBe(["Discover", "Emit"]); - } - - [Fact] - public async Task Union_Cases_Are_Surfaced_For_Polyfill_Marked_Struct() - { - var provider = BuildProvider(); - - var cases = await provider.GetMembersAsync( - "T:Fixtures.UnionAlpha", - MemberKind.UnionCases, - AccessFilter.Public, - MemberOrder.Alphabetical); - - cases.Select(m => m.Name).ShouldBe(["CaseA", "CaseB"]); - cases.All(m => m.Kind == MemberKind.UnionCases).ShouldBeTrue(); - cases.Single(m => m.Name == "CaseA").Uid.ShouldBe("T:Fixtures.CaseA"); - } - - [Fact] - public async Task Union_Cases_Appear_In_All_Members() - { - var provider = BuildProvider(); - - var all = await provider.GetMembersAsync( - "T:Fixtures.UnionAlpha", - MemberKind.All, - AccessFilter.Public, - MemberOrder.Alphabetical); - - all.Where(m => m.Kind == MemberKind.UnionCases) - .Select(m => m.Name) - .ShouldBe(["CaseA", "CaseB"]); - } - - [Fact] - public async Task Param_Fallback_Does_Not_Override_Existing_Summary() - { - var provider = BuildProvider(); - - var members = await provider.GetMembersAsync( - "T:Fixtures.MixedEntry", - MemberKind.Properties, - AccessFilter.Public, - MemberOrder.Alphabetical); - - var name = members.Single(m => m.Name == "Name"); - name.Xmldoc.HasSummary.ShouldBeTrue(); - name.Xmldoc.Summary[0].ShouldBeCase().Text.ShouldBe("The receiver name."); - - var description = members.Single(m => m.Name == "Description"); - description.Xmldoc.HasSummary.ShouldBeTrue(); - description.Xmldoc.Summary[0].ShouldBeCase().Text.ShouldBe("Has its own summary already."); - - var untouched = members.Single(m => m.Name == "Untouched"); - untouched.Xmldoc.HasSummary.ShouldBeFalse(); - } - - private sealed class StubWorkspaceService(Solution solution) : ISolutionWorkspaceService - { - public Task LoadSolutionAsync(string solutionPath) => Task.FromResult(solution); - - public Task> GetProjectsAsync(Func? filter = null) - => Task.FromResult(filter is null ? solution.Projects : solution.Projects.Where(filter)); - - public Task GetCompilationAsync(Project project) => project.GetCompilationAsync(); - - public void InvalidateSolution() { } - - public void UpdateDocument(string filePath) { } - - public void Dispose() { } - } - - private sealed class NullHighlighter : ICodeHighlighter - { - public IReadOnlySet SupportedLanguages { get; } = new HashSet { "csharp" }; - public int Priority => 0; - public string Highlight(string code, string language) => code; - } -} \ No newline at end of file diff --git a/tests/Pennington.Roslyn.Tests/Documentation/XmlDocHtmlRendererTests.cs b/tests/Pennington.Roslyn.Tests/Documentation/XmlDocHtmlRendererTests.cs deleted file mode 100644 index 9c4deb40..00000000 --- a/tests/Pennington.Roslyn.Tests/Documentation/XmlDocHtmlRendererTests.cs +++ /dev/null @@ -1,144 +0,0 @@ -namespace Pennington.Roslyn.Tests.Documentation; - -using System.Collections.Immutable; -using Pennington.ApiMetadata; - -public sealed class XmlDocHtmlRendererTests -{ - private readonly XmlDocHtmlRenderer _renderer = new(); - private readonly XmlDocParser _parser = new(); - - [Fact] - public void Inline_Renders_Plain_Text_Html_Encoded() - { - var nodes = ImmutableArray.Create(new XmlDocNode(new TextNode("3 < 5 & true"))); - - _renderer.RenderInlineHtml(nodes).ShouldBe("3 < 5 & true"); - } - - [Fact] - public void Inline_Renders_InlineCode() - { - var nodes = ImmutableArray.Create(new XmlDocNode(new InlineCodeNode("AddPennington"))); - - _renderer.RenderInlineHtml(nodes).ShouldBe("AddPennington"); - } - - [Fact] - public void Inline_Renders_Cref_As_Code_Span() - { - var nodes = ImmutableArray.Create(new XmlDocNode(new CrefNode("T:Pennington.Infrastructure.PenningtonOptions", null))); - - _renderer.RenderInlineHtml(nodes).ShouldBe("PenningtonOptions"); - } - - [Fact] - public void Inline_Uses_Cref_DisplayText_When_Present() - { - var nodes = ImmutableArray.Create(new XmlDocNode(new CrefNode("T:System.String", "a string"))); - - _renderer.RenderInlineHtml(nodes).ShouldBe("a string"); - } - - [Fact] - public void Inline_Strips_Method_Generic_Arity_From_Cref() - { - var nodes = ImmutableArray.Create(new XmlDocNode(new CrefNode("M:Ns.Type.AddMarkdownContent``1", null))); - - _renderer.RenderInlineHtml(nodes).ShouldBe("AddMarkdownContent"); - } - - [Fact] - public void Inline_Strips_Type_Generic_Arity_From_Cref() - { - var nodes = ImmutableArray.Create(new XmlDocNode(new CrefNode("T:System.Collections.Generic.List`1", null))); - - _renderer.RenderInlineHtml(nodes).ShouldBe("List"); - } - - [Fact] - public void Inline_Preserves_Whitespace_Around_Cref() - { - var parsed = _parser.Parse("""See for details."""); - - _renderer.RenderInlineHtml(parsed.Summary).ShouldBe("See String for details."); - } - - [Fact] - public void Block_Wraps_Text_In_Paragraph() - { - var parsed = _parser.Parse("""Hello world."""); - - _renderer.RenderHtml(parsed.Summary).ShouldBe("

    Hello world.

    "); - } - - [Fact] - public void Block_Separates_Paragraphs() - { - var parsed = _parser.Parse(""" - - - First. - Second. - - - """); - - _renderer.RenderHtml(parsed.Remarks).ShouldBe("

    First.

    Second.

    "); - } - - [Fact] - public void Block_Renders_Bullet_List() - { - var parsed = _parser.Parse(""" - - - - alpha - beta - - - - """); - - _renderer.RenderHtml(parsed.Summary).ShouldBe("
    • alpha
    • beta
    "); - } - - [Fact] - public void Block_Renders_Number_List_As_Ol() - { - var parsed = _parser.Parse(""" - - - - one - - - - """); - - _renderer.RenderHtml(parsed.Summary).ShouldBe("
    1. one
    "); - } - - [Fact] - public void Block_Renders_Code_Block_Outside_Paragraph() - { - var parsed = _parser.Parse(""" - - - var x = 1; - - - """); - - _renderer.RenderHtml(parsed.Remarks).ShouldBe("
    var x = 1;
    "); - } - - [Fact] - public void Inline_Does_Not_Wrap_In_Paragraph() - { - var parsed = _parser.Parse("""Short sentence."""); - - _renderer.RenderInlineHtml(parsed.Summary).ShouldBe("Short sentence."); - } -} \ No newline at end of file diff --git a/tests/Pennington.Roslyn.Tests/Documentation/XmlDocNodeAssertions.cs b/tests/Pennington.Roslyn.Tests/Documentation/XmlDocNodeAssertions.cs deleted file mode 100644 index 8f2e8577..00000000 --- a/tests/Pennington.Roslyn.Tests/Documentation/XmlDocNodeAssertions.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace Pennington.Roslyn.Tests.Documentation; - -using System.Collections.Generic; -using System.Linq; -using Pennington.ApiMetadata; - -internal static class XmlDocNodeAssertions -{ - public static T ShouldBeCase(this XmlDocNode node) where T : class - { - if (node is T match) - { - return match; - } - - throw new ShouldAssertException( - $"Expected XmlDocNode case {typeof(T).Name} but was a different case."); - } - - public static IEnumerable Cases(this IEnumerable nodes) where T : class - => nodes.Select(n => n is T t ? t : null).Where(t => t is not null).Select(t => t!); -} \ No newline at end of file diff --git a/tests/Pennington.Roslyn.Tests/Documentation/XmlDocParserTests.cs b/tests/Pennington.Roslyn.Tests/Documentation/XmlDocParserTests.cs deleted file mode 100644 index 5fbe8f3a..00000000 --- a/tests/Pennington.Roslyn.Tests/Documentation/XmlDocParserTests.cs +++ /dev/null @@ -1,190 +0,0 @@ -namespace Pennington.Roslyn.Tests.Documentation; - -using Pennington.ApiMetadata; - -public sealed class XmlDocParserTests -{ - private readonly XmlDocParser _parser = new(); - - [Fact] - public void Null_Or_Empty_Returns_Empty() - { - _parser.Parse(null).ShouldBe(ParsedXmlDoc.Empty); - _parser.Parse("").ShouldBe(ParsedXmlDoc.Empty); - _parser.Parse(" ").ShouldBe(ParsedXmlDoc.Empty); - } - - [Fact] - public void Parses_Summary_Text() - { - var xml = """ - - Hello world. - - """; - - var parsed = _parser.Parse(xml); - - parsed.Summary.Length.ShouldBe(1); - parsed.Summary[0].ShouldBeCase().Text.ShouldBe("Hello world."); - } - - [Fact] - public void Parses_Inline_Code_And_Text_In_Summary() - { - var xml = """ - - Use AddPennington to register. - - """; - - var parsed = _parser.Parse(xml); - - parsed.Summary.Length.ShouldBe(3); - parsed.Summary[0].ShouldBeCase().Text.ShouldBe("Use "); - parsed.Summary[1].ShouldBeCase().Text.ShouldBe("AddPennington"); - parsed.Summary[2].ShouldBeCase().Text.ShouldBe(" to register."); - } - - [Fact] - public void Preserves_Whitespace_Around_Cref_In_Mixed_Content() - { - var xml = """ - - See for details. - - """; - - var parsed = _parser.Parse(xml); - - parsed.Summary.Length.ShouldBe(3); - parsed.Summary[0].ShouldBeCase().Text.ShouldBe("See "); - parsed.Summary[1].ShouldBeCase().CrefId.ShouldBe("T:System.String"); - parsed.Summary[2].ShouldBeCase().Text.ShouldBe(" for details."); - } - - [Fact] - public void Parses_Cref_As_CrefNode() - { - var xml = """ - - See for details. - - """; - - var parsed = _parser.Parse(xml); - - var cref = parsed.Summary.Cases().ShouldHaveSingleItem(); - cref.CrefId.ShouldBe("T:System.String"); - } - - [Fact] - public void Parses_See_Langword_As_InlineCode() - { - var xml = """ - - by default. - - """; - - var parsed = _parser.Parse(xml); - - parsed.Summary[0].ShouldBeCase().Text.ShouldBe("null"); - } - - [Fact] - public void Parses_Params_By_Name() - { - var xml = """ - - The thing's name. - How many. - - """; - - var parsed = _parser.Parse(xml); - - parsed.Params.Count.ShouldBe(2); - parsed.Params["name"][0].ShouldBeCase().Text.ShouldBe("The thing's name."); - parsed.Params["count"][0].ShouldBeCase().Text.ShouldBe("How many."); - } - - [Fact] - public void Parses_Returns_And_Remarks() - { - var xml = """ - - The widget. - Called from the widget factory. - - """; - - var parsed = _parser.Parse(xml); - - parsed.Returns[0].ShouldBeCase().Text.ShouldBe("The widget."); - parsed.Remarks[0].ShouldBeCase().Text.ShouldBe("Called from the widget factory."); - } - - [Fact] - public void Parses_Para_Children() - { - var xml = """ - - - First paragraph. - Second paragraph. - - - """; - - var parsed = _parser.Parse(xml); - - var paras = parsed.Remarks.Cases().ToList(); - paras.Count.ShouldBe(2); - paras[0].Children[0].ShouldBeCase().Text.ShouldBe("First paragraph."); - paras[1].Children[0].ShouldBeCase().Text.ShouldBe("Second paragraph."); - } - - [Fact] - public void Parses_SeeAlso_Crefs() - { - var xml = """ - - - - - """; - - var parsed = _parser.Parse(xml); - - parsed.SeeAlso.ShouldBe(["T:Bar", "T:Baz"]); - } - - [Fact] - public void Parses_Bullet_List() - { - var xml = """ - - - - One - Two - - - - """; - - var parsed = _parser.Parse(xml); - - var list = parsed.Summary.Cases().ShouldHaveSingleItem(); - list.Kind.ShouldBe("bullet"); - list.Items.Length.ShouldBe(2); - list.Items[0].Description[0].ShouldBeCase().Text.ShouldBe("One"); - } - - [Fact] - public void Malformed_Xml_Returns_Empty() - { - _parser.Parse("oops"); - } - - [Fact] - public void Handles_Empty_Code() - { - var result = _highlighter.Highlight("", "csharp"); - - result.ShouldNotBeNull(); - result.ShouldStartWith("
     _syntaxHighlighter.Dispose();
    -}
    \ No newline at end of file
    diff --git a/tests/Pennington.Roslyn.Tests/Pennington.Roslyn.Tests.csproj b/tests/Pennington.Roslyn.Tests/Pennington.Roslyn.Tests.csproj
    deleted file mode 100644
    index 1acecd38..00000000
    --- a/tests/Pennington.Roslyn.Tests/Pennington.Roslyn.Tests.csproj
    +++ /dev/null
    @@ -1,19 +0,0 @@
    -
    -  
    -    net11.0
    -    preview
    -    enable
    -    enable
    -    false
    -    true
    -  
    -  
    -    
    -    
    -    
    -    
    -  
    -  
    -    
    -  
    -
    diff --git a/tests/Pennington.Roslyn.Tests/Preprocessing/RoslynCodeBlockPreprocessorTests.cs b/tests/Pennington.Roslyn.Tests/Preprocessing/RoslynCodeBlockPreprocessorTests.cs
    deleted file mode 100644
    index 95fd8431..00000000
    --- a/tests/Pennington.Roslyn.Tests/Preprocessing/RoslynCodeBlockPreprocessorTests.cs
    +++ /dev/null
    @@ -1,408 +0,0 @@
    -namespace Pennington.Roslyn.Tests.Preprocessing;
    -
    -using Markdown.Extensions;
    -using Microsoft.Extensions.DependencyInjection;
    -using Pennington.Highlighting;
    -using Pennington.Roslyn.Highlighting;
    -using Pennington.Roslyn.Preprocessing;
    -
    -public sealed class RoslynCodeBlockPreprocessorTests
    -{
    -    [Fact]
    -    public void ParseLanguageId_Extracts_XmlDocId()
    -    {
    -        var (baseLanguage, modifier) = RoslynCodeBlockPreprocessor.ParseLanguageId("csharp:xmldocid");
    -
    -        baseLanguage.ShouldBe("csharp");
    -        modifier.ShouldBe("xmldocid");
    -    }
    -
    -    [Fact]
    -    public void ParseLanguageId_Extracts_Path()
    -    {
    -        var (baseLanguage, modifier) = RoslynCodeBlockPreprocessor.ParseLanguageId("csharp:path");
    -
    -        baseLanguage.ShouldBe("csharp");
    -        modifier.ShouldBe("path");
    -    }
    -
    -    [Fact]
    -    public void ParseLanguageId_Extracts_XmlDocIdDiff()
    -    {
    -        var (baseLanguage, modifier) = RoslynCodeBlockPreprocessor.ParseLanguageId("csharp:xmldocid-diff");
    -
    -        baseLanguage.ShouldBe("csharp");
    -        modifier.ShouldBe("xmldocid-diff");
    -    }
    -
    -    [Fact]
    -    public void ParseLanguageId_Extracts_Bodyonly()
    -    {
    -        var (baseLanguage, modifier) = RoslynCodeBlockPreprocessor.ParseLanguageId("csharp:xmldocid,bodyonly");
    -
    -        baseLanguage.ShouldBe("csharp");
    -        modifier.ShouldBe("xmldocid,bodyonly");
    -    }
    -
    -    [Fact]
    -    public void ParseLanguageId_Extracts_Usings()
    -    {
    -        var (baseLanguage, modifier) = RoslynCodeBlockPreprocessor.ParseLanguageId("csharp:xmldocid,usings");
    -
    -        baseLanguage.ShouldBe("csharp");
    -        modifier.ShouldBe("xmldocid,usings");
    -    }
    -
    -    [Fact]
    -    public void ParseLanguageId_Extracts_Bodyonly_And_Usings()
    -    {
    -        var (baseLanguage, modifier) = RoslynCodeBlockPreprocessor.ParseLanguageId("csharp:xmldocid,bodyonly,usings");
    -
    -        baseLanguage.ShouldBe("csharp");
    -        modifier.ShouldBe("xmldocid,bodyonly,usings");
    -    }
    -
    -    [Fact]
    -    public void ParseLanguageId_Normalises_Flag_Order_For_Usings_Then_Bodyonly()
    -    {
    -        var (baseLanguage, modifier) = RoslynCodeBlockPreprocessor.ParseLanguageId("csharp:xmldocid,usings,bodyonly");
    -
    -        baseLanguage.ShouldBe("csharp");
    -        modifier.ShouldBe("xmldocid,bodyonly,usings");
    -    }
    -
    -    [Fact]
    -    public void ParseLanguageId_Usings_Is_Case_Insensitive()
    -    {
    -        var (baseLanguage, modifier) = RoslynCodeBlockPreprocessor.ParseLanguageId("csharp:xmldocid,Usings");
    -
    -        baseLanguage.ShouldBe("csharp");
    -        modifier.ShouldBe("xmldocid,usings");
    -    }
    -
    -    [Fact]
    -    public void ParseLanguageId_Extracts_XmlDocIdDiff_Bodyonly()
    -    {
    -        var (baseLanguage, modifier) = RoslynCodeBlockPreprocessor.ParseLanguageId("csharp:xmldocid-diff,bodyonly");
    -
    -        baseLanguage.ShouldBe("csharp");
    -        modifier.ShouldBe("xmldocid-diff,bodyonly");
    -    }
    -
    -    [Fact]
    -    public void ParseLanguageId_Returns_Null_Modifier_For_No_Modifier()
    -    {
    -        var (baseLanguage, modifier) = RoslynCodeBlockPreprocessor.ParseLanguageId("csharp");
    -
    -        baseLanguage.ShouldBe("csharp");
    -        modifier.ShouldBeNull();
    -    }
    -
    -    [Fact]
    -    public void ParseLanguageId_Returns_Null_Modifier_For_Plain_Language()
    -    {
    -        var (baseLanguage, modifier) = RoslynCodeBlockPreprocessor.ParseLanguageId("python");
    -
    -        baseLanguage.ShouldBe("python");
    -        modifier.ShouldBeNull();
    -    }
    -
    -    [Fact]
    -    public void ParseLanguageId_Is_Case_Insensitive()
    -    {
    -        var (baseLanguage, modifier) = RoslynCodeBlockPreprocessor.ParseLanguageId("csharp:XmlDocId");
    -
    -        baseLanguage.ShouldBe("csharp");
    -        modifier.ShouldBe("xmldocid");
    -    }
    -
    -    [Fact]
    -    public void ParseLanguageId_Trims_Whitespace()
    -    {
    -        var (baseLanguage, modifier) = RoslynCodeBlockPreprocessor.ParseLanguageId("  csharp:xmldocid  ");
    -
    -        baseLanguage.ShouldBe("csharp");
    -        modifier.ShouldBe("xmldocid");
    -    }
    -
    -    [Fact]
    -    public void ParseLanguageId_Vb_Path()
    -    {
    -        var (baseLanguage, modifier) = RoslynCodeBlockPreprocessor.ParseLanguageId("vb:path");
    -
    -        baseLanguage.ShouldBe("vb");
    -        modifier.ShouldBe("path");
    -    }
    -
    -    [Fact]
    -    public void TryProcess_Returns_Null_For_Unrecognized_Language()
    -    {
    -        var preprocessor = CreatePreprocessor();
    -
    -        var result = preprocessor.TryProcess("some code", "python");
    -
    -        result.ShouldBeNull();
    -    }
    -
    -    [Fact]
    -    public void TryProcess_Returns_Null_For_Plain_CSharp()
    -    {
    -        var preprocessor = CreatePreprocessor();
    -
    -        var result = preprocessor.TryProcess("var x = 42;", "csharp");
    -
    -        result.ShouldBeNull();
    -    }
    -
    -    [Fact]
    -    public void Priority_Is_100()
    -    {
    -        var preprocessor = CreatePreprocessor();
    -
    -        preprocessor.Priority.ShouldBe(100);
    -    }
    -
    -    [Fact]
    -    public void ProcessPath_Accepts_Bare_Filename_SolutionPath()
    -    {
    -        // Regression: ProcessPath used to call Path.GetDirectoryName directly on
    -        // SolutionPath. A bare filename (no directory component) returns empty,
    -        // which triggered a spurious "Solution directory not found" error — even
    -        // though the rest of Pennington resolves SolutionPath against the process
    -        // CWD. See postmortem-BeyondRoslynExample.md.
    -        var preprocessor = new RoslynCodeBlockPreprocessor(
    -            new StubSymbolExtractionService(),
    -            new SyntaxHighlighter(),
    -            CreateHighlightingService(),
    -            new RoslynOptions { SolutionPath = "bare-filename.slnx" },
    -            new NullHttpContextAccessor());
    -
    -        var result = preprocessor.TryProcess("nonexistent-file-for-test.cs", "csharp:path");
    -
    -        result.ShouldNotBeNull();
    -        result.HighlightedHtml.ShouldNotContain("Solution directory not found");
    -        // File doesn't exist — we expect a clean "File not found" error, proving
    -        // the preprocessor got past the directory-resolution step.
    -        result.HighlightedHtml.ShouldContain("File not found");
    -    }
    -
    -    [Fact]
    -    public void ProcessPath_Resolves_File_Relative_To_SolutionPath_Directory()
    -    {
    -        // End-to-end: a :path fence body resolves relative to the SolutionPath's
    -        // directory. This covers the happy path for the bare-filename fix.
    -        var tempDir = Path.Combine(Path.GetTempPath(), $"penn-roslyn-{Guid.NewGuid():N}");
    -        Directory.CreateDirectory(tempDir);
    -        try
    -        {
    -            var solutionFile = Path.Combine(tempDir, "fake.slnx");
    -            File.WriteAllText(solutionFile, "");
    -            var contentFile = Path.Combine(tempDir, "sample.cs");
    -            File.WriteAllText(contentFile, "var sentinel = 42;");
    -
    -            var preprocessor = new RoslynCodeBlockPreprocessor(
    -                new StubSymbolExtractionService(),
    -                new SyntaxHighlighter(),
    -                CreateHighlightingService(),
    -                new RoslynOptions { SolutionPath = solutionFile },
    -                new NullHttpContextAccessor());
    -
    -            var result = preprocessor.TryProcess("sample.cs", "csharp:path");
    -
    -            result.ShouldNotBeNull();
    -            result.HighlightedHtml.ShouldNotContain("File not found");
    -            result.HighlightedHtml.ShouldNotContain("Solution directory not found");
    -            result.HighlightedHtml.ShouldContain("sentinel");
    -        }
    -        finally
    -        {
    -            Directory.Delete(tempDir, recursive: true);
    -        }
    -    }
    -
    -    [Fact]
    -    public void TryProcess_Rejects_XmlDocId_For_Non_CSharp_Language()
    -    {
    -        var preprocessor = CreatePreprocessor();
    -
    -        // A :xmldocid fence with a non-C#/VB base language is a misuse — the
    -        // extractor pulls C# expression text, not the string value, so wrapping
    -        // markdown as a raw-string expression leaks `"""` delimiters into the
    -        // rendered block. The preprocessor must refuse and pass through.
    -        var result = preprocessor.TryProcess("T:Whatever.Type", "markdown:xmldocid");
    -
    -        result.ShouldBeNull();
    -    }
    -
    -    [Fact]
    -    public void TryProcess_Accepts_XmlDocId_For_CSharp_Language()
    -    {
    -        var preprocessor = CreatePreprocessor();
    -
    -        var result = preprocessor.TryProcess("T:Whatever.Type", "csharp:xmldocid");
    -
    -        result.ShouldNotBeNull();
    -    }
    -
    -    [Fact]
    -    public void ProcessPath_Uses_HighlightingService_For_Markdown()
    -    {
    -        // For non-C#/VB :path fences, highlighting should dispatch through the
    -        // HighlightingService so TextMate (or another registered highlighter)
    -        // picks up the language — not the Roslyn C# classifier.
    -        var tempDir = Path.Combine(Path.GetTempPath(), $"penn-roslyn-md-{Guid.NewGuid():N}");
    -        Directory.CreateDirectory(tempDir);
    -        try
    -        {
    -            var solutionFile = Path.Combine(tempDir, "fake.slnx");
    -            File.WriteAllText(solutionFile, "");
    -            var contentFile = Path.Combine(tempDir, "sample.md");
    -            File.WriteAllText(contentFile, "# hello markdown");
    -
    -            var captured = new CapturingHighlighter();
    -            var highlightingService = new HighlightingService([captured]);
    -
    -            var preprocessor = new RoslynCodeBlockPreprocessor(
    -                new StubSymbolExtractionService(),
    -                new SyntaxHighlighter(),
    -                highlightingService,
    -                new RoslynOptions { SolutionPath = solutionFile },
    -                new NullHttpContextAccessor());
    -
    -            var result = preprocessor.TryProcess("sample.md", "markdown:path");
    -
    -            result.ShouldNotBeNull();
    -            captured.LastLanguage.ShouldBe("markdown");
    -            captured.LastCode.ShouldBe("# hello markdown");
    -        }
    -        finally
    -        {
    -            Directory.Delete(tempDir, recursive: true);
    -        }
    -    }
    -
    -    [Fact]
    -    public void AddPenningtonRoslyn_Registers_ICodeHighlighter()
    -    {
    -        var services = new ServiceCollection();
    -        services.AddLogging();
    -        services.AddPenningtonRoslyn();
    -
    -        var provider = services.BuildServiceProvider();
    -        var highlighter = provider.GetService();
    -
    -        highlighter.ShouldNotBeNull();
    -        highlighter.ShouldBeOfType();
    -    }
    -
    -    [Fact]
    -    public void AddPenningtonRoslyn_Registers_SyntaxHighlighter_As_Singleton()
    -    {
    -        var services = new ServiceCollection();
    -        services.AddLogging();
    -        services.AddPenningtonRoslyn();
    -
    -        var provider = services.BuildServiceProvider();
    -        var h1 = provider.GetService();
    -        var h2 = provider.GetService();
    -
    -        h1.ShouldNotBeNull();
    -        h1.ShouldBeSameAs(h2);
    -    }
    -
    -    [Fact]
    -    public void AddPenningtonRoslyn_Without_SolutionPath_Does_Not_Register_Preprocessor()
    -    {
    -        var services = new ServiceCollection();
    -        services.AddLogging();
    -        services.AddPenningtonRoslyn();
    -
    -        var provider = services.BuildServiceProvider();
    -        var preprocessor = provider.GetService();
    -
    -        preprocessor.ShouldBeNull();
    -    }
    -
    -    [Fact]
    -    public void AddPenningtonRoslyn_With_SolutionPath_Registers_ICodeBlockPreprocessor()
    -    {
    -        var services = new ServiceCollection();
    -        services.AddLogging();
    -
    -        // Register IFileWatcher which SolutionWorkspaceService requires
    -        services.AddSingleton();
    -
    -        services.AddPenningtonRoslyn(opts => opts.SolutionPath = @"C:\fake\solution.sln");
    -
    -        var descriptors = services.Where(d => d.ServiceType == typeof(ICodeBlockPreprocessor)).ToList();
    -        descriptors.Count.ShouldBe(1);
    -    }
    -
    -    private static RoslynCodeBlockPreprocessor CreatePreprocessor()
    -    {
    -        return new RoslynCodeBlockPreprocessor(
    -            new StubSymbolExtractionService(),
    -            new SyntaxHighlighter(),
    -            CreateHighlightingService(),
    -            new RoslynOptions(),
    -            new NullHttpContextAccessor());
    -    }
    -
    -    private static HighlightingService CreateHighlightingService() =>
    -        new([new PlainTextHighlighter()]);
    -
    -    private sealed class NullHttpContextAccessor : Microsoft.AspNetCore.Http.IHttpContextAccessor
    -    {
    -        public Microsoft.AspNetCore.Http.HttpContext? HttpContext { get; set; }
    -    }
    -
    -    /// Stub that returns empty for any extraction call.
    -    private sealed class StubSymbolExtractionService : Roslyn.Symbols.ISymbolExtractionService
    -    {
    -        public Task> ExtractSymbolsAsync(
    -            IEnumerable projects)
    -            => Task.FromResult>(
    -                new Dictionary());
    -
    -        public Task FindSymbolAsync(string xmlDocId)
    -            => Task.FromResult(null);
    -
    -        public Task ExtractCodeFragmentAsync(string xmlDocId, bool bodyOnly = false, bool includeLeadingTrivia = true)
    -            => Task.FromResult(string.Empty);
    -
    -        public Task ExtractCodeFragmentWithUsingsAsync(string xmlDocId, bool bodyOnly = false, bool includeLeadingTrivia = true)
    -            => Task.FromResult(new Roslyn.Symbols.CodeFragmentResult(string.Empty, System.Collections.Immutable.ImmutableList.Empty));
    -
    -        public Task ExtractDeclarationSignatureAsync(string xmlDocId)
    -            => Task.FromResult(string.Empty);
    -
    -        public void ClearCache() { }
    -
    -        public Task WarmupAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
    -    }
    -
    -    /// Records what the preprocessor forwards to the highlighting pipeline.
    -    private sealed class CapturingHighlighter : ICodeHighlighter
    -    {
    -        public IReadOnlySet SupportedLanguages { get; } = new HashSet { "*" };
    -        public int Priority => 200;
    -        public string? LastLanguage { get; private set; }
    -        public string? LastCode { get; private set; }
    -
    -        public string Highlight(string code, string language)
    -        {
    -            LastCode = code;
    -            LastLanguage = language;
    -            return $"
    {code}
    "; - } - } - - /// Stub file watcher for DI registration tests. - private sealed class StubFileWatcher : Infrastructure.IFileWatcher - { - public void AddPathWatch(string path, string filePattern, Action onFileChanged, bool includeSubdirectories = true) { } - public void SubscribeToChanges(Action onUpdate) { } - public void SubscribeToChanges(Action onUpdate) { } - public void Dispose() { } - } -} \ No newline at end of file diff --git a/tests/Pennington.Roslyn.Tests/Symbols/CodeFragmentExtractorTests.cs b/tests/Pennington.Roslyn.Tests/Symbols/CodeFragmentExtractorTests.cs deleted file mode 100644 index 8618f44e..00000000 --- a/tests/Pennington.Roslyn.Tests/Symbols/CodeFragmentExtractorTests.cs +++ /dev/null @@ -1,197 +0,0 @@ -namespace Pennington.Roslyn.Tests.Symbols; - -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Pennington.Roslyn.Symbols; -using Pennington.Roslyn.Utilities; - -public sealed class CodeFragmentExtractorTests -{ - [Fact] - public async Task Extracts_Full_Method_Declaration() - { - var ct = TestContext.Current.CancellationToken; - var code = "public class Foo { public void Bar() { Console.WriteLine(); } }"; - var tree = CSharpSyntaxTree.ParseText(code, cancellationToken: ct); - var root = await tree.GetRootAsync(ct); - var method = root.DescendantNodes().OfType().First(); - - var result = await CodeFragmentExtractor.ExtractCodeFragmentAsync(method, code, bodyOnly: false); - - result.ShouldContain("public void Bar()"); - result.ShouldContain("Console.WriteLine()"); - } - - [Fact] - public async Task Extracts_Method_Body_Only() - { - var ct = TestContext.Current.CancellationToken; - var code = "public class Foo { public void Bar() { Console.WriteLine(); } }"; - var tree = CSharpSyntaxTree.ParseText(code, cancellationToken: ct); - var root = await tree.GetRootAsync(ct); - var method = root.DescendantNodes().OfType().First(); - - var result = await CodeFragmentExtractor.ExtractCodeFragmentAsync(method, code, bodyOnly: true); - - result.ShouldContain("Console.WriteLine()"); - result.ShouldNotContain("public void Bar()"); - } - - [Fact] - public async Task Extracts_Expression_Body() - { - var ct = TestContext.Current.CancellationToken; - var code = "public class Foo { public int Bar() => 42; }"; - var tree = CSharpSyntaxTree.ParseText(code, cancellationToken: ct); - var root = await tree.GetRootAsync(ct); - var method = root.DescendantNodes().OfType().First(); - - var result = await CodeFragmentExtractor.ExtractCodeFragmentAsync(method, code, bodyOnly: true); - - result.ShouldBe("42"); - } - - [Fact] - public async Task Extracts_Class_Body_Only() - { - var ct = TestContext.Current.CancellationToken; - var code = "public class Foo { public int X { get; } }"; - var tree = CSharpSyntaxTree.ParseText(code, cancellationToken: ct); - var root = await tree.GetRootAsync(ct); - var classDecl = root.DescendantNodes().OfType().First(); - - var result = await CodeFragmentExtractor.ExtractCodeFragmentAsync(classDecl, code, bodyOnly: true); - - result.ShouldContain("public int X { get; }"); - result.ShouldNotContain("public class Foo"); - } - - [Fact] - public async Task Full_Declaration_Returns_Complete_Text() - { - var ct = TestContext.Current.CancellationToken; - var code = "public class Foo { public int X { get; } }"; - var tree = CSharpSyntaxTree.ParseText(code, cancellationToken: ct); - var root = await tree.GetRootAsync(ct); - var classDecl = root.DescendantNodes().OfType().First(); - - var result = await CodeFragmentExtractor.ExtractCodeFragmentAsync(classDecl, code, bodyOnly: false); - - result.ShouldContain("public class Foo"); - result.ShouldContain("public int X { get; }"); - } - - [Fact] - public async Task Declaration_Strips_Leading_Xmldoc_When_IncludeLeadingTrivia_False() - { - var ct = TestContext.Current.CancellationToken; - var code = """ - public class Foo - { - /// Do the thing. - public void Bar() { } - } - """; - var tree = CSharpSyntaxTree.ParseText(code, cancellationToken: ct); - var root = await tree.GetRootAsync(ct); - var method = root.DescendantNodes().OfType().First(); - - var result = await CodeFragmentExtractor.ExtractCodeFragmentAsync(method, code, bodyOnly: false, includeLeadingTrivia: false); - - result.ShouldNotContain(""); - result.ShouldContain("public void Bar()"); - } - - [Fact] - public async Task Declaration_Keeps_Leading_Xmldoc_By_Default() - { - var ct = TestContext.Current.CancellationToken; - var code = """ - public class Foo - { - /// Do the thing. - public void Bar() { } - } - """; - var tree = CSharpSyntaxTree.ParseText(code, cancellationToken: ct); - var root = await tree.GetRootAsync(ct); - var method = root.DescendantNodes().OfType().First(); - - var result = await CodeFragmentExtractor.ExtractCodeFragmentAsync(method, code, bodyOnly: false); - - result.ShouldContain("Do the thing."); - result.ShouldContain("public void Bar()"); - } - - [Fact] - public async Task BodyOnly_Method_DedentsCleanly() - { - var ct = TestContext.Current.CancellationToken; - var code = """ - public class Foo - { - public void Bar() - { - Console.WriteLine(); - DoMore(); - } - } - """; - var tree = CSharpSyntaxTree.ParseText(code, cancellationToken: ct); - var root = await tree.GetRootAsync(ct); - var method = root.DescendantNodes().OfType().First(); - - var result = await CodeFragmentExtractor.ExtractCodeFragmentAsync(method, code, bodyOnly: true); - var normalized = TextFormatter.NormalizeIndents(result); - - normalized.ShouldBe("Console.WriteLine();\nDoMore();"); - } - - [Fact] - public async Task Declaration_WithoutLeadingTrivia_DedentsCleanly() - { - var ct = TestContext.Current.CancellationToken; - var code = """ - public class Foo - { - /// Do. - public void Bar() - { - Console.WriteLine(); - } - } - """; - var tree = CSharpSyntaxTree.ParseText(code, cancellationToken: ct); - var root = await tree.GetRootAsync(ct); - var method = root.DescendantNodes().OfType().First(); - - var result = await CodeFragmentExtractor.ExtractCodeFragmentAsync(method, code, bodyOnly: false, includeLeadingTrivia: false); - var normalized = TextFormatter.NormalizeIndents(result); - - normalized.ShouldBe("public void Bar()\n{\n Console.WriteLine();\n}"); - } - - [Fact] - public async Task BodyOnly_TypeBraceContent_DedentsCleanly() - { - var ct = TestContext.Current.CancellationToken; - var code = """ - namespace Foo - { - public class Bar - { - public int X { get; } - public int Y { get; } - } - } - """; - var tree = CSharpSyntaxTree.ParseText(code, cancellationToken: ct); - var root = await tree.GetRootAsync(ct); - var classDecl = root.DescendantNodes().OfType().First(); - - var result = await CodeFragmentExtractor.ExtractCodeFragmentAsync(classDecl, code, bodyOnly: true); - var normalized = TextFormatter.NormalizeIndents(result); - - normalized.ShouldBe("public int X { get; }\npublic int Y { get; }"); - } -} \ No newline at end of file diff --git a/tests/Pennington.Roslyn.Tests/Symbols/RequiredUsingsAnalyzerTests.cs b/tests/Pennington.Roslyn.Tests/Symbols/RequiredUsingsAnalyzerTests.cs deleted file mode 100644 index 85327577..00000000 --- a/tests/Pennington.Roslyn.Tests/Symbols/RequiredUsingsAnalyzerTests.cs +++ /dev/null @@ -1,236 +0,0 @@ -namespace Pennington.Roslyn.Tests.Symbols; - -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Pennington.Roslyn.Symbols; - -public sealed class RequiredUsingsAnalyzerTests -{ - [Fact] - public async Task Includes_Using_For_Type_Referenced_In_Body() - { - var (root, model) = await CompileAsync(""" - using System.Text; - using System.IO; - - namespace Sample; - - public static class Demo - { - public static void Build() - { - var sb = new StringBuilder(); - sb.Append("hi"); - } - } - """); - var method = root.DescendantNodes().OfType().Single(); - - var result = AnalyzeAsStrings(method, model, root); - - result.ShouldContain("using System.Text;"); - result.ShouldNotContain("using System.IO;"); - } - - [Fact] - public async Task Includes_Using_For_Extension_Method() - { - var (root, model) = await CompileAsync(""" - using System.Collections.Generic; - using System.Linq; - - namespace Sample; - - public static class Demo - { - public static int Count(List xs) => xs.Where(x => x > 0).Count(); - } - """); - var method = root.DescendantNodes().OfType().Single(); - - var result = AnalyzeAsStrings(method, model, root); - - result.ShouldContain("using System.Linq;"); - } - - [Fact] - public async Task Includes_Using_Static_Only_When_Member_Is_Unqualified() - { - var (root, model) = await CompileAsync(""" - using static System.Math; - - namespace Sample; - - public static class Demo - { - public static double Hypot(double x, double y) => Sqrt(x * x + y * y); - } - """); - var method = root.DescendantNodes().OfType().Single(); - - var result = AnalyzeAsStrings(method, model, root); - - result.ShouldContain("using static System.Math;"); - } - - [Fact] - public async Task Excludes_Using_Static_When_Member_Is_Qualified() - { - var (root, model) = await CompileAsync(""" - using static System.Math; - - namespace Sample; - - public static class Demo - { - public static double Hypot(double x, double y) => System.Math.Sqrt(x * x + y * y); - } - """); - var method = root.DescendantNodes().OfType().Single(); - - var result = AnalyzeAsStrings(method, model, root); - - result.ShouldNotContain("using static System.Math;"); - } - - [Fact] - public async Task Includes_Alias_Only_When_Referenced() - { - var (root, model) = await CompileAsync(""" - using Out = System.Console; - - namespace Sample; - - public static class Demo - { - public static void Greet() => Out.WriteLine("hi"); - } - """); - var method = root.DescendantNodes().OfType().Single(); - - var result = AnalyzeAsStrings(method, model, root); - - result.ShouldContain("using Out = System.Console;"); - } - - [Fact] - public async Task Excludes_Alias_When_Not_Referenced() - { - var (root, model) = await CompileAsync(""" - using Out = System.Console; - using System.Text; - - namespace Sample; - - public static class Demo - { - public static string Build() => new StringBuilder().ToString(); - } - """); - var method = root.DescendantNodes().OfType().Single(); - - var result = AnalyzeAsStrings(method, model, root); - - result.ShouldNotContain("using Out = System.Console;"); - result.ShouldContain("using System.Text;"); - } - - [Fact] - public async Task Excludes_Using_For_Same_Namespace_As_Declaration() - { - var (root, model) = await CompileAsync(""" - using Sample; - - namespace Sample; - - public class Helper { public static int Value() => 1; } - - public static class Demo - { - public static int Use() => Helper.Value(); - } - """); - var method = root.DescendantNodes().OfType().Single(m => m.Identifier.Text == "Use"); - - var result = AnalyzeAsStrings(method, model, root); - - result.ShouldNotContain("using Sample;"); - } - - [Fact] - public async Task Returns_Empty_For_Body_With_No_External_Dependencies() - { - var (root, model) = await CompileAsync(""" - using System.Text; - - namespace Sample; - - public static class Demo - { - public static int Add(int a, int b) => a + b; - } - """); - var method = root.DescendantNodes().OfType().Single(); - - var result = AnalyzeAsStrings(method, model, root); - - result.ShouldBeEmpty(); - } - - [Fact] - public async Task Plain_Using_Does_Not_Cover_SubNamespace() - { - // `using System;` does NOT import System.Text.StringBuilder — match must be exact. - var (root, model) = await CompileAsync(""" - using System; - using System.Text; - - namespace Sample; - - public static class Demo - { - public static string Build() => new StringBuilder().ToString(); - } - """); - var method = root.DescendantNodes().OfType().Single(); - - var result = AnalyzeAsStrings(method, model, root); - - result.ShouldContain("using System.Text;"); - result.ShouldNotContain("using System;"); - } - - private static IReadOnlyList AnalyzeAsStrings( - MethodDeclarationSyntax method, - SemanticModel model, - CompilationUnitSyntax root) - => RequiredUsingsAnalyzer.Analyze(method, model, root) - .Select(d => d.ToString().Trim()) - .ToList(); - - private static readonly Lazy _references = new(() => - { - var trusted = (string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") ?? string.Empty; - return trusted - .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) - .Where(p => p.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) - .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)) - .ToArray(); - }); - - private static async Task<(CompilationUnitSyntax Root, SemanticModel Model)> CompileAsync(string source) - { - var ct = TestContext.Current.CancellationToken; - var tree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.Preview), cancellationToken: ct); - var compilation = CSharpCompilation.Create( - "Fixture", - [tree], - _references.Value, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - - var root = (CompilationUnitSyntax)await tree.GetRootAsync(ct); - var model = compilation.GetSemanticModel(tree); - return (root, model); - } -} \ No newline at end of file diff --git a/tests/Pennington.Roslyn.Tests/Symbols/XmlDocIdNormalizerTests.cs b/tests/Pennington.Roslyn.Tests/Symbols/XmlDocIdNormalizerTests.cs deleted file mode 100644 index 7885ac84..00000000 --- a/tests/Pennington.Roslyn.Tests/Symbols/XmlDocIdNormalizerTests.cs +++ /dev/null @@ -1,87 +0,0 @@ -namespace Pennington.Roslyn.Tests.Symbols; - -using Pennington.Roslyn.Symbols; - -public sealed class XmlDocIdNormalizerTests -{ - [Fact] - public void Strips_Namespace_From_Simple_Parameter() - { - var result = XmlDocIdNormalizer.Normalize("M:Type.Method(System.String)"); - - result.ShouldBe("M:Type.Method(String)"); - } - - [Fact] - public void Strips_Multiple_Parameters() - { - var result = XmlDocIdNormalizer.Normalize("M:Type.Method(System.String,System.Int32)"); - - result.ShouldBe("M:Type.Method(String,Int32)"); - } - - [Fact] - public void Preserves_Generic_Params() - { - var result = XmlDocIdNormalizer.Normalize("M:Type.Method(`0)"); - - result.ShouldBe("M:Type.Method(`0)"); - } - - [Fact] - public void Preserves_Non_Method_IDs() - { - var result = XmlDocIdNormalizer.Normalize("T:MyNamespace.MyClass"); - - result.ShouldBe("T:MyNamespace.MyClass"); - } - - [Fact] - public void Handles_Nested_Generics() - { - var result = XmlDocIdNormalizer.Normalize( - "M:Type.Method(System.Collections.Generic.List{System.String})"); - - result.ShouldBe("M:Type.Method(List{String})"); - } - - [Fact] - public void Handles_Empty_String() - { - var result = XmlDocIdNormalizer.Normalize(""); - - result.ShouldBe(""); - } - - [Fact] - public void Handles_Null_String() - { - var result = XmlDocIdNormalizer.Normalize(null!); - - result.ShouldBeNull(); - } - - [Fact] - public void Handles_Array_Parameters() - { - var result = XmlDocIdNormalizer.Normalize("M:Type.Method(System.String[])"); - - result.ShouldBe("M:Type.Method(String[])"); - } - - [Fact] - public void Handles_Double_Backtick_Generic_Params() - { - var result = XmlDocIdNormalizer.Normalize("M:Type.Method(``0)"); - - result.ShouldBe("M:Type.Method(``0)"); - } - - [Fact] - public void Handles_Mixed_Generic_And_Qualified_Params() - { - var result = XmlDocIdNormalizer.Normalize("M:Type.Method(`0,System.String)"); - - result.ShouldBe("M:Type.Method(`0,String)"); - } -} \ No newline at end of file diff --git a/tests/Pennington.Roslyn.Tests/Utilities/TextFormatterTests.cs b/tests/Pennington.Roslyn.Tests/Utilities/TextFormatterTests.cs deleted file mode 100644 index b13aca9a..00000000 --- a/tests/Pennington.Roslyn.Tests/Utilities/TextFormatterTests.cs +++ /dev/null @@ -1,72 +0,0 @@ -namespace Pennington.Roslyn.Tests.Utilities; - -using Pennington.Roslyn.Utilities; - -public sealed class TextFormatterTests -{ - [Fact] - public void Normalizes_Indentation() - { - var code = " var x = 1;\n var y = 2;"; - - var result = TextFormatter.NormalizeIndents(code); - - result.ShouldBe("var x = 1;\nvar y = 2;"); - } - - [Fact] - public void Preserves_Relative_Indentation() - { - var code = " if (true)\n return;\n end"; - - var result = TextFormatter.NormalizeIndents(code); - - result.ShouldBe("if (true)\n return;\nend"); - } - - [Fact] - public void Handles_Empty_Lines() - { - var code = " var x = 1;\n\n var y = 2;"; - - var result = TextFormatter.NormalizeIndents(code); - - result.ShouldBe("var x = 1;\n\nvar y = 2;"); - } - - [Fact] - public void Returns_Empty_For_Empty_Input() - { - var result = TextFormatter.NormalizeIndents(""); - - result.ShouldBe(""); - } - - [Fact] - public void Returns_Unchanged_When_No_Common_Indent() - { - var code = "var x = 1;\n var y = 2;"; - - var result = TextFormatter.NormalizeIndents(code); - - result.ShouldBe("var x = 1;\n var y = 2;"); - } - - [Fact] - public void Handles_Null_Input() - { - var result = TextFormatter.NormalizeIndents(null!); - - result.ShouldBeNull(); - } - - [Fact] - public void Trims_Leading_And_Trailing_Blank_Lines() - { - var code = "\n foo\n bar\n "; - - var result = TextFormatter.NormalizeIndents(code); - - result.ShouldBe("foo\nbar"); - } -} \ No newline at end of file diff --git a/tests/Pennington.Roslyn.Tests/Workspace/SolutionWorkspaceServiceTests.cs b/tests/Pennington.Roslyn.Tests/Workspace/SolutionWorkspaceServiceTests.cs deleted file mode 100644 index 2dd4f707..00000000 --- a/tests/Pennington.Roslyn.Tests/Workspace/SolutionWorkspaceServiceTests.cs +++ /dev/null @@ -1,157 +0,0 @@ -namespace Pennington.Roslyn.Tests.Workspace; - -using Infrastructure; -using Microsoft.CodeAnalysis; -using Microsoft.Extensions.Logging.Abstractions; -using Pennington.Roslyn.Symbols; -using Pennington.Roslyn.Workspace; - -public sealed class SolutionWorkspaceServiceTests -{ - [Fact] - public void Implements_ISolutionWorkspaceService() - { - typeof(SolutionWorkspaceService) - .GetInterfaces() - .ShouldContain(typeof(ISolutionWorkspaceService)); - } - - [Fact] - public void Constructor_Does_Not_Throw_With_Valid_Options() - { - var options = new RoslynOptions { SolutionPath = "B:\\Pennington\\Pennington.slnx" }; - var watcher = new StubFileWatcher(); - var logger = NullLogger.Instance; - - using var service = new SolutionWorkspaceService(options, watcher, logger); - - service.ShouldNotBeNull(); - } - - [Fact] - public void Constructor_Throws_When_SolutionPath_Is_Null() - { - var options = new RoslynOptions { SolutionPath = null }; - var watcher = new StubFileWatcher(); - var logger = NullLogger.Instance; - - Should.Throw(() => - new SolutionWorkspaceService(options, watcher, logger)); - } - - [Fact] - public void InvalidateSolution_Does_Not_Throw_Before_Load() - { - var options = new RoslynOptions { SolutionPath = "B:\\Pennington\\Pennington.slnx" }; - var watcher = new StubFileWatcher(); - var logger = NullLogger.Instance; - - using var service = new SolutionWorkspaceService(options, watcher, logger); - - Should.NotThrow(() => service.InvalidateSolution()); - } - - [Fact] - public void UpdateDocument_Queues_Without_Error() - { - var options = new RoslynOptions { SolutionPath = "B:\\Pennington\\Pennington.slnx" }; - var watcher = new StubFileWatcher(); - var logger = NullLogger.Instance; - - using var service = new SolutionWorkspaceService(options, watcher, logger); - - Should.NotThrow(() => service.UpdateDocument("B:\\Pennington\\src\\Pennington\\Routing\\UrlPath.cs")); - } - - [Fact] - public void Constructor_Registers_File_Watches() - { - var options = new RoslynOptions { SolutionPath = "B:\\Pennington\\Pennington.slnx" }; - var watcher = new StubFileWatcher(); - var logger = NullLogger.Instance; - - using var service = new SolutionWorkspaceService(options, watcher, logger); - - // Should register watches for *.csproj, *.sln, *.slnx, *.cs - watcher.RegisteredPatterns.ShouldContain("*.csproj"); - watcher.RegisteredPatterns.ShouldContain("*.sln"); - watcher.RegisteredPatterns.ShouldContain("*.slnx"); - watcher.RegisteredPatterns.ShouldContain("*.cs"); - } - - [Fact] - public void Dispose_Can_Be_Called_Multiple_Times() - { - var options = new RoslynOptions { SolutionPath = "B:\\Pennington\\Pennington.slnx" }; - var watcher = new StubFileWatcher(); - var logger = NullLogger.Instance; - - var service = new SolutionWorkspaceService(options, watcher, logger); - - Should.NotThrow(() => - { - service.Dispose(); - service.Dispose(); - }); - } - - [Fact] - public void InvalidateSolution_Clears_Symbol_Cache() - { - var options = new RoslynOptions { SolutionPath = "B:\\Pennington\\Pennington.slnx" }; - var watcher = new StubFileWatcher(); - var logger = NullLogger.Instance; - var symbolService = new SpySymbolExtractionService(); - - using var service = new SolutionWorkspaceService(options, watcher, logger); - service.SymbolExtractionService = symbolService; - - service.InvalidateSolution(); - - symbolService.ClearCacheCallCount.ShouldBe(1); - } - - /// - /// Minimal stub for IFileWatcher that records registered patterns. - /// - private sealed class StubFileWatcher : IFileWatcher - { - public List RegisteredPatterns { get; } = []; - - public void AddPathWatch(string path, string filePattern, Action onFileChanged, bool includeSubdirectories = true) - { - RegisteredPatterns.Add(filePattern); - } - - public void SubscribeToChanges(Action onUpdate) { } - - public void SubscribeToChanges(Action onUpdate) { } - - public void Dispose() { } - } - - private sealed class SpySymbolExtractionService : ISymbolExtractionService - { - public int ClearCacheCallCount { get; private set; } - - public Task> ExtractSymbolsAsync(IEnumerable projects) - => Task.FromResult>( - new Dictionary()); - - public Task FindSymbolAsync(string xmlDocId) - => Task.FromResult(null); - - public Task ExtractCodeFragmentAsync(string xmlDocId, bool bodyOnly = false, bool includeLeadingTrivia = true) - => Task.FromResult(string.Empty); - - public Task ExtractCodeFragmentWithUsingsAsync(string xmlDocId, bool bodyOnly = false, bool includeLeadingTrivia = true) - => Task.FromResult(new CodeFragmentResult(string.Empty, System.Collections.Immutable.ImmutableList.Empty)); - - public Task ExtractDeclarationSignatureAsync(string xmlDocId) - => Task.FromResult(string.Empty); - - public void ClearCache() => ClearCacheCallCount++; - - public Task WarmupAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; - } -} \ No newline at end of file diff --git a/tests/Pennington.Tests/ApiMetadata/Reflection/CompiledAssemblyApiMetadataProviderTests.cs b/tests/Pennington.Tests/ApiMetadata/Reflection/CompiledAssemblyApiMetadataProviderTests.cs new file mode 100644 index 00000000..4f8fe8fb --- /dev/null +++ b/tests/Pennington.Tests/ApiMetadata/Reflection/CompiledAssemblyApiMetadataProviderTests.cs @@ -0,0 +1,93 @@ +namespace Pennington.Tests.ApiMetadata.Reflection; + +using System.IO; +using System.Linq; +using Pennington.ApiMetadata; +using Pennington.ApiMetadata.Reflection; +using Pennington.Highlighting; +using Shouldly; + +/// +/// Exercises the reflection-backed API provider against Pennington's own built assembly, so the +/// behaviors the docs site depends on — union cases, inheritdoc resolution, extension-method +/// cross-references, const defaults, and implicit-member exclusion — are validated end-to-end +/// over real metadata + xmldoc. +/// +public sealed class CompiledAssemblyApiMetadataProviderTests +{ + private static CompiledAssemblyApiMetadataProvider CreateProvider() + { + var options = new CompiledAssemblyApiOptions(); + options.AssemblyFiles.Add(Path.Combine(AppContext.BaseDirectory, "Pennington.dll")); + return new CompiledAssemblyApiMetadataProvider(options, new XmlDocParser(), new PlainTextHighlighter()); + } + + [Fact] + public async Task Enumerates_union_cases_for_a_union_type() + { + var provider = CreateProvider(); + + var cases = await provider.GetMembersAsync( + "T:Pennington.Pipeline.ContentItem", MemberKind.UnionCases, AccessFilter.Public, MemberOrder.Declaration); + + cases.ShouldAllBe(m => m.Kind == MemberKind.UnionCases); + var names = cases.Select(m => m.Name).ToList(); + names.ShouldContain("DiscoveredItem"); + names.ShouldContain("ParsedItem"); + names.ShouldContain("RenderedItem"); + names.ShouldContain("FailedItem"); + } + + [Fact] + public async Task Resolves_inheritdoc_from_an_implemented_interface() + { + var provider = CreateProvider(); + + // PlainTextHighlighter.Highlight is `/// `; the summary lives on + // ICodeHighlighter.Highlight. Without resolution the member would have no summary. + var members = await provider.GetMembersAsync( + "T:Pennington.Highlighting.PlainTextHighlighter", MemberKind.Methods, AccessFilter.Public, MemberOrder.Declaration); + + var highlight = members.First(m => m.Name == "Highlight"); + highlight.HasInheritDocDirective.ShouldBeTrue(); + highlight.Xmldoc.HasSummary.ShouldBeTrue(); + } + + [Fact] + public async Task Groups_extension_methods_by_receiver_type_name() + { + var provider = CreateProvider(); + + var forServices = await provider.GetExtensionMethodsForAsync("IServiceCollection"); + + forServices.ShouldNotBeEmpty(); + forServices.ShouldContain(e => e.Name == "AddPennington"); + forServices.ShouldAllBe(e => e.ReceiverTypeName == "IServiceCollection"); + } + + [Fact] + public async Task Surfaces_const_field_default_values() + { + var provider = CreateProvider(); + + var key = await provider.GetMemberAsync("F:Pennington.Pipeline.ReadingTimeEnricher.Key"); + + key.ShouldNotBeNull(); + key.Kind.ShouldBe(MemberKind.Fields); + key.DefaultValue.ShouldBe("\"reading_time_minutes\""); + } + + [Fact] + public async Task Excludes_operators_from_the_method_list() + { + var provider = CreateProvider(); + + // UrlPath defines implicit string operators; the member table lists only ordinary methods, + // so the reflection provider must drop op_* members too. + var methods = await provider.GetMembersAsync( + "T:Pennington.Routing.UrlPath", MemberKind.Methods, AccessFilter.Public, MemberOrder.Declaration); + + methods.ShouldNotBeEmpty(); + methods.ShouldNotContain(m => m.Name.StartsWith("op_", StringComparison.Ordinal)); + } +} diff --git a/tests/Pennington.Tests/Markdown/Extensions/CodeBlockRenderingServiceTests.cs b/tests/Pennington.Tests/Markdown/Extensions/CodeBlockRenderingServiceTests.cs index e4f843f7..8bffe654 100644 --- a/tests/Pennington.Tests/Markdown/Extensions/CodeBlockRenderingServiceTests.cs +++ b/tests/Pennington.Tests/Markdown/Extensions/CodeBlockRenderingServiceTests.cs @@ -15,8 +15,8 @@ public class CodeBlockRenderingServiceTests [Fact] public void Render_MatchingPreprocessor_ReturnsPreprocessedHtml() { - // Simulates M:Foo.Bar - // hitting RoslynCodeBlockPreprocessor (which would resolve the body). + // Simulates a whose info string matches a registered code-fragment + // preprocessor (which would resolve the fenced body). var preprocessor = new StubPreprocessor( matchLanguageId: "csharp:xmldocid,bodyonly", result: new CodeBlockPreprocessResult( @@ -59,7 +59,7 @@ public void Render_NoMatchingPreprocessor_FallsBackToHighlighter() [Fact] public void Render_PreprocessorReturnsNull_FallsBackToHighlighter() { - // Preprocessor returns null (e.g. RoslynCodeBlockPreprocessor on a non-Roslyn languageId). + // Preprocessor returns null (e.g. a code-fragment preprocessor on a languageId it doesn't handle). var preprocessor = new StubPreprocessor(matchLanguageId: "csharp:xmldocid", result: null); var service = new CodeBlockRenderingService( diff --git a/tests/Pennington.Tests/Pennington.Tests.csproj b/tests/Pennington.Tests/Pennington.Tests.csproj index 5e3fec5d..147cd529 100644 --- a/tests/Pennington.Tests/Pennington.Tests.csproj +++ b/tests/Pennington.Tests/Pennington.Tests.csproj @@ -23,6 +23,7 @@ +