diff --git a/docs/design/schema-query.md b/docs/design/schema-query.md index 2fbaf687..bf7677d6 100644 --- a/docs/design/schema-query.md +++ b/docs/design/schema-query.md @@ -175,6 +175,23 @@ var effective = schema.EffectiveFields("Package", The section-level callback maps directly to `SectionEntry.CanRender`. The field-level callback is harder — it requires mapping field display names to model properties. This is where the source generator helps: it can emit the mapping. +**Realized approach (type discovery).** For single-type discovery (` -D
--effective`), field-level effectiveness is computed by _rendering_ the section at the user's actual verbosity/options and keeping only the columns that appear in the rendered markdown table headers (`DiscoverOutput.FilterSchemaToRenderedHeaders`). This keeps the effective column list faithful to what the user would actually see — e.g. the show-index-only `Select` column is dropped, and Minimal-verbosity summary columns (`Return Type`/`Accessors`) replace the Detailed `Signature` column. Header-scoped matching (not whole-body substring) avoids false positives from members whose name matches a column (e.g. `Enumerable.Select`). Sections the member renderer does not produce (e.g. `Custom Attributes`, `Source`, `IL`) retain their full schema columns. + +**Effective sections are restricted to the schema.** The `ApiType` member pipeline includes member-detail code sections (`Source`, `IL`, `IL (Annotated)`, `Lowered C#`) whose `CanRender` returns true for any type with methods, even though they only render for a specific member selection (`member :N`). These sections are _not_ part of the `TypeView` schema, so they cannot be queried via `-D
`. To keep effective discovery consistent with what `-D
` accepts (`-D --effective` ⊆ `-D`), the effective section list is filtered to schema-representable sections via `DiscoverOutput.RestrictToSchemaSections` before rendering. Effective discovery for both the `type` and `member` commands runs through one shared helper (`ApiCommand.ExecuteEffectiveDiscovery`), so the section restriction and the render-probe column narrowing apply uniformly. + +**Empty sections are dropped via a render-probe.** Some schema sections have a coarse `CanRender` proxy that over-reports. For example, `Custom Attributes` (`MethodAttributes` descriptor) returns true for any type with methods, but its data (`TypeView.MethodAttributeRows`) is only populated on the member-detail/index path — the type-level renderer produces no table for it. To avoid advertising sections that would render nothing, `DiscoverOutput.RestrictToRenderedSections` probes the rendered markdown and drops any _tabular_ schema section that produced no table. This reuses the same rendered output already used for the column render-probe, so "no rendered table" reliably means "no data" for these sections. Non-tabular sections are left untouched. + +**Valid-but-empty sections report "no data" (not "not found").** When `-D
--effective` names a section that exists in the full schema but was dropped as empty (e.g. `-D "Custom Attributes" --effective` for a type with no attributes), `DiscoverOutput.FilterEmptyEffectiveSections` emits `note: section '' has no data for this type` on stderr and exits `0`, rather than the misleading `Error: Section '' not found`. A genuinely unknown section still falls through to the resolver's `not found` error (with suggestions) and exit `1`. This path is type/member-only — package/assembly callers pass no full schema and keep prior behavior. + +**Effective discovery is the default (type/member).** For the `type` and `member` commands, plain `-D`/`-D
` now defaults to _effective_ discovery: it resolves and loads the source and lists only the sections/columns that actually have data. This fixes the footgun where the static schema advertised sections (e.g. `Custom Attributes`) that a given type can never populate. The opt-out is `--schema`, which restores the cheap, offline static schema listing (no source resolution, no NuGet download). The legacy `--effective` flag is now redundant (it is already the default) but still accepted for back-compat; passing `--effective --schema` together resolves to `--schema` with a warning. This default flip is scoped to `ApiOptions` (type/member) only — `package`/`assembly` discovery remains static-by-default with `--effective` as the opt-in, because their effective path runs a full-package inspection and would regress cost/offline behavior. The gate is the computed `ApiOptions.EffectiveDiscovery` (`Discover != null && !Schema`). The "no source" case for type/member can't regress because the parser early-returns a static `Discovery` result before any source is required. + +**Plain (static schema) discovery.** Static single-type discovery (` -D
--schema`, or bare ` -D` with no resolvable source) lists the static schema, but option-gated columns are dropped so that what is listed matches what the user can actually project. This is centralized in `ApiCommand.ToQueryableSchema`, the option/contract-level queryability gate (data-independent, the counterpart to the data-level effective gate). Today the only option-gated column is the `Select` overload-index column, which only renders with `--show-index` (`member`-only); it is hidden via `DiscoverOutput.WithoutColumn` unless `ShowSelect` is set, and reappears for `member -D
--show-index`. + +**Projection diagnostics (type path).** When `--columns`/`--fields` are combined with a section selection on the type/member path, the requested names are validated and diagnosed, mirroring the package path: +- Pre-render (`ProjectionDiagnostics.ValidateProjection`): an unknown name (typo) warns `column '' not found in section '
'` with prefix suggestions. +- Post-render (`ProjectionDiagnostics.DiagnoseRendered`): a name valid in the schema but absent from the rendered output (e.g. `Select` without `--show-index`, or `Signature` below Detailed verbosity) emits `note: N field(s) have no data: `. +All columns are still shown (warn, don't suppress) and the exit code stays `0`. To capture the rendered output for the post-render check, `ApiCommand.WriteTypeOutput` accepts an optional `TextWriter`. + ## Schema and rendering: same library, two concerns The source generator is the single point of truth. It reads the attributes once and emits both rendering code and schema metadata. The two concerns never diverge because they're generated from the same walk. diff --git a/src/dotnet-inspect.Tests/CommandExecutionTests.cs b/src/dotnet-inspect.Tests/CommandExecutionTests.cs index 203bc2a9..c01912f9 100644 --- a/src/dotnet-inspect.Tests/CommandExecutionTests.cs +++ b/src/dotnet-inspect.Tests/CommandExecutionTests.cs @@ -133,13 +133,170 @@ public async Task Type_SingleType_SelectClasses_ShowsSelectError() } [Fact] - public async Task Type_SingleType_DiscoverMethods_Works() + public async Task Type_SingleType_NoQuery_DefaultsToShape() + { + var options = new TypeOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer" + }; + + var (exit, output, _) = await ConsoleCapture.RunAsync( + () => TypeCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + // Default single-type invocation renders the tree shape. + Assert.Contains("├─", output); + Assert.Contains("Inherits", output); + } + + [Fact] + public async Task Type_SingleType_SelectSection_RendersSectionNotShape() + { + var options = new TypeOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + Select = ["Properties"] + }; + + var (exit, output, _) = await ConsoleCapture.RunAsync( + () => TypeCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + // Selection produces a focused section view, not the tree shape. + Assert.Contains("## Properties", output); + Assert.DoesNotContain("├─", output); + } + + [Fact] + public async Task Type_SingleType_SelectWithColumns_ProjectsColumns() + { + var options = new TypeOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + Select = ["Properties"], + Columns = ["Name"] + }; + + var (exit, output, _) = await ConsoleCapture.RunAsync( + () => TypeCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + Assert.Contains("## Properties", output); + Assert.Contains("| Name |", output); + Assert.DoesNotContain("Return Type", output); + Assert.DoesNotContain("├─", output); + } + + [Fact] + public async Task Type_SingleType_ExplicitShapeWithSelect_WarnsAndKeepsShape() + { + var options = new TypeOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + ShapeOutput = true, + ShapeExplicitlySet = true, + Select = ["Properties"] + }; + + var (exit, output, error) = await ConsoleCapture.RunAsync( + () => TypeCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + Assert.Contains("--shape does not support", error); + Assert.Contains("├─", output); + } + + [Fact] + public async Task Type_SingleType_SelectEmptySection_WritesNote() + { + var options = new TypeOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + Select = ["Values"] // enum-only section; JsonSerializer is a class + }; + + var (exit, _, error) = await ConsoleCapture.RunAsync( + () => TypeCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + Assert.Contains("section 'Values' has no data", error); + } + + [Fact] + public async Task Type_SingleType_SelectPopulatedSection_NoEmptyNote() + { + var options = new TypeOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + Select = ["Methods"] + }; + + var (exit, _, error) = await ConsoleCapture.RunAsync( + () => TypeCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + Assert.DoesNotContain("has no data", error); + } + + [Fact] + public async Task Type_SingleType_JsonWithSelect_ScopesToSection() + { + var options = new TypeOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + JsonOutput = true, + Select = ["Properties"] + }; + + var (exit, output, _) = await ConsoleCapture.RunAsync( + () => TypeCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + using var doc = JsonDocument.Parse(output); + var members = doc.RootElement.GetProperty("members"); + Assert.True(members.GetArrayLength() > 0); + foreach (var m in members.EnumerateArray()) + Assert.Equal("property", m.GetProperty("kind").GetString()); + // Non-selected facets are scoped out. + Assert.Empty(doc.RootElement.GetProperty("interfaces").EnumerateArray()); + } + + [Fact] + public async Task Type_SingleType_JsonWithSelectEmptySection_EmptyMembersAndNote() + { + var options = new TypeOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + JsonOutput = true, + Select = ["Values"] + }; + + var (exit, output, error) = await ConsoleCapture.RunAsync( + () => TypeCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + using var doc = JsonDocument.Parse(output); + Assert.Empty(doc.RootElement.GetProperty("members").EnumerateArray()); + Assert.Contains("section 'Values' has no data", error); + } + + [Fact] + public async Task Type_SingleType_DiscoverMethods_Schema_ListsAllColumns() { var options = new TypeOptions { PlatformAssembly = "System.Text.Json", TypeName = "JsonSerializer", - Discover = ["Methods"] + Discover = ["Methods"], + Schema = true }; var (exit, output, _) = await ConsoleCapture.RunAsync( @@ -150,6 +307,48 @@ public async Task Type_SingleType_DiscoverMethods_Works() Assert.Contains("Signature", output); } + [Fact] + public async Task Type_SingleType_Discover_DefaultsToEffective_DropsEmptySections() + { + // -D with no --schema now defaults to effective discovery: it resolves the source + // and lists only sections that actually have data (the empty-section footgun fix). + var options = new TypeOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + Discover = [] + }; + + var (exit, output, _) = await ConsoleCapture.RunAsync( + () => TypeCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + Assert.Contains("| Methods | section |", output); + // JsonSerializer has no custom attributes, so effective-by-default must drop it. + Assert.DoesNotContain("| Custom Attributes | section |", output); + } + + [Fact] + public async Task Type_SingleType_DiscoverSchema_ListsAllStaticSections() + { + // --schema opts back out to the cheap, offline static schema listing, which + // includes sections that may have no data (e.g. Custom Attributes, Fields). + var options = new TypeOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + Discover = [], + Schema = true + }; + + var (exit, output, _) = await ConsoleCapture.RunAsync( + () => TypeCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + Assert.Contains("| Custom Attributes | section |", output); + Assert.Contains("| Fields | section |", output); + } + [Fact] public async Task Type_SingleType_DiscoverEffective_OnlyShowsSectionsWithData() { @@ -169,6 +368,304 @@ public async Task Type_SingleType_DiscoverEffective_OnlyShowsSectionsWithData() Assert.DoesNotContain("| Fields | section |", output); } + [Fact] + public async Task Type_SingleType_DiscoverEffective_ExcludesMemberDetailCodeSections() + { + var options = new TypeOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + Discover = [], + Effective = true + }; + + var (exit, output, _) = await ConsoleCapture.RunAsync( + () => TypeCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + Assert.Contains("| Properties | section |", output); + Assert.Contains("| Methods | section |", output); + // Code sections (Source, IL, IL (Annotated), Lowered C#) are member-detail + // sections not present in the type schema. They must not appear in effective + // discovery, since they are not queryable via -D
. + Assert.DoesNotContain("| Source | section |", output); + Assert.DoesNotContain("| IL | section |", output); + Assert.DoesNotContain("| IL (Annotated) | section |", output); + Assert.DoesNotContain("| Lowered C# | section |", output); + } + + [Fact] + public async Task Type_SingleType_DiscoverEffective_ExcludesEmptyCustomAttributesSection() + { + var options = new TypeOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + Discover = [], + Effective = true + }; + + var (exit, output, _) = await ConsoleCapture.RunAsync( + () => TypeCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + Assert.Contains("| Methods | section |", output); + // Custom Attributes is in the type schema, but its CanRender probe is a coarse + // "type has methods" proxy; the section only has data when a specific member's + // attributes are read. JsonSerializer has none, so effective discovery must not list it. + Assert.DoesNotContain("| Custom Attributes | section |", output); + } + + [Fact] + public async Task Type_DiscoverEmptySection_Effective_ReportsNoDataNote() + { + var options = new TypeOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + Discover = ["Custom Attributes"], + Effective = true + }; + + var (exit, output, error) = await ConsoleCapture.RunAsync( + () => TypeCommand.ExecuteAsync(options)); + + // A valid-but-empty section reports a clear "no data" note rather than the + // misleading "Section not found", and exits 0. + Assert.Equal(0, exit); + Assert.Contains("section 'Custom Attributes' has no data", error); + Assert.DoesNotContain("not found", error); + Assert.DoesNotContain("| Name | column |", output); + } + + [Fact] + public async Task Type_DiscoverUnknownSection_Effective_ReportsNotFound() + { + var options = new TypeOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + Discover = ["Bogus"], + Effective = true + }; + + var (exit, _, error) = await ConsoleCapture.RunAsync( + () => TypeCommand.ExecuteAsync(options)); + + // A genuinely unknown section still reports "not found" (with suggestions) and exits 1. + Assert.Equal(1, exit); + Assert.Contains("Section 'Bogus' not found", error); + } + + [Fact] + public async Task Type_DiscoverSection_WithoutEffective_HidesSelectColumn() + { + var options = new TypeOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + Discover = ["Properties"], + Schema = true + }; + + var (exit, output, _) = await ConsoleCapture.RunAsync( + () => TypeCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + // The Select overload-index column only renders with --show-index, so plain + // single-type discovery hides it while still listing the real columns. + Assert.DoesNotContain("| Select | column |", output); + Assert.Contains("| Name | column |", output); + } + + [Fact] + public async Task Member_DiscoverSection_ShowIndex_ListsSelectColumn() + { + var options = new MemberOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + Discover = ["Properties"], + ShowSelect = true, + Schema = true + }; + + var (exit, output, _) = await ConsoleCapture.RunAsync( + () => MemberCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + // With --show-index the Select column does render, so discovery must list it. + Assert.Contains("| Select | column |", output); + } + + [Fact] + public async Task Member_DiscoverEffective_HidesSelectColumn() + { + var options = new MemberOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + Discover = ["Methods"], + Effective = true + }; + + var (exit, output, _) = await ConsoleCapture.RunAsync( + () => MemberCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + // Without --show-index the Select column is not queryable, so effective + // discovery must not list it (regression: member effective used to leak it). + Assert.DoesNotContain("| Select | column |", output); + Assert.Contains("| Name | column |", output); + } + + [Fact] + public async Task Member_DiscoverEffective_ShowIndex_ListsSelectColumn() + { + var options = new MemberOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + Discover = ["Methods"], + Effective = true, + ShowSelect = true + }; + + var (exit, output, _) = await ConsoleCapture.RunAsync( + () => MemberCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + // With --show-index the Select column renders, so effective discovery lists it. + Assert.Contains("| Select | column |", output); + } + + [Fact] + public async Task Type_SelectWithUnknownColumn_WarnsNotFound() + { + var options = new TypeOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + Select = ["Properties"], + Columns = ["Bogus"] + }; + + var (exit, _, error) = await ConsoleCapture.RunAsync( + () => TypeCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + Assert.Contains("column 'Bogus' not found in section 'Properties'", error); + } + + [Fact] + public async Task Type_SelectWithSelectColumn_WarnsNoData() + { + // Select is valid in the schema but only renders with --show-index, so on the + // plain type path it produces no data and must be flagged (not silently ignored). + var options = new TypeOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + Select = ["Properties"], + Columns = ["Select"] + }; + + var (exit, _, error) = await ConsoleCapture.RunAsync( + () => TypeCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + Assert.Contains("no data: Select", error); + } + + [Fact] + public async Task Type_SelectWithColumnNotShownAtVerbosity_WarnsNoData() + { + // Signature is a valid Properties column but only renders at Detailed verbosity; + // at the default verbosity it is absent and must be flagged. + var options = new TypeOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + Select = ["Properties"], + Columns = ["Signature"] + }; + + var (exit, _, error) = await ConsoleCapture.RunAsync( + () => TypeCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + Assert.Contains("no data: Signature", error); + } + + [Fact] + public async Task Type_SelectWithValidColumn_NoWarning() + { + var options = new TypeOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + Select = ["Properties"], + Columns = ["Name"] + }; + + var (exit, output, error) = await ConsoleCapture.RunAsync( + () => TypeCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + Assert.Contains("| Name |", output); + Assert.DoesNotContain("not found", error); + Assert.DoesNotContain("no data", error); + } + + [Fact] + public async Task Type_DiscoverSection_Effective_DropsSelectColumn() + { + var options = new TypeOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + Discover = ["Properties"], + Effective = true + }; + + var (exit, output, _) = await ConsoleCapture.RunAsync( + () => TypeCommand.ExecuteAsync(options)); + + Assert.Equal(0, exit); + // Effective discovery reports only columns that actually render. Select is hidden + // without --show-index, so it must not appear. + Assert.DoesNotContain("| Select | column |", output); + Assert.Contains("| Name | column |", output); + } + + [Fact] + public async Task Type_DiscoverSection_Effective_ReflectsVerbosityColumns() + { + // At default (Minimal) verbosity, Properties renders the summary row (Return Type/Accessors). + var minimal = new TypeOptions + { + PlatformAssembly = "System.Text.Json", + TypeName = "JsonSerializer", + Discover = ["Properties"], + Effective = true + }; + var (exitMin, minOutput, _) = await ConsoleCapture.RunAsync( + () => TypeCommand.ExecuteAsync(minimal)); + + Assert.Equal(0, exitMin); + Assert.Contains("| Return Type | column |", minOutput); + Assert.DoesNotContain("| Signature | column |", minOutput); + + // At Detailed verbosity, Properties renders the full member row (Signature, no Return Type). + var detailed = minimal with { Verbosity = Verbosity.Detailed }; + var (exitDet, detOutput, _) = await ConsoleCapture.RunAsync( + () => TypeCommand.ExecuteAsync(detailed)); + + Assert.Equal(0, exitDet); + Assert.Contains("| Signature | column |", detOutput); + Assert.DoesNotContain("| Return Type | column |", detOutput); + } + [Fact] public async Task Api_NonexistentPackage_ShowsError() { diff --git a/src/dotnet-inspect.Tests/CommandLineTests.cs b/src/dotnet-inspect.Tests/CommandLineTests.cs index 71b8199e..6a519a3a 100644 --- a/src/dotnet-inspect.Tests/CommandLineTests.cs +++ b/src/dotnet-inspect.Tests/CommandLineTests.cs @@ -99,6 +99,15 @@ public void PackageCommand_WithPreviewAlias_ParsesCorrectly() Assert.Empty(result2.Errors); } + [Fact] + public void Router_WithCompactFlag_ParsesCorrectly() + { + var args = CommandLineBuilder.PreprocessArgs(["System.Text.Json.JsonSerializer", "--json", "--compact"]); + var result = CommandLineBuilder.CreateRootCommand().Parse(args); + + Assert.Empty(result.Errors); + } + [Fact] public void PackageCommand_WithDependencies_ParsesCorrectly() { diff --git a/src/dotnet-inspect.Tests/Parsers/MemberOptionsParserTests.cs b/src/dotnet-inspect.Tests/Parsers/MemberOptionsParserTests.cs index b575cd8b..aabfb660 100644 --- a/src/dotnet-inspect.Tests/Parsers/MemberOptionsParserTests.cs +++ b/src/dotnet-inspect.Tests/Parsers/MemberOptionsParserTests.cs @@ -317,4 +317,22 @@ public async Task EffectiveFlag_SetsEffectiveMode() Assert.True(options.Effective); } + + [Fact] + public async Task Discover_DefaultsToEffectiveDiscovery() + { + var options = await ParseSuccessAsync("member", "JsonSerializer", "--package", "System.Text.Json", "-D"); + + Assert.False(options.Schema); + Assert.True(options.EffectiveDiscovery); + } + + [Fact] + public async Task SchemaFlag_OptsOutOfEffectiveDiscovery() + { + var options = await ParseSuccessAsync("member", "JsonSerializer", "--package", "System.Text.Json", "-D", "--schema"); + + Assert.True(options.Schema); + Assert.False(options.EffectiveDiscovery); + } } diff --git a/src/dotnet-inspect/CommandLine/Commands/RouterCommandDefinition.cs b/src/dotnet-inspect/CommandLine/Commands/RouterCommandDefinition.cs index 00f0b8a6..ddc61742 100644 --- a/src/dotnet-inspect/CommandLine/Commands/RouterCommandDefinition.cs +++ b/src/dotnet-inspect/CommandLine/Commands/RouterCommandDefinition.cs @@ -50,9 +50,12 @@ public static Command Create(SharedOptions opts) routerPrereleaseOption.Aliases.Add("--prerelease"); routerCommand.Options.Add(routerPrereleaseOption); + var routerCompactOption = new Option("--compact") { Description = "Output as minified JSON (use with --json)" }; + routerCommand.Options.Add(routerCompactOption); + var commandArgs = new RouterOptionsParser.RouterCommandArgs( packageNameArg, routerVersionOption, routerLatestVersionOption, routerVersionsOption, - routerPrereleaseOption, routerOneLineOption, routerNoHeaderOption); + routerPrereleaseOption, routerOneLineOption, routerNoHeaderOption, routerCompactOption); routerCommand.SetAction(async (parseResult, ct) => { @@ -81,7 +84,7 @@ public static Command Create(SharedOptions opts) return await AssemblyCommand.ExecuteAsync(route.Options); case RouterOptionsParser.RouteToPlatformAssembly route: - return await ExecutePlatformAssemblyAsync(route, opts, parseResult); + return await ExecutePlatformAssemblyAsync(route, opts, parseResult, commandArgs); case RouterOptionsParser.HandleVersionQuery query: return await ExecuteVersionQueryAsync(query, opts, parseResult, routerVersionsOption); @@ -103,7 +106,8 @@ public static Command Create(SharedOptions opts) private static async Task ExecutePlatformAssemblyAsync( RouterOptionsParser.RouteToPlatformAssembly route, SharedOptions opts, - ParseResult parseResult) + ParseResult parseResult, + RouterOptionsParser.RouterCommandArgs commandArgs) { bool verbose = route.Options.Verbose; Action? log = verbose ? msg => Console.Error.WriteLine(msg) : null; @@ -143,6 +147,7 @@ private static async Task ExecutePlatformAssemblyAsync( OneLineExplicitlySet = route.Options.OneLineExplicitlySet, FormatExplicitlySet = route.Options.FormatExplicitlySet, NoHeader = route.NoHeader, + CompactJson = parseResult.GetValue(commandArgs.CompactOption), Verbose = route.Options.Verbose, Verbosity = route.Verbosity, IncludeSections = null, @@ -152,6 +157,7 @@ private static async Task ExecutePlatformAssemblyAsync( Columns = route.Options.Columns, Fields = route.Options.Fields, Effective = route.Options.Effective, + Schema = opts.ParseSchema(parseResult), SourceOptions = route.Options.SourceOptions, TipLevel = ArgumentPreprocessor.HeadLines != null || ArgumentPreprocessor.TailLines != null ? TipLevel.Quiet : opts.ParseTipLevel(parseResult) }; @@ -284,6 +290,7 @@ private static async Task ExecuteTypeCommandAsync( OneLineExplicitlySet = parseResult.GetResult(commandArgs.OneLineOption) is { Implicit: false }, FormatExplicitlySet = opts.IsFormatExplicitlySet(parseResult, commandArgs.OneLineOption), NoHeader = parseResult.GetValue(commandArgs.NoHeaderOption), + CompactJson = parseResult.GetValue(commandArgs.CompactOption), Verbose = parseResult.GetValue(opts.Verbose), Verbosity = verbosity, Discover = opts.ParseDiscover(parseResult), @@ -292,6 +299,7 @@ private static async Task ExecuteTypeCommandAsync( Columns = opts.ParseColumns(parseResult), Fields = opts.ParseFields(parseResult), Effective = parseResult.GetValue(opts.Effective), + Schema = opts.ParseSchema(parseResult), SourceOptions = opts.ParseNuGetSourceOptions(parseResult), TipLevel = ArgumentPreprocessor.HeadLines != null || ArgumentPreprocessor.TailLines != null ? TipLevel.Quiet : opts.ParseTipLevel(parseResult) }; diff --git a/src/dotnet-inspect/CommandLine/Parsers/MemberOptionsParser.cs b/src/dotnet-inspect/CommandLine/Parsers/MemberOptionsParser.cs index b37f22f4..37f8d481 100644 --- a/src/dotnet-inspect/CommandLine/Parsers/MemberOptionsParser.cs +++ b/src/dotnet-inspect/CommandLine/Parsers/MemberOptionsParser.cs @@ -201,6 +201,7 @@ public static async Task ParseAsync( Columns = opts.ParseColumns(parseResult), Fields = opts.ParseFields(parseResult), Effective = parseResult.GetValue(opts.Effective), + Schema = opts.ParseSchema(parseResult), Verbose = parseResult.GetValue(opts.Verbose), Verbosity = opts.ParseVerbosity(parseResult), SourceOptions = opts.ParseNuGetSourceOptions(parseResult) diff --git a/src/dotnet-inspect/CommandLine/Parsers/RouterOptionsParser.cs b/src/dotnet-inspect/CommandLine/Parsers/RouterOptionsParser.cs index 86484c77..f8a80b0f 100644 --- a/src/dotnet-inspect/CommandLine/Parsers/RouterOptionsParser.cs +++ b/src/dotnet-inspect/CommandLine/Parsers/RouterOptionsParser.cs @@ -22,7 +22,8 @@ public record RouterCommandArgs( Option VersionsOption, Option PrereleaseOption, Option OneLineOption, - Option NoHeaderOption); + Option NoHeaderOption, + Option CompactOption); /// /// Result of parsing router command options. diff --git a/src/dotnet-inspect/CommandLine/Parsers/TypeOptionsParser.cs b/src/dotnet-inspect/CommandLine/Parsers/TypeOptionsParser.cs index bc29de1b..9d0154e9 100644 --- a/src/dotnet-inspect/CommandLine/Parsers/TypeOptionsParser.cs +++ b/src/dotnet-inspect/CommandLine/Parsers/TypeOptionsParser.cs @@ -144,6 +144,7 @@ public static async Task ParseAsync( Columns = opts.ParseColumns(parseResult), Fields = opts.ParseFields(parseResult), Effective = parseResult.GetValue(opts.Effective), + Schema = opts.ParseSchema(parseResult), Verbose = parseResult.GetValue(opts.Verbose), Verbosity = opts.ParseVerbosity(parseResult), SourceOptions = opts.ParseNuGetSourceOptions(parseResult) diff --git a/src/dotnet-inspect/Commands/ApiCommand.cs b/src/dotnet-inspect/Commands/ApiCommand.cs index 53f1006a..72e1e8b2 100644 --- a/src/dotnet-inspect/Commands/ApiCommand.cs +++ b/src/dotnet-inspect/Commands/ApiCommand.cs @@ -43,7 +43,7 @@ public class ApiCommand KindFilter = options.KindFilter, UnsafeOnly = options.UnsafeOnly, IncludeSections = options.IncludeSections, Select = options.Select, Columns = options.Columns, Fields = options.Fields, - Effective = options.Effective, SourceOptions = options.SourceOptions, + Effective = options.Effective, Schema = options.Schema, SourceOptions = options.SourceOptions, TipLevel = options.TipLevel }) }; @@ -64,12 +64,18 @@ internal static (PreambleResult Result, int? Error) RunPreamble(ApiOptions optio bool singleTypeMode = options is MemberOptions || (hasTypeName && !typeNameIsGlob); var knownSections = singleTypeMode ? memberPipeline.AllSectionNames : typePipeline.AllSectionNames; - // Discovery mode: -D/--discover lists schema unless --effective is requested. - if (options.Discover != null && !options.Effective) + // Discovery mode: -D/--discover lists effective sections (resolves source) by + // default; --schema opts out to the cheap, offline static schema listing. + if (options.Discover != null && !options.EffectiveDiscovery) { var schema = singleTypeMode ? ApiViewContext.Default.GetSchemaInfo()!.ToDocumentSchema() : ApiViewContext.Default.GetSchemaInfo()!.ToDocumentSchema(); + + // Restrict plain discovery to columns/sections queryable under the active options. + if (singleTypeMode) + schema = ToQueryableSchema(schema, options); + return (null!, DiscoverOutput.Execute(options.Discover, schema, tree: options.Tree, json: options.JsonOutput, markdown: !options.OneLine && !options.JsonOutput)); } @@ -377,6 +383,33 @@ internal static void ApplySurfaceFilters(ApiSurface api, ApiOptions options, str } } + /// + /// Writes a stderr note when sections explicitly requested via -S matched the schema + /// but produced no data for this type (e.g. the enum-only "Values" section on a class). + /// This distinguishes "valid but empty" from a typo (which yields a "not found" error) + /// and from a silent empty render. Only meaningful for section-rendering output, so the + /// caller must skip JSON (ignores -S), shape, and one-line output. + /// + internal static void WarnEmptySelectedSections(ApiType type, ApiOptions options, SectionPipeline pipeline) + { + if (options.IncludeSections is not { Count: > 0 }) + return; + + var filtered = BuildFilteredTypeForSections(type, options); + var (empty, _) = pipeline.GetEmptySections(filtered, options.Verbosity, options.IncludeSections); + if (empty.Count == 0) + return; + + bool filtersActive = options.MemberFilter.Count > 0 || options.KindFilter.Count > 0 + || options.UnsafeOnly || options.Limit.HasValue; + var suffix = filtersActive ? " after filters" : ""; + + if (empty.Count == 1) + Console.Error.WriteLine($"Note: section '{empty[0]}' has no data for {type.FullName}{suffix}."); + else + Console.Error.WriteLine($"Note: {empty.Count} sections have no data for {type.FullName}{suffix}: {string.Join(", ", empty)}."); + } + internal static ApiType BuildFilteredTypeForSections(ApiType type, ApiOptions options) { var members = type.Members.Where(m => !MemberFilters.IsCompilerGenerated(m.Name)); @@ -554,8 +587,10 @@ await SourceEnricher.AcquirePdbAsync(context, httpClient, // ===== Single Type Rendering ===== - internal static void WriteTypeOutput(ApiType type, string? foundIn, string? packageName, string? packageVersion, string? apiSource, string? selectedTfm, ApiOptions options) + internal static void WriteTypeOutput(ApiType type, string? foundIn, string? packageName, string? packageVersion, string? apiSource, string? selectedTfm, ApiOptions options, TextWriter? output = null) { + var sink = output ?? Console.Out; + if (options is TypeOptions { ShapeOutput: true }) { ApiOutputFormatter.WriteShapeOutput(type, foundIn, packageName, packageVersion, options.MemberFilter, options.KindFilter); @@ -618,12 +653,12 @@ internal static void WriteTypeOutput(ApiType type, string? foundIn, string? pack { Projection = OutputFormatter.BuildProjection(options.Columns, options.Fields) }; - MarkoutSerializer.Serialize(oneLineView, Console.Out, new Markout.OneLineFormatter(showHeader: !options.NoHeader), ApiViewContext.Default, writerOpts); + MarkoutSerializer.Serialize(oneLineView, sink, new Markout.OneLineFormatter(showHeader: !options.NoHeader), ApiViewContext.Default, writerOpts); } else { var writerOptions = ApiOutputFormatter.BuildTypeWriterOptions(type, options); - var writer = new Markout.MarkoutWriter(Console.Out, options.CreateFormatter(), writerOptions); + var writer = new Markout.MarkoutWriter(sink, options.CreateFormatter(), writerOptions); ApiViewContext.Default.Serialize(view, writer); if (view.MemberCode != null) @@ -633,6 +668,97 @@ internal static void WriteTypeOutput(ApiType type, string? foundIn, string? pack } } + /// + /// Restricts a plain-discovery schema to the columns queryable under the active options. + /// The view schema is a union of all rendering variants, so it advertises columns that only + /// specific options surface. When the enabling option is off, the column is not queryable + /// (e.g. --columns Select does nothing), so it is hidden from discovery to keep what + /// is listed consistent with what the user can actually project. This is the option/contract + /// level gate (data-independent); the data-level gate is --effective. + /// + /// + /// Currently the only option-gated column is the Select overload-index column, which + /// is surfaced only by member --show-index. Add future gated columns here. + /// + internal static DocumentSchema ToQueryableSchema(DocumentSchema schema, ApiOptions options) + { + if (options is not MemberOptions { ShowSelect: true }) + schema = DiscoverOutput.WithoutColumn(schema, "Select"); + return schema; + } + + /// + /// Executes effective discovery (-D --effective) for a single type. Shared by the + /// type and member commands so both paths apply identical queryability filtering: + /// + /// Section gate: drops pipeline + /// sections absent from the type schema (the member-detail code sections Source/IL/...), + /// then drops schema sections that + /// render no data for this type (e.g. Custom Attributes when the type has no attributes), + /// so every listed section is queryable via -D <Section> and actually has data. + /// Column gate: renders the + /// type at the active options and keeps only columns that appear, dropping columns the + /// active options never surface (e.g. Select without --show-index) and columns with no data + /// (e.g. Obsolete when no member is obsolete). + /// + /// This keeps effective discovery consistent with what the user can actually query and see. + /// + internal static int ExecuteEffectiveDiscovery( + ApiType apiType, SectionPipeline memberPipeline, ApiOptions options) + { + var fullSchema = ApiViewContext.Default.GetSchemaInfo()!.ToDocumentSchema(); + var filteredType = BuildFilteredTypeForSections(apiType, options); + var effective = memberPipeline.GetEffectiveSections(filteredType, Verbosity.Detailed, options.IncludeSections); + effective = DiscoverOutput.RestrictToSchemaSections(effective, fullSchema); + var rendered = RenderTypeSectionsMarkdown(filteredType, options); + effective = DiscoverOutput.RestrictToRenderedSections(effective, fullSchema, rendered); + var schema = DiscoverOutput.FilterSchemaToRenderedHeaders(effective, fullSchema, rendered); + return DiscoverOutput.ExecuteEffective(options.Discover, effective, schema, + tree: options.Tree, json: options.JsonOutput, markdown: !options.OneLine && !options.JsonOutput, + verbosity: (int)options.Verbosity, fullSchema: fullSchema); + } + + /// + /// Renders the type's member/enum sections to a markdown string for effective-column + /// discovery. Replicates 's verbosity branching so the + /// rendered table headers reflect exactly which columns the user would actually see + /// (e.g. summary columns at Minimal, full member columns at Detailed, the Select column + /// only with --show-index). Projection (--columns/--fields) is intentionally dropped so + /// the result reflects all renderable columns, not a user-narrowed subset. + /// + internal static string RenderTypeSectionsMarkdown(ApiType type, ApiOptions options) + { + var renderOptions = options with + { + Columns = null, + Fields = null, + PlainText = false, + JsonOutput = false, + OneLine = false, + }; + + var view = ApiOutputFormatter.BuildTypeView(type, null, null, null, null, null, renderOptions); + + if (type.Kind == "enum") + ApiOutputFormatter.PopulateEnumValues(view, type, renderOptions); + + bool isMember = renderOptions is MemberOptions; + if (view.EnumValues == null && view.EnumValuesWithDocs == null) + { + if (renderOptions.Verbosity == Verbosity.Minimal && !isMember) + ApiOutputFormatter.PopulateMemberSummarySections(view, type, renderOptions); + else + ApiOutputFormatter.PopulateMemberSections(view, type, renderOptions); + } + + var writerOptions = ApiOutputFormatter.BuildTypeWriterOptions(type, renderOptions); + var sw = new StringWriter(); + var writer = new MarkoutWriter(sw, new Markout.MarkdownFormatter(), writerOptions); + ApiViewContext.Default.Serialize(view, writer); + writer.Flush(); + return sw.ToString(); + } + private static void WriteJsonTypeOutput(ApiType type, ApiOptions options) { var outputType = type; @@ -647,7 +773,12 @@ private static void WriteJsonTypeOutput(ApiType type, ApiOptions options) if (options.Limit.HasValue && members.Count > options.Limit.Value) members = members.Take(options.Limit.Value).ToList(); - if (members != type.Members) + // -S/--select scopes JSON to the requested sections, mirroring the markdown view. + if (options.IncludeSections is { Count: > 0 } sections) + { + outputType = ProjectTypeToSections(type, members, sections); + } + else if (members != type.Members) { outputType = new ApiType { @@ -674,6 +805,67 @@ private static void WriteJsonTypeOutput(ApiType type, ApiOptions options) Console.WriteLine(JsonSerializer.Serialize(outputType, ApiTypeJsonContext.Default.ApiType)); } + /// + /// Maps each member section name to the predicate that selects its members. + /// + private static readonly Dictionary> MemberSectionPredicates = + new(StringComparer.OrdinalIgnoreCase) + { + [SectionNames.Values] = m => m.Kind == "field" && m.EnumValue.HasValue, + [SectionNames.Fields] = m => m.Kind == "field" && !m.EnumValue.HasValue, + [SectionNames.Properties] = m => m.Kind == "property", + [SectionNames.Methods] = m => m.Kind == "method", + [SectionNames.Constructors] = m => m.Kind == "constructor", + [SectionNames.Events] = m => m.Kind == "event", + }; + + /// + /// Builds a copy of scoped to the requested sections: members are + /// restricted to the selected member sections, and the Baseclass / Interfaces / Type + /// Parameters facets are retained only when their section is selected. Identity fields + /// (namespace, name, kind) are always preserved. + /// + private static ApiType ProjectTypeToSections(ApiType type, IEnumerable members, HashSet sections) + { + var predicates = MemberSectionPredicates + .Where(kv => sections.Contains(kv.Key)) + .Select(kv => kv.Value) + .ToList(); + + var scopedMembers = predicates.Count > 0 + ? members.Where(m => predicates.Any(p => p(m))).ToList() + : []; + + return new ApiType + { + Namespace = type.Namespace, + Name = type.Name, + Kind = type.Kind, + IsSealed = type.IsSealed, + IsAbstract = type.IsAbstract, + IsStatic = type.IsStatic, + BaseType = sections.Contains(SectionNames.Baseclass) && IsRenderableBaseType(type.BaseType) ? type.BaseType : null, + Interfaces = sections.Contains(SectionNames.TypeInterfaces) ? type.Interfaces : [], + TypeParameters = sections.Contains(SectionNames.TypeParameters) ? type.TypeParameters : [], + Members = scopedMembers, + SourceFilePath = type.SourceFilePath, + SourceUrl = type.SourceUrl, + GitHubBrowseUrl = type.GitHubBrowseUrl, + SourceLineNumber = type.SourceLineNumber, + Documentation = type.Documentation + }; + } + + /// + /// Mirrors the Baseclass section's CanRender: a base type is meaningful only when it is + /// present and not one of the implicit roots (Object/ValueType/Enum). + /// + private static bool IsRenderableBaseType(string? baseType) + => !string.IsNullOrEmpty(baseType) + && baseType is not ("System.Object" or "System.ValueType" or "System.Enum"); + + + // ===== Parameter Type Matching Helpers ===== internal static List ExtractParameterTypes(string signature) diff --git a/src/dotnet-inspect/Commands/MemberCommand.cs b/src/dotnet-inspect/Commands/MemberCommand.cs index 74dfc882..58cb07be 100644 --- a/src/dotnet-inspect/Commands/MemberCommand.cs +++ b/src/dotnet-inspect/Commands/MemberCommand.cs @@ -252,14 +252,9 @@ public static async Task ExecuteAsync(MemberOptions options) effectiveOptions = effectiveOptions with { MethodSource = methodSource }; } - if (effectiveOptions.Effective && effectiveOptions.Discover != null) + if (effectiveOptions.EffectiveDiscovery) { - var schema = ApiViewContext.Default.GetSchemaInfo()!.ToDocumentSchema(); - var filteredType = ApiCommand.BuildFilteredTypeForSections(apiType, effectiveOptions); - var effective = memberPipeline.GetEffectiveSections(filteredType, Verbosity.Detailed, effectiveOptions.IncludeSections); - return DiscoverOutput.ExecuteEffective(effectiveOptions.Discover, effective, schema, - tree: effectiveOptions.Tree, json: effectiveOptions.JsonOutput, markdown: !effectiveOptions.OneLine && !effectiveOptions.JsonOutput, - verbosity: (int)effectiveOptions.Verbosity); + return ApiCommand.ExecuteEffectiveDiscovery(apiType, memberPipeline, effectiveOptions); } diff --git a/src/dotnet-inspect/Commands/TypeCommand.cs b/src/dotnet-inspect/Commands/TypeCommand.cs index e511d10f..124d41a7 100644 --- a/src/dotnet-inspect/Commands/TypeCommand.cs +++ b/src/dotnet-inspect/Commands/TypeCommand.cs @@ -86,7 +86,7 @@ public static async Task ExecuteAsync(TypeOptions options) if (pdbLookupPath != null && listOptions.ShowDocs) SourceEnricher.EnrichFromLocalXmlDocs(api.Types, pdbLookupPath, listOptions, logger); - if (options.Effective && options.Discover != null) + if (options.EffectiveDiscovery) { ApiCommand.ApplySurfaceFilters(api, options, options.TypeFilter); var schema = ApiViewContext.Default.GetSchemaInfo()!.ToDocumentSchema(); @@ -184,10 +184,17 @@ public static async Task ExecuteAsync(TypeOptions options) if (!options.DocsExplicitlySet && options.Verbosity >= Verbosity.Normal) effectiveOptions = options with { ShowDocs = true }; - // Default --shape on for single-type view when no explicit format was chosen - if (!effectiveOptions.ShapeExplicitlySet && effectiveOptions.IsDefaultInvocation) + // Default --shape on for single-type view when no explicit format was + // chosen and the user is not running a section/projection query. Selection + // (-S) and projection (--columns/--fields) produce focused output, not the tree. + if (!effectiveOptions.ShapeExplicitlySet && effectiveOptions.IsDefaultInvocation && !effectiveOptions.HasSectionQuery) effectiveOptions = effectiveOptions with { ShapeOutput = true }; + // Explicit --shape cannot honor a section/projection query; warn rather than + // silently dropping the selection. + if (effectiveOptions is { ShapeOutput: true, HasSectionQuery: true }) + Console.Error.WriteLine("Warning: --shape does not support -S/--columns/--fields; selection was ignored."); + // Enrich with local XML docs only (source info is in the source command) { var dllPath = runtimeAssemblyPath ?? apiDllPath; @@ -195,17 +202,48 @@ public static async Task ExecuteAsync(TypeOptions options) SourceEnricher.EnrichFromLocalXmlDocs(apiType, dllPath, effectiveOptions, logger); } - if (effectiveOptions.Effective && effectiveOptions.Discover != null) + if (effectiveOptions.EffectiveDiscovery) { - var schema = ApiViewContext.Default.GetSchemaInfo()!.ToDocumentSchema(); - var filteredType = ApiCommand.BuildFilteredTypeForSections(apiType, effectiveOptions); - var effective = memberPipeline.GetEffectiveSections(filteredType, Verbosity.Detailed, effectiveOptions.IncludeSections); - return DiscoverOutput.ExecuteEffective(effectiveOptions.Discover, effective, schema, - tree: effectiveOptions.Tree, json: effectiveOptions.JsonOutput, markdown: !effectiveOptions.OneLine && !effectiveOptions.JsonOutput, - verbosity: (int)effectiveOptions.Verbosity); + return ApiCommand.ExecuteEffectiveDiscovery(apiType, memberPipeline, effectiveOptions); } - ApiCommand.WriteTypeOutput(apiType, foundIn, packageName, packageVersion, apiSource, selectedTfm, effectiveOptions); + bool hasProjection = effectiveOptions.Columns is { Length: > 0 } || effectiveOptions.Fields is { Length: > 0 }; + bool tabularProjection = hasProjection + && !effectiveOptions.JsonOutput + && effectiveOptions is not TypeOptions { ShapeOutput: true }; + + // Pre-render: validate --columns/--fields names against the section schema + // (catches typos) when a specific section is selected, mirroring the package path. + if (tabularProjection && effectiveOptions.IncludeSections is { Count: > 0 }) + { + var projSchema = ApiViewContext.Default.GetSchemaInfo()!.ToDocumentSchema(); + foreach (var section in effectiveOptions.IncludeSections) + ProjectionDiagnostics.ValidateProjection(projSchema, section, effectiveOptions.Fields, effectiveOptions.Columns); + } + + if (tabularProjection) + { + // Capture output so we can warn when a requested column produced no data + // (e.g. Select without --show-index, or a column not shown at this verbosity). + var sw = new StringWriter(); + ApiCommand.WriteTypeOutput(apiType, foundIn, packageName, packageVersion, apiSource, selectedTfm, effectiveOptions, sw); + var rendered = sw.ToString(); + ProjectionDiagnostics.DiagnoseRendered(effectiveOptions.Fields ?? effectiveOptions.Columns, rendered); + Console.Out.Write(rendered); + } + else + { + ApiCommand.WriteTypeOutput(apiType, foundIn, packageName, packageVersion, apiSource, selectedTfm, effectiveOptions); + } + + // Notify when a requested section matched but has no data for this type. + // JSON and markdown both honor -S; one-line falls back to showing all + // members and shape replaces selection, so skip those. + if (!effectiveOptions.OneLine + && effectiveOptions is not TypeOptions { ShapeOutput: true }) + { + ApiCommand.WarnEmptySelectedSections(apiType, effectiveOptions, memberPipeline); + } if (!effectiveOptions.IsRawOutput) { diff --git a/src/dotnet-inspect/Options/ApiOptions.cs b/src/dotnet-inspect/Options/ApiOptions.cs index ad6ed92d..66827fd9 100644 --- a/src/dotnet-inspect/Options/ApiOptions.cs +++ b/src/dotnet-inspect/Options/ApiOptions.cs @@ -62,13 +62,29 @@ public record ApiOptions public string[]? Columns { get; init; } public string[]? Fields { get; init; } public bool Effective { get; init; } + public bool Schema { get; init; } public TipLevel TipLevel { get; init; } = TipLevel.Minimal; + /// + /// True when discovery (-D) should resolve and load the source to report only the + /// sections/columns that actually have data (effective discovery). For type/member + /// queries this is the default; --schema opts out to the cheap, offline static + /// schema listing. The legacy --effective flag is now redundant but still honored. + /// + public bool EffectiveDiscovery => Discover != null && !Schema; + /// /// True when the user has opted into rich markdown output (via --markdown or -v:*). /// public bool VerbosityEnabled => !OneLine && !JsonOutput; + /// + /// True when the user is performing a section/projection query (-S/--columns/--fields). + /// Such queries produce a focused section view, not the default tree shape. + /// + public bool HasSectionQuery => + Select is { Length: > 0 } || Columns is { Length: > 0 } || Fields is { Length: > 0 }; + /// /// Returns the appropriate Markout formatter for the current output format. /// diff --git a/src/dotnet-inspect/Output/DiscoverOutput.cs b/src/dotnet-inspect/Output/DiscoverOutput.cs index 3ed49f3d..fb953958 100644 --- a/src/dotnet-inspect/Output/DiscoverOutput.cs +++ b/src/dotnet-inspect/Output/DiscoverOutput.cs @@ -59,7 +59,7 @@ public static int Execute(string[]? discover, DocumentSchema schema, /// public static int ExecuteEffective(string[]? discover, List effectiveSections, DocumentSchema schema, bool tree = false, bool markdown = false, bool json = false, int verbosity = 0, - string? rootLabel = null) + string? rootLabel = null, DocumentSchema? fullSchema = null) { // Build a filtered schema with only effective sections var filtered = new DocumentSchema(); @@ -71,9 +71,206 @@ public static int ExecuteEffective(string[]? discover, List effectiveSec else filtered.AddSection(name); } + + // For a specific section query, distinguish a valid section that simply has no data for + // this input from a genuinely unknown section. The full schema lets us recognize the + // former and report it clearly instead of the misleading "Section not found". + if (discover is { Length: > 0 } && fullSchema != null) + { + var remaining = FilterEmptyEffectiveSections(discover, filtered, fullSchema); + if (remaining == null) + return 0; + discover = remaining; + } + return Execute(discover, filtered, tree, markdown, json, verbosity, rootLabel); } + /// + /// Splits requested discovery sections into those still worth resolving and those that are + /// valid in the full schema but have no data under --effective. For the latter a + /// "has no data for this type" note is written (clearer than "Section not found", since the + /// section is real — just empty for this input). Truly unknown names are kept so the normal + /// discovery resolver can report them with suggestions. Returns the names to still resolve, + /// or null when every requested section was valid-but-empty (fully handled via notes). + /// + private static string[]? FilterEmptyEffectiveSections( + string[] discover, DocumentSchema effective, DocumentSchema fullSchema) + { + var remaining = new List(); + bool emittedNote = false; + foreach (var name in discover) + { + var (effMatches, _) = SelectResolver.ResolveSingle(name, effective.SectionNames, singleGlob: true); + if (effMatches.Count >= 1) + { + remaining.Add(name); + continue; + } + + var (fullMatches, _) = SelectResolver.ResolveSingle(name, fullSchema.SectionNames, singleGlob: true); + if (fullMatches.Count >= 1) + { + foreach (var match in fullMatches) + Console.Error.WriteLine($"note: section '{match}' has no data for this type"); + emittedNote = true; + } + else + { + remaining.Add(name); + } + } + + return remaining.Count == 0 && emittedNote ? null : remaining.ToArray(); + } + + /// + /// Restricts effective section names to those the discovery schema can represent. + /// The single-type member pipeline reports decompiler code sections (Source, IL, + /// IL (Annotated), Lowered C#) as renderable whenever the type has methods, but these + /// are member-detail sections produced only for a specific member selection — they are + /// not part of the type schema. Dropping them keeps -D --effective consistent + /// with -D and ensures every listed section is queryable via -D <Section>. + /// + public static List RestrictToSchemaSections(List effectiveSections, DocumentSchema schema) + => effectiveSections.Where(s => schema.GetSection(s) != null).ToList(); + + /// + /// Restricts effective sections to those that actually produced rendered output in the + /// supplied markdown rendering of the same view. A tabular schema section whose + /// CanRender probe passed but which rendered no table for this input is dropped, + /// since it has no data to query. This catches sections whose CanRender is a coarse + /// proxy — e.g. "Custom Attributes", gated on "the type has methods" but only populated + /// when a specific member's attributes are read (the member-detail path) — so + /// --effective reflects real data rather than mere potential. + /// Only tabular sections (those with schema columns) are subject to the drop; non-tabular + /// sections are left untouched because their content may not render as a markdown table. + /// This measures renderability in the current type effective-discovery render path + /// (RenderTypeSectionsMarkdown); a section populated only in another command path is + /// intentionally treated as having no data here. + /// + public static List RestrictToRenderedSections( + List effectiveSections, DocumentSchema schema, string rendered) + { + var renderedTables = ParseSectionHeaders(rendered); + return effectiveSections.Where(name => + { + var section = schema.GetSection(name); + bool tabular = section is { Items.Length: > 0 }; + return !tabular || renderedTables.ContainsKey(name); + }).ToList(); + } + + /// + /// Returns a copy of the schema with the named column removed from every section. + /// Used to hide the Select overload-index column from plain discovery unless --show-index + /// produced it (the column only renders for members when an overload index is shown). + /// + public static DocumentSchema WithoutColumn(DocumentSchema schema, string columnName) + { + var filtered = new DocumentSchema(); + foreach (var name in schema.SectionNames) + { + var section = schema.GetSection(name); + if (section == null) { filtered.AddSection(name); continue; } + + var items = section.Items + .Where(i => !string.Equals(i.Name, columnName, StringComparison.OrdinalIgnoreCase)) + .Select(i => i.Name) + .ToArray(); + + if (items.Length > 0) + filtered.Add(name, section.ItemKind, items); + else + filtered.AddSection(name); + } + return filtered; + } + + /// + /// Filters a schema's section columns to only those that actually render, given a + /// markdown rendering of the same view. Used by --effective discovery so the reported + /// columns match what the user would see at their current verbosity/options (e.g. the + /// Select overload-index column is dropped unless --show-index produced it, summary + /// columns replace detailed columns at Minimal verbosity, etc.). + /// Matches against section-scoped markdown table headers, not the rendered body, to + /// avoid false positives from column names appearing in member names or signatures. + /// + public static DocumentSchema FilterSchemaToRenderedHeaders( + List effectiveSections, DocumentSchema schema, string rendered) + { + var headersBySection = ParseSectionHeaders(rendered); + var filtered = new DocumentSchema(); + foreach (var name in effectiveSections) + { + var section = schema.GetSection(name); + if (section == null) { filtered.AddSection(name); continue; } + + // No table rendered for this section (e.g. a non-tabular section such as Source/IL, + // or one not produced by the member renderer): preserve the original schema columns + // rather than stripping them — we only narrow columns for sections we actually rendered. + if (!headersBySection.TryGetValue(name, out var headerCells)) + { + if (section.Items.Length > 0) + filtered.Add(name, section.ItemKind, section.Items.Select(i => i.Name).ToArray()); + else + filtered.AddSection(name); + continue; + } + + var effectiveItems = section.Items + .Where(item => headerCells.Contains(item.Name)) + .Select(item => item.Name) + .ToArray(); + + if (effectiveItems.Length > 0) + filtered.Add(name, section.ItemKind, effectiveItems); + else + filtered.AddSection(name); + } + return filtered; + } + + /// + /// Parses a rendered markdown document into a map of section heading text → the set of + /// column header cells from the first table under that heading. + /// + private static Dictionary> ParseSectionHeaders(string rendered) + { + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var lines = rendered.Split('\n'); + string? current = null; + for (int i = 0; i < lines.Length; i++) + { + var trimmed = lines[i].Trim(); + if (trimmed.StartsWith('#')) + { + current = trimmed.TrimStart('#').Trim(); + continue; + } + + // A table header is a pipe row immediately followed by a separator row. + if (current != null && !result.ContainsKey(current) + && trimmed.StartsWith('|') && i + 1 < lines.Length + && IsSeparatorRow(lines[i + 1])) + { + result[current] = new HashSet(ParseRowCells(trimmed), StringComparer.OrdinalIgnoreCase); + } + } + return result; + } + + private static bool IsSeparatorRow(string line) + { + var trimmed = line.Trim(); + if (!trimmed.StartsWith('|')) + return false; + return trimmed.All(c => c is '|' or '-' or ':' or ' '); + } + + private static IEnumerable ParseRowCells(string row) + => row.Trim().Trim('|').Split('|').Select(c => c.Trim()).Where(c => c.Length > 0); + private static List? GetDiscoveryRows(string[]? discover, DocumentSchema schema) { // Bare -D: list sections diff --git a/src/dotnet-inspect/Services/SharedOptions.cs b/src/dotnet-inspect/Services/SharedOptions.cs index f39ae919..d53a09f0 100644 --- a/src/dotnet-inspect/Services/SharedOptions.cs +++ b/src/dotnet-inspect/Services/SharedOptions.cs @@ -34,7 +34,8 @@ public class SharedOptions public Option Select { get; } public Option Columns { get; } public Option Fields { get; } - public Option Effective { get; } = new("--effective") { Description = "Show sections with data (runs full pipeline)" }; + public Option Effective { get; } = new("--effective") { Description = "Discover only sections with data (default for type/member -D; opt-in for package/assembly)" }; + public Option Schema { get; } = new("--schema") { Description = "With type/member -D: show the full static schema without resolving/loading source (offline)" }; public Option Tree { get; } = new("--tree") { Description = "Show discovery as a tree (sections → items)" }; // NuGet source options @@ -127,6 +128,7 @@ public void AddSectionOptionsTo(Command command) command.Options.Add(Columns); command.Options.Add(Fields); command.Options.Add(Effective); + command.Options.Add(Schema); command.Options.Add(Tree); } @@ -296,6 +298,19 @@ public bool IsDiscoveryMode(ParseResult parseResult) public bool ParseTree(ParseResult parseResult) => parseResult.GetValue(Tree); + /// + /// Resolves the --schema opt-out for type/member discovery. When both + /// --schema and the deprecated --effective are passed explicitly, + /// --schema wins and a warning is emitted. + /// + public bool ParseSchema(ParseResult parseResult) + { + var schema = parseResult.GetValue(Schema); + if (schema && IsDiscoveryMode(parseResult) && parseResult.GetResult(Effective) is { Implicit: false }) + Console.Error.WriteLine("Warning: --schema and --effective conflict; using --schema (static schema listing)."); + return schema; + } + private static string[]? ParseProjectionList(ParseResult parseResult, Option option) { var values = ParseCommaSeparatedList(parseResult.GetValue(option));