Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions tools/BootstrapBlazor.LLMsDocsGenerator/ArgumentsHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,17 @@ internal static class ArgumentsHelper
{
public static ParseResult Parse(string[] args)
{
var rootFolderOption = new Option<string?>("--root") { Description = "Set the root folder of project" };
var outputFolderOption = new Option<string?>("--output") { Description = "Set the publish folder of project" };
const string defaultFolder = ".";
var rootFolderOption = new Option<string>("--root")
{
Description = "Set the root folder of project",
DefaultValueFactory = _ => defaultFolder
};
var outputFolderOption = new Option<string>("--output")
{
Description = "Set the publish folder of project",
DefaultValueFactory = _ => defaultFolder
};

var rootCommand = new RootCommand("BootstrapBlazor LLMs Documentation Generator")
{
Expand All @@ -25,13 +34,13 @@ public static ParseResult Parse(string[] args)
var rootFolder = result.GetValue(rootFolderOption);
if (string.IsNullOrEmpty(rootFolder))
{
return;
throw new ArgumentException("Project root folder must not be empty.");
}

var outputFolder = result.GetValue(outputFolderOption);
if (string.IsNullOrEmpty(outputFolder))
{
return;
throw new ArgumentException("Publish folder must not be empty.");
}

await DocsGenerator.GenerateAllAsync(rootFolder, outputFolder);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<Version>10.0.4</Version>
<Version>10.0.5</Version>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0;net11.0</TargetFrameworks>
<TargetFrameworks>net11.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<SatelliteResourceLanguages>false</SatelliteResourceLanguages>
Expand All @@ -20,8 +20,8 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" />
<PackageReference Include="System.CommandLine" Version="2.0.1" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.*" />
<PackageReference Include="System.CommandLine" Version="3.0.0-rc.*" />
</ItemGroup>

<ItemGroup>
Expand Down
4 changes: 4 additions & 0 deletions tools/BootstrapBlazor.LLMsDocsGenerator/CategoryProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ internal sealed class CategoryProvider
public CategoryProvider(string serverPath)
{
_docsFile = Path.Combine(serverPath, "docs.json");
if (!File.Exists(_docsFile))
{
throw new FileNotFoundException("Documentation configuration not found.", _docsFile);
}
}

/// <summary>
Expand Down
103 changes: 88 additions & 15 deletions tools/BootstrapBlazor.LLMsDocsGenerator/ComponentAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Text.RegularExpressions;
using System.Xml.Linq;

namespace BootstrapBlazorLLMsDocsGenerator;

Expand All @@ -26,6 +27,7 @@ public partial class ComponentAnalyzer
private readonly string _componentsPath;
private readonly string _samplesPath;
private readonly string? _extensionsSourcePath;
private readonly IReadOnlyDictionary<string, string>? _extensionProjects;

private CSharpCompilation? _compilation;
private readonly Dictionary<string, SyntaxTree> _treeByPath = new(StringComparer.OrdinalIgnoreCase);
Expand All @@ -43,12 +45,14 @@ public partial class ComponentAnalyzer
typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces,
genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters);

public ComponentAnalyzer(string sourcePath, string? extensionsSourcePath = null)
public ComponentAnalyzer(string sourcePath, string? extensionsSourcePath = null,
IReadOnlyDictionary<string, string>? extensionProjects = null)
{
_sourcePath = sourcePath;
_componentsPath = Path.Combine(sourcePath, "Components");
_samplesPath = Path.Combine(Path.GetDirectoryName(sourcePath)!, "BootstrapBlazor.Server", "Components", "Samples");
_extensionsSourcePath = extensionsSourcePath;
_extensionProjects = extensionProjects;
}

/// <summary>
Expand All @@ -69,8 +73,11 @@ public async Task<List<ComponentInfo>> AnalyzeAllComponentsAsync()
// Entry points: component code-behind files and explicit base classes.
// Other partial files (e.g. Table.razor.Toolbar.cs) are NOT entry points;
// their members are merged into the component via the symbol model.
// Razor-only components use the synthesized @inherits declaration.
var entryFiles = Directory.GetFiles(_componentsPath, "*.razor.cs", SearchOption.AllDirectories)
.Concat(Directory.GetFiles(_componentsPath, "*Base.cs", SearchOption.AllDirectories))
.Concat(Directory.GetFiles(_componentsPath, "*.razor", SearchOption.AllDirectories)
.Where(file => !File.Exists(file + ".cs") && _treeByPath.ContainsKey(Path.GetFullPath(file))))
Comment on lines +79 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Inherited component details disappear

When a Razor-only component uses an unqualified @inherits base type supplied by _Imports.razor or project-level usings, BuildInheritsShimAsync parses the synthesized class without those imports, so Roslyn cannot resolve the base type and the analyzer omits inherited parameters and methods or excludes the component from the generated docs.

Include the applicable Razor imports and project-level usings when building the synthesized shim.

Also at tools/BootstrapBlazor.LLMsDocsGenerator/ComponentAnalyzer.cs:381.

Prompt for AI agents
In `tools/BootstrapBlazor.LLMsDocsGenerator/ComponentAnalyzer.cs` at lines 79-80:

**Inherited component details disappear**

When a Razor-only component uses an unqualified `@inherits` base type supplied by `_Imports.razor` or project-level usings, `BuildInheritsShimAsync` parses the synthesized class without those imports, so Roslyn cannot resolve the base type and the analyzer omits inherited parameters and methods or excludes the component from the generated docs.

Include the applicable Razor imports and project-level usings when building the synthesized shim.

Also at `tools/BootstrapBlazor.LLMsDocsGenerator/ComponentAnalyzer.cs:381`.

.Distinct(StringComparer.OrdinalIgnoreCase);

var seenTypes = new HashSet<string>(StringComparer.Ordinal);
Expand Down Expand Up @@ -104,6 +111,10 @@ public async Task<List<ComponentInfo>> AnalyzeAllComponentsAsync()
{
files = Directory.GetFiles(_componentsPath, $"{componentName}.cs", SearchOption.AllDirectories);
}
if (files.Length == 0)
{
files = Directory.GetFiles(_componentsPath, $"{componentName}.razor", SearchOption.AllDirectories);
}

return files.Length == 0 ? null : AnalyzeEntryFile(files[0]);
}
Expand All @@ -126,7 +137,7 @@ public async Task<List<ComponentInfo>> AnalyzeServicesAsync(IEnumerable<string>
var syntax = symbol?.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax();
if (symbol == null || syntax == null)
{
continue;
throw new InvalidDataException($"Service type not found in analyzed sources: {name}");
}

services.Add(new ComponentInfo
Expand All @@ -147,7 +158,8 @@ public async Task<List<ComponentInfo>> AnalyzeServicesAsync(IEnumerable<string>
/// ComponentInfo records with parameters, methods and source path. The compilation
/// must include both the extension sources and the core library (for base types).
/// </summary>
public async Task<List<ComponentInfo>> AnalyzeTypesByNameAsync(IEnumerable<string> typeNames, bool resolveRegistration = false)
public async Task<List<ComponentInfo>> AnalyzeTypesByNameAsync(IEnumerable<string> typeNames,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Extension docs describe the wrong type

When a configured extension type shares a short name with a core or another extension type, or two configured packages use the same short type name, Distinct() collapses duplicate configured names and AnalyzeTypesByNameAsync resolves names with GetSymbolsWithName(...).FirstOrDefault() across the combined compilation, so the generator documents the wrong declaration, can overwrite another package’s document, and produces incorrect or missing registration guidance.

Keep each configured name associated with its package and resolve it within the owning extension project instead of selecting the first symbol from the combined compilation.

Also at tools/BootstrapBlazor.LLMsDocsGenerator/ComponentAnalyzer.cs:162, tools/BootstrapBlazor.LLMsDocsGenerator/ComponentAnalyzer.cs:333, tools/BootstrapBlazor.LLMsDocsGenerator/DocsGenerator.cs:69.

Prompt for AI agents
In `tools/BootstrapBlazor.LLMsDocsGenerator/ComponentAnalyzer.cs` at line 161:

**Extension docs describe the wrong type**

When a configured extension type shares a short name with a core or another extension type, or two configured packages use the same short type name, `Distinct()` collapses duplicate configured names and `AnalyzeTypesByNameAsync` resolves names with `GetSymbolsWithName(...).FirstOrDefault()` across the combined compilation, so the generator documents the wrong declaration, can overwrite another package’s document, and produces incorrect or missing registration guidance.

Keep each configured name associated with its package and resolve it within the owning extension project instead of selecting the first symbol from the combined compilation.

Also at `tools/BootstrapBlazor.LLMsDocsGenerator/ComponentAnalyzer.cs:162`, `tools/BootstrapBlazor.LLMsDocsGenerator/ComponentAnalyzer.cs:333`, `tools/BootstrapBlazor.LLMsDocsGenerator/DocsGenerator.cs:69`.

bool resolveRegistration = false, IReadOnlyList<ExtensionPackage>? registrationPackages = null)
{
await EnsureCompilationAsync();

Expand All @@ -161,7 +173,7 @@ public async Task<List<ComponentInfo>> AnalyzeTypesByNameAsync(IEnumerable<strin
var syntax = symbol?.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax();
if (symbol == null || syntax == null)
{
continue;
throw new InvalidDataException($"Extension type not found in analyzed sources: {name}");
}

var path = Path.GetFullPath(syntax.SyntaxTree.FilePath);
Expand All @@ -176,7 +188,10 @@ public async Task<List<ComponentInfo>> AnalyzeTypesByNameAsync(IEnumerable<strin
PublicMethods = CollectMethods(symbol),
SourcePath = GetRelativePath(path),
IsExtension = extSrc != null && path.StartsWith(extSrc, StringComparison.OrdinalIgnoreCase),
Registration = resolveRegistration ? ResolveRegistration(symbol) : null
Registrations = resolveRegistration
? ResolveRegistrations(symbol, registrationPackages?.Where(p => p.Services.Contains(name))
.Select(p => p.Name).ToHashSet(StringComparer.OrdinalIgnoreCase))
: new()
});
}

Expand All @@ -192,15 +207,17 @@ public async Task<List<ComponentInfo>> AnalyzeTypesByNameAsync(IEnumerable<strin
"TryAddScoped", "TryAddSingleton", "TryAddTransient"
};

private Dictionary<INamedTypeSymbol, ServiceRegistration>? _registrationIndex;
private Dictionary<INamedTypeSymbol, List<ServiceRegistration>>? _registrationIndex;

/// <summary>
/// Look up the extension method that registers a service type into DI.
/// </summary>
private ServiceRegistration? ResolveRegistration(INamedTypeSymbol serviceType)
private List<ServiceRegistration> ResolveRegistrations(INamedTypeSymbol serviceType, HashSet<string>? packageNames)
{
BuildRegistrationIndex();
return _registrationIndex!.TryGetValue(serviceType, out var reg) ? reg : null;
return _registrationIndex!.TryGetValue(serviceType, out var registrations)
? registrations.Where(reg => packageNames == null || packageNames.Contains(reg.PackageName)).ToList()
: new();
}

/// <summary>
Expand All @@ -218,10 +235,14 @@ private void BuildRegistrationIndex()
return;
}

_registrationIndex = new Dictionary<INamedTypeSymbol, ServiceRegistration>(SymbolEqualityComparer.Default);
_registrationIndex = new Dictionary<INamedTypeSymbol, List<ServiceRegistration>>(SymbolEqualityComparer.Default);

foreach (var tree in _compilation!.SyntaxTrees)
{
var packageName = _extensionProjects?.FirstOrDefault(project =>
Path.GetFullPath(tree.FilePath).StartsWith(
Path.GetFullPath(project.Value) + Path.DirectorySeparatorChar,
StringComparison.OrdinalIgnoreCase)).Key ?? "";
SemanticModel? model = null;
foreach (var method in tree.GetRoot().DescendantNodes().OfType<MethodDeclarationSyntax>())
{
Expand Down Expand Up @@ -253,14 +274,21 @@ private void BuildRegistrationIndex()
continue;
}

// First writer wins so the most direct registration method sticks.
if (!_registrationIndex.ContainsKey(serviceType))
if (!_registrationIndex.TryGetValue(serviceType, out var registrations))
{
_registrationIndex[serviceType] = new ServiceRegistration
registrations = new();
_registrationIndex[serviceType] = registrations;
}

// First writer wins within each package so the most direct registration method sticks.
if (!registrations.Any(reg => reg.PackageName == packageName))
{
registrations.Add(new ServiceRegistration

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Registration guidance is omitted

When a configured extension service uses a non-generic registration overload such as AddScoped(typeof(IDataService<>), ...), BuildRegistrationIndex only recognizes generic registration calls and their type arguments, so ResolveRegistrations finds no registration and the generated extension documentation omits the package’s registration instructions.

Teach BuildRegistrationIndex to extract service types from non-generic registration overloads that use typeof.

Also at tools/BootstrapBlazor.LLMsDocsGenerator/DocsGenerator.cs:72-73.

Prompt for AI agents
In `tools/BootstrapBlazor.LLMsDocsGenerator/ComponentAnalyzer.cs` at line 286:

**Registration guidance is omitted**

When a configured extension service uses a non-generic registration overload such as `AddScoped(typeof(IDataService<>), ...)`, `BuildRegistrationIndex` only recognizes generic registration calls and their type arguments, so `ResolveRegistrations` finds no registration and the generated extension documentation omits the package’s registration instructions.

Teach `BuildRegistrationIndex` to extract service types from non-generic registration overloads that use `typeof`.

Also at `tools/BootstrapBlazor.LLMsDocsGenerator/DocsGenerator.cs:72-73`.

{
PackageName = packageName,
MethodName = method.Identifier.Text,
Parameters = BuildRegistrationParameters(method)
};
});
}
}
}
Expand Down Expand Up @@ -298,14 +326,16 @@ private async Task EnsureCompilationAsync()

// Include the core library and, when provided, the extensions repository so that
// extension components (which inherit core base types) resolve their full chain.
// Solution project paths exclude leftover directories outside the solution.
var sourceRoots = new List<string> { _sourcePath };
if (!string.IsNullOrEmpty(_extensionsSourcePath) && Directory.Exists(_extensionsSourcePath))
{
sourceRoots.Add(_extensionsSourcePath);
sourceRoots.AddRange(_extensionProjects?.Values ?? new[] { _extensionsSourcePath });
}

foreach (var root in sourceRoots)
{
var projectUsings = ReadProjectUsings(root);
foreach (var file in Directory.GetFiles(root, "*.cs", SearchOption.AllDirectories).Where(IsCompilableSource))
{
var fullPath = Path.GetFullPath(file);
Expand All @@ -316,6 +346,14 @@ private async Task EnsureCompilationAsync()

var code = await File.ReadAllTextAsync(file);
var tree = CSharpSyntaxTree.ParseText(code, parseOptions, path: fullPath);
if (projectUsings.Count > 0)
{
// Keep project imports local to each file in the combined compilation.
var syntaxRoot = tree.GetCompilationUnitRoot();
var imports = projectUsings.Where(import =>
!syntaxRoot.Usings.Any(existing => existing.IsEquivalentTo(import)));
tree = CSharpSyntaxTree.Create(syntaxRoot.AddUsings(imports.ToArray()), parseOptions, fullPath);
}
trees.Add(tree);
_treeByPath[fullPath] = tree;
}
Expand All @@ -339,7 +377,9 @@ private async Task EnsureCompilationAsync()
var shim = await BuildInheritsShimAsync(razorFile);
if (shim != null)
{
trees.Add(CSharpSyntaxTree.ParseText(shim, parseOptions));
var tree = CSharpSyntaxTree.ParseText(shim, parseOptions, path: razorFile);
trees.Add(tree);
_treeByPath[Path.GetFullPath(razorFile)] = tree;
}
}
}
Expand All @@ -351,6 +391,35 @@ private async Task EnsureCompilationAsync()
options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
}

private static List<UsingDirectiveSyntax> ReadProjectUsings(string projectDirectory)
{
var imports = new List<UsingDirectiveSyntax>();
foreach (var projectFile in Directory.EnumerateFiles(projectDirectory, "*.csproj"))
{
var project = XDocument.Load(projectFile);
foreach (var item in project.Descendants("Using"))
{
var name = (string?)item.Attribute("Include");
if (string.IsNullOrWhiteSpace(name))
{
continue;
}

var alias = (string?)item.Attribute("Alias");
var prefix = !string.IsNullOrWhiteSpace(alias) ? $"{alias} = "
: string.Equals((string?)item.Attribute("Static"), "true", StringComparison.OrdinalIgnoreCase)
? "static " : "";
var syntax = SyntaxFactory.ParseCompilationUnit($"using {prefix}{name};");
if (syntax.ContainsDiagnostics)
{
throw new InvalidDataException($"Invalid project Using '{name}' in {projectFile}");
}
imports.AddRange(syntax.Usings);
}
}
return imports;
}

/// <summary>
/// Read the <c>@inherits</c> / <c>@typeparam</c> / <c>@namespace</c> directives from a
/// .razor file and emit a minimal partial class that re-states the base type, so the
Expand Down Expand Up @@ -720,6 +789,10 @@ private List<MethodInfo> CollectMethods(INamedTypeSymbol type)
private static string DeriveClassName(string filePath)
{
var fileName = Path.GetFileName(filePath);
if (fileName.EndsWith(".razor", StringComparison.OrdinalIgnoreCase))
{
return fileName[..^".razor".Length];
}
if (fileName.EndsWith(".razor.cs", StringComparison.OrdinalIgnoreCase))
{
return fileName[..^".razor.cs".Length];
Expand Down
9 changes: 7 additions & 2 deletions tools/BootstrapBlazor.LLMsDocsGenerator/ComponentInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ public class ComponentInfo
/// <summary>
/// Dependency-injection registration for an extension service, resolved from the
/// <c>AddXxx(this IServiceCollection)</c> extension method that registers this type.
/// Null for components and for services with no discoverable registration method.
/// Empty for components and for services with no discoverable registration method.
/// </summary>
public ServiceRegistration? Registration { get; set; }
public List<ServiceRegistration> Registrations { get; set; } = new();
}

/// <summary>
Expand All @@ -80,6 +80,11 @@ public class ComponentInfo
/// </summary>
public class ServiceRegistration
{
/// <summary>
/// Extension package providing this registration.
/// </summary>
public string PackageName { get; set; } = string.Empty;

/// <summary>
/// Registration extension method name, e.g. <c>AddBootstrapBlazorWinBoxService</c>.
/// </summary>
Expand Down
Loading
Loading