From 36de0d37272ebd21fe77d2803456e15b84e0f227 Mon Sep 17 00:00:00 2001 From: Phil Scott Date: Mon, 25 May 2026 00:16:36 -0400 Subject: [PATCH] feat(treesitter): add ,imports and ,signatures flags to :symbol fences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The :symbol fence could scope to a member but always dropped the file's import context, and there was no way to show a type's shape without dumping every body. Add two opt-in, comma-combinable flags: - ,imports prepends the file's top-of-file using/import/require lines - ,signatures elides member bodies to { … } for an outline view Both extend the existing config-driven tree walk. A shared TreeWalker, lifted from NamePathResolver, now drives name resolution, import collection, and outline elision. Flags flow through parsing → service → extractor in a new FragmentOptions record (replacing the bare bodyOnly bool); per-language ImportNodeTypes seed import collection. Imports and signatures apply to :symbol only — :symbol-diff still honors just ,bodyonly. Docs: new how-to sections with live examples, updated fence grammar and suffix table, and retired the now-false "no using prepend" caveat. --- docs/Pennington.Docs/CLAUDE.md | 9 +- .../code-samples/focused-code-samples.md | 36 ++++++++ .../reference/markdown/code-block-args.md | 7 +- .../Fragments/FragmentExtractor.cs | 47 ++++++++++- .../Fragments/FragmentOptions.cs | 17 ++++ .../Fragments/ISourceFragmentService.cs | 6 +- .../Fragments/ImportCollector.cs | 26 ++++++ .../Fragments/SourceFragmentService.cs | 14 +++- .../TreeSitterCodeBlockPreprocessor.cs | 48 ++++++----- .../Resolution/LanguageDeclarationConfig.cs | 6 ++ .../LanguageDeclarationConfigDefaults.cs | 11 +++ .../Resolution/NamePathResolver.cs | 20 +---- .../Resolution/TreeWalker.cs | 31 +++++++ .../Fragments/SignatureExtractionTests.cs | 77 +++++++++++++++++ .../Fragments/SourceFragmentServiceTests.cs | 84 +++++++++++++++++-- .../Integration/CodeBlockPipelineTests.cs | 28 +++++++ .../TreeSitterCodeBlockPreprocessorTests.cs | 44 +++++++--- .../Resolution/NamePathResolverTests.cs | 3 +- .../TreeSitterTestHelper.cs | 4 +- 19 files changed, 449 insertions(+), 69 deletions(-) create mode 100644 src/Pennington.TreeSitter/Fragments/FragmentOptions.cs create mode 100644 src/Pennington.TreeSitter/Fragments/ImportCollector.cs create mode 100644 src/Pennington.TreeSitter/Resolution/TreeWalker.cs create mode 100644 tests/Pennington.TreeSitter.Tests/Fragments/SignatureExtractionTests.cs diff --git a/docs/Pennington.Docs/CLAUDE.md b/docs/Pennington.Docs/CLAUDE.md index 59f64ea1..69da11b9 100644 --- a/docs/Pennington.Docs/CLAUDE.md +++ b/docs/Pennington.Docs/CLAUDE.md @@ -99,14 +99,21 @@ src/Pennington/Pipeline/ContentPipeline.cs > ContentPipeline.ParseAsync ### Diff two members — `:symbol-diff` Body must contain exactly 2 references (before/after), one per line. Supports the same `,bodyonly` suffix. +Two more `:symbol` flags, comma-combinable with `,bodyonly`: + +- **`,imports`** — prepends the file's top-of-file `using` / `import` / `require` statements above the snippet. Prepends *all* of them, not only the ones the member uses; no-op for whole-file embeds and import-less languages (Ruby). +- **`,signatures`** — elides member bodies to `{ … }` (or `…` for non-brace bodies) for an outline of a type's shape. Inverse of `,bodyonly`, so the two don't combine. + ### When to use which - **`:symbol`** — the default. A bare path embeds the whole file; `path > Type.Member` embeds one member. Language-agnostic. - **`:symbol,bodyonly`** — when the declaration is noise (showing what's inside a method, or a type's members without its header). +- **`:symbol,imports`** — when a member snippet needs its import context to make sense on its own. +- **`:symbol,signatures`** — when the point is a type's surface (its members) rather than how each one works. - **`:symbol-diff`** — before/after comparisons in explanation pages. ### Caveats - **Overloads** resolve to the first declaration of that name in the file — name-path addressing can't distinguish signatures. Point at an unambiguous member, or hand-write the snippet. -- **No `using` prepend.** Tree-sitter is syntactic; there is no `,usings` equivalent. Snippets render without import directives. +- **`,imports` is unfiltered.** It prepends every top-of-file import, not just the ones the snippet references — trim by hand when a member needs only a few. Requires `Pennington.TreeSitter` wired (`AddPenningtonTreeSitter`) with `ContentRoot` set. 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 c2ca47f7..266b5a7b 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 @@ -63,6 +63,24 @@ examples/FocusedCodeSamplesExample/MonolithWordCounter.cs > MonolithWordCounter. `,bodyonly` also works on types (members between the braces, skipping the type header) and properties (the `get`/`set` accessors). +## Carry the imports with `,imports` + +A member-scoped fence drops the file's `using` / `import` / `require` lines, so a reader can't see where the types resolve from. Append `,imports` to prepend the file's top-of-file imports above the snippet, separated by a blank line. + +````markdown +```csharp:symbol,imports +examples/FocusedCodeSamplesExample/MonolithWordCounter.cs > MonolithWordCounter.CountWords +``` +```` + +Which renders as: + +```csharp:symbol,imports +examples/FocusedCodeSamplesExample/MonolithWordCounter.cs > MonolithWordCounter.CountWords +``` + +`,imports` prepends every import at the top of the file, not only the ones the member references. It composes with `,bodyonly` — the imports lead, the body follows — and is a no-op for whole-file embeds and for languages with no syntactic import statement (Ruby's `require` is a method call, not a declaration). + ## 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 whole-type fence gives the reader the full picture in one place: @@ -125,6 +143,24 @@ examples/FocusedCodeSamplesExample/ModularWordCounter.cs > ModularWordCounter.Fo `: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 type's shape with `,signatures` + +When the point is a type's surface — what it exposes, not how each member works — append `,signatures`. Every member body collapses to `{ … }`, leaving the declarations, signatures, doc comments, and member order intact for an at-a-glance outline. + +````markdown +```csharp:symbol,signatures +examples/FocusedCodeSamplesExample/ModularWordCounter.cs > ModularWordCounter +``` +```` + +Which renders as: + +```csharp:symbol,signatures +examples/FocusedCodeSamplesExample/ModularWordCounter.cs > ModularWordCounter +``` + +`,signatures` works on a single member too, rendering just its signature over an elided body. It targets brace-delimited languages (C#, Java, TypeScript, Go, Rust); Python and Ruby suites collapse to a best-effort `…`. As the inverse of `,bodyonly`, the two don't combine — `,signatures` wins when both are set. + ## 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 `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. 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 bf5b1797..eb3eceee 100644 --- a/docs/Pennington.Docs/Content/reference/markdown/code-block-args.md +++ b/docs/Pennington.Docs/Content/reference/markdown/code-block-args.md @@ -14,8 +14,9 @@ The fence info-string grammar: the opening-fence text after the three backticks, ```text info-string := language [ ":" suffix ] ( WS attribute )* language := IDENT -suffix := "symbol" [ ",bodyonly" ] +suffix := "symbol" symbol-flag* | "symbol-diff" [ ",bodyonly" ] +symbol-flag := ",bodyonly" | ",imports" | ",signatures" attribute := key "=" value key := IDENT value := bare-value | "'" quoted-value "'" | '"' quoted-value '"' @@ -38,9 +39,11 @@ quoted-value := any chars up to the matching quote |---|---|---| | `: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,imports` | same as `:symbol` | Prepends the file's top-of-file import/using/require statements above the snippet. Composes with `,bodyonly`; a no-op for whole-file embeds and import-less languages. | +| `:symbol,signatures` | same as `:symbol` | Replaces member bodies with an elision marker (`{ … }`, or `…` for non-brace bodies) for an outline view. Mutually exclusive with `,bodyonly`. | | `: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.TreeSitter` ships the implementations for `symbol` and `symbol-diff`. +The `symbol` flags (`,bodyonly`, `,imports`, `,signatures`) are comma-separated and order-independent. Suffix forms are resolved by an `ICodeBlockPreprocessor`; `Pennington.TreeSitter` ships the implementations for `symbol` and `symbol-diff`. ## `[!code …]` directives diff --git a/src/Pennington.TreeSitter/Fragments/FragmentExtractor.cs b/src/Pennington.TreeSitter/Fragments/FragmentExtractor.cs index a3f71ff9..ff48bdb2 100644 --- a/src/Pennington.TreeSitter/Fragments/FragmentExtractor.cs +++ b/src/Pennington.TreeSitter/Fragments/FragmentExtractor.cs @@ -7,12 +7,18 @@ namespace Pennington.TreeSitter.Fragments; internal static class FragmentExtractor { /// - /// Returns the declaration's full source text, or its body when is set, dedented - /// so the fragment renders at column zero. + /// Returns the declaration's source text, dedented so the fragment renders at column zero. Honors + /// : elides member bodies (outline + /// view), otherwise returns just the body. /// - public static string Extract(TsNode node, LanguageDeclarationConfig config, bool bodyOnly) + public static string Extract(TsNode node, LanguageDeclarationConfig config, FragmentOptions options) { - if (!bodyOnly) + if (options.SignaturesOnly) + { + return ExtractSignatures(node, config); + } + + if (!options.BodyOnly) { return DedentByColumn(node.Text, node.StartPosition.Column); } @@ -35,6 +41,39 @@ public static string Extract(TsNode node, LanguageDeclarationConfig config, bool return DedentByMin(text); } + /// + /// Renders the node as an outline: each member's body (or, for a single member, its own body) is replaced + /// with an elision marker — { … } for brace-delimited bodies, otherwise — leaving signatures, + /// member order, and the enclosing declaration intact. + /// + private static string ExtractSignatures(TsNode node, LanguageDeclarationConfig config) + { + var members = TreeWalker + .ChildrenMatching(node, config.TransparentNodeTypes, config.DeclarationNodeTypes) + .ToList(); + + // A type elides its members' bodies; a lone member (no nested declarations) elides its own body. + var targets = members.Count > 0 ? members : [node]; + + var bodies = targets + .Select(target => target.GetChildForField(config.BodyFieldName)) + .Where(body => body is not null) + .Select(body => body!.Text.Replace("\r\n", "\n")) + .Where(text => text.Length > 0) + .Distinct() + .OrderByDescending(text => text.Length) + .ToList(); + + var outline = node.Text.Replace("\r\n", "\n"); + foreach (var body in bodies) + { + var marker = body.TrimStart().StartsWith('{') ? "{ … }" : "…"; + outline = outline.Replace(body, marker); + } + + return DedentByColumn(outline, node.StartPosition.Column); + } + /// /// Removes up to leading whitespace characters from every line after the first. /// The node's own first line already starts at the declaration, so only continuation lines carry the diff --git a/src/Pennington.TreeSitter/Fragments/FragmentOptions.cs b/src/Pennington.TreeSitter/Fragments/FragmentOptions.cs new file mode 100644 index 00000000..439ae11f --- /dev/null +++ b/src/Pennington.TreeSitter/Fragments/FragmentOptions.cs @@ -0,0 +1,17 @@ +namespace Pennington.TreeSitter.Fragments; + +/// Per-reference extraction flags parsed from a :symbol info-string. +public sealed record FragmentOptions +{ + /// Emit only the declaration's body, stripping the signature and enclosing braces. + public bool BodyOnly { get; init; } + + /// Prepend the file's top-of-file import/using/require statements to the fragment. + public bool IncludeImports { get; init; } + + /// Render the node with member bodies replaced by an elision marker (outline view). + public bool SignaturesOnly { get; init; } + + /// The default: full declaration text, no imports, no elision. + public static FragmentOptions Default { get; } = new(); +} diff --git a/src/Pennington.TreeSitter/Fragments/ISourceFragmentService.cs b/src/Pennington.TreeSitter/Fragments/ISourceFragmentService.cs index f5746a29..658f011b 100644 --- a/src/Pennington.TreeSitter/Fragments/ISourceFragmentService.cs +++ b/src/Pennington.TreeSitter/Fragments/ISourceFragmentService.cs @@ -5,10 +5,10 @@ public interface ISourceFragmentService { /// /// Returns the source text of within , or a - /// failure. An empty returns the whole file. When is - /// set, only the matched declaration's body is returned. + /// failure. An empty returns the whole file. select + /// body-only extraction, an elided-body outline, and whether the file's imports are prepended. /// - FragmentResult GetFragment(string languageId, string relativeFilePath, string namePath, bool bodyOnly); + FragmentResult GetFragment(string languageId, string relativeFilePath, string namePath, FragmentOptions options); } /// Outcome of a fragment lookup: either the extracted or an message. diff --git a/src/Pennington.TreeSitter/Fragments/ImportCollector.cs b/src/Pennington.TreeSitter/Fragments/ImportCollector.cs new file mode 100644 index 00000000..4a3a4f45 --- /dev/null +++ b/src/Pennington.TreeSitter/Fragments/ImportCollector.cs @@ -0,0 +1,26 @@ +namespace Pennington.TreeSitter.Fragments; + +using Resolution; +using TsNode = global::TreeSitter.Node; + +/// Collects a file's top-of-file import/using/require statements for prepending to a fragment. +internal static class ImportCollector +{ + /// + /// Returns the source text of every import node under in document order, joined by + /// newlines, or an empty string when the language declares no import node types or the file has none. + /// + public static string Collect(TsNode root, LanguageDeclarationConfig config) + { + if (config.ImportNodeTypes.Count == 0) + { + return string.Empty; + } + + var imports = TreeWalker + .ChildrenMatching(root, config.TransparentNodeTypes, config.ImportNodeTypes) + .Select(node => node.Text.Replace("\r\n", "\n")); + + return string.Join("\n", imports); + } +} diff --git a/src/Pennington.TreeSitter/Fragments/SourceFragmentService.cs b/src/Pennington.TreeSitter/Fragments/SourceFragmentService.cs index 2174ce72..ea214b9d 100644 --- a/src/Pennington.TreeSitter/Fragments/SourceFragmentService.cs +++ b/src/Pennington.TreeSitter/Fragments/SourceFragmentService.cs @@ -17,7 +17,7 @@ public SourceFragmentService(TreeSitterOptions options, ITreeSitterParserPool po _resolver = resolver; } - public FragmentResult GetFragment(string languageId, string relativeFilePath, string namePath, bool bodyOnly) + public FragmentResult GetFragment(string languageId, string relativeFilePath, string namePath, FragmentOptions options) { if (string.IsNullOrEmpty(_options.ContentRoot)) { @@ -63,6 +63,16 @@ public FragmentResult GetFragment(string languageId, string relativeFilePath, st return FragmentResult.Fail($"Member '{namePath}' not found in {relativeFilePath}."); } - return FragmentResult.Ok(FragmentExtractor.Extract(node, config, bodyOnly)); + var fragment = FragmentExtractor.Extract(node, config, options); + if (options.IncludeImports) + { + var imports = ImportCollector.Collect(tree.RootNode, config); + if (imports.Length > 0) + { + fragment = imports + "\n\n" + fragment; + } + } + + return FragmentResult.Ok(fragment); } } diff --git a/src/Pennington.TreeSitter/Preprocessing/TreeSitterCodeBlockPreprocessor.cs b/src/Pennington.TreeSitter/Preprocessing/TreeSitterCodeBlockPreprocessor.cs index ea9c318b..8f09c90b 100644 --- a/src/Pennington.TreeSitter/Preprocessing/TreeSitterCodeBlockPreprocessor.cs +++ b/src/Pennington.TreeSitter/Preprocessing/TreeSitterCodeBlockPreprocessor.cs @@ -39,16 +39,17 @@ public TreeSitterCodeBlockPreprocessor( /// public CodeBlockPreprocessResult? TryProcess(string code, string languageId) { - var (baseLanguage, modifier, bodyOnly) = ParseLanguageId(languageId); + var (baseLanguage, modifier, options) = ParseLanguageId(languageId); return modifier switch { - "symbol" => ProcessSymbol(baseLanguage, code, bodyOnly), - "symbol-diff" => ProcessSymbolDiff(baseLanguage, code, bodyOnly), + "symbol" => ProcessSymbol(baseLanguage, code, options), + // Imports and outline elision make no sense across a diff; honor only the body-only flag. + "symbol-diff" => ProcessSymbolDiff(baseLanguage, code, new FragmentOptions { BodyOnly = options.BodyOnly }), _ => null, }; } - private CodeBlockPreprocessResult ProcessSymbol(string baseLanguage, string code, bool bodyOnly) + private CodeBlockPreprocessResult ProcessSymbol(string baseLanguage, string code, FragmentOptions options) { try { @@ -56,7 +57,7 @@ private CodeBlockPreprocessResult ProcessSymbol(string baseLanguage, string code foreach (var line in code.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var (filePath, namePath) = ParseReference(line); - var result = _fragmentService.GetFragment(baseLanguage, filePath, namePath, bodyOnly); + var result = _fragmentService.GetFragment(baseLanguage, filePath, namePath, options); if (!result.Succeeded) { Diagnostics?.AddWarning($"tree-sitter :symbol — {result.Error}"); @@ -80,7 +81,7 @@ private CodeBlockPreprocessResult ProcessSymbol(string baseLanguage, string code } } - private CodeBlockPreprocessResult ProcessSymbolDiff(string baseLanguage, string code, bool bodyOnly) + private CodeBlockPreprocessResult ProcessSymbolDiff(string baseLanguage, string code, FragmentOptions options) { try { @@ -92,8 +93,8 @@ private CodeBlockPreprocessResult ProcessSymbolDiff(string baseLanguage, string return new CodeBlockPreprocessResult(errorHtml, baseLanguage, SkipTransform: true); } - var fragment1 = ResolveFragment(baseLanguage, references[0], bodyOnly); - var fragment2 = ResolveFragment(baseLanguage, references[1], bodyOnly); + var fragment1 = ResolveFragment(baseLanguage, references[0], options); + var fragment2 = ResolveFragment(baseLanguage, references[1], options); if (!fragment1.Succeeded || !fragment2.Succeeded) { @@ -136,18 +137,18 @@ private CodeBlockPreprocessResult ProcessSymbolDiff(string baseLanguage, string } } - private FragmentResult ResolveFragment(string baseLanguage, string reference, bool bodyOnly) + private FragmentResult ResolveFragment(string baseLanguage, string reference, FragmentOptions options) { var (filePath, namePath) = ParseReference(reference); - return _fragmentService.GetFragment(baseLanguage, filePath, namePath, bodyOnly); + return _fragmentService.GetFragment(baseLanguage, filePath, namePath, options); } /// /// Splits an info-string such as python:symbol,bodyonly or python:symbol-diff into its base - /// language, the modifier (symbol, symbol-diff, or null when absent), and whether the - /// bodyonly flag is present. + /// language, the modifier (symbol, symbol-diff, or null when absent), and the + /// parsed from the comma-separated flag tail. /// - internal static (string baseLanguage, string? modifier, bool bodyOnly) ParseLanguageId(string languageId) + internal static (string baseLanguage, string? modifier, FragmentOptions options) ParseLanguageId(string languageId) { var trimmed = languageId.Trim(); const string diffMarker = ":symbol-diff"; @@ -157,22 +158,29 @@ internal static (string baseLanguage, string? modifier, bool bodyOnly) ParseLang var diffIndex = trimmed.IndexOf(diffMarker, StringComparison.OrdinalIgnoreCase); if (diffIndex >= 0) { - return (trimmed[..diffIndex], "symbol-diff", HasBodyOnly(trimmed[(diffIndex + diffMarker.Length)..])); + return (trimmed[..diffIndex], "symbol-diff", ParseFlags(trimmed[(diffIndex + diffMarker.Length)..])); } var index = trimmed.IndexOf(marker, StringComparison.OrdinalIgnoreCase); if (index < 0) { - return (trimmed, null, false); + return (trimmed, null, FragmentOptions.Default); } - return (trimmed[..index], "symbol", HasBodyOnly(trimmed[(index + marker.Length)..])); + return (trimmed[..index], "symbol", ParseFlags(trimmed[(index + marker.Length)..])); } - private static bool HasBodyOnly(string afterMarker) => - afterMarker - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Any(flag => flag.Equals("bodyonly", StringComparison.OrdinalIgnoreCase)); + /// Reads the comma-separated flag tail after a :symbol marker into a ; unknown flags are ignored. + private static FragmentOptions ParseFlags(string afterMarker) + { + var flags = afterMarker.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return new FragmentOptions + { + BodyOnly = flags.Any(flag => flag.Equals("bodyonly", StringComparison.OrdinalIgnoreCase)), + IncludeImports = flags.Any(flag => flag.Equals("imports", StringComparison.OrdinalIgnoreCase)), + SignaturesOnly = flags.Any(flag => flag.Equals("signatures", StringComparison.OrdinalIgnoreCase)), + }; + } /// Splits a body line into a file path and member name path on the " > " separator; a bare path yields an empty name path. internal static (string filePath, string namePath) ParseReference(string line) diff --git a/src/Pennington.TreeSitter/Resolution/LanguageDeclarationConfig.cs b/src/Pennington.TreeSitter/Resolution/LanguageDeclarationConfig.cs index 9b7b6ebb..8a94db4e 100644 --- a/src/Pennington.TreeSitter/Resolution/LanguageDeclarationConfig.cs +++ b/src/Pennington.TreeSitter/Resolution/LanguageDeclarationConfig.cs @@ -28,6 +28,12 @@ public sealed record LanguageDeclarationConfig /// Field name holding a declaration's body, used for body-only extraction. Defaults to body. public string BodyFieldName { get; init; } = "body"; + /// + /// File-level node types that count as import/using/require statements, used to prepend a snippet's + /// import context. Empty when the language has no syntactic import form the walker can collect. + /// + public IReadOnlySet ImportNodeTypes { get; init; } = new HashSet(); + /// Returns the field name to read for the given declaration node type's name. public string NameFieldFor(string nodeType) => NameFieldOverrides.TryGetValue(nodeType, out var field) ? field : DefaultNameField; diff --git a/src/Pennington.TreeSitter/Resolution/LanguageDeclarationConfigDefaults.cs b/src/Pennington.TreeSitter/Resolution/LanguageDeclarationConfigDefaults.cs index 472baf70..1e139101 100644 --- a/src/Pennington.TreeSitter/Resolution/LanguageDeclarationConfigDefaults.cs +++ b/src/Pennington.TreeSitter/Resolution/LanguageDeclarationConfigDefaults.cs @@ -46,6 +46,7 @@ private static void Add(Dictionary map, Langu { "declaration_list", "namespace_declaration", "file_scoped_namespace_declaration", }, + ImportNodeTypes = new HashSet { "using_directive" }, }; private static LanguageDeclarationConfig Python() => new() @@ -53,6 +54,10 @@ private static void Add(Dictionary map, Langu TreeSitterLanguageName = "Python", DeclarationNodeTypes = new HashSet { "class_definition", "function_definition" }, TransparentNodeTypes = new HashSet { "block", "decorated_definition" }, + ImportNodeTypes = new HashSet + { + "import_statement", "import_from_statement", "future_import_statement", + }, }; private static LanguageDeclarationConfig TypeScript() => new() @@ -64,6 +69,7 @@ private static void Add(Dictionary map, Langu "method_definition", "interface_declaration", "enum_declaration", }, TransparentNodeTypes = new HashSet { "class_body", "export_statement" }, + ImportNodeTypes = new HashSet { "import_statement" }, }; private static LanguageDeclarationConfig JavaScript() => new() @@ -74,6 +80,7 @@ private static void Add(Dictionary map, Langu "class_declaration", "function_declaration", "generator_function_declaration", "method_definition", }, TransparentNodeTypes = new HashSet { "class_body", "export_statement", "statement_block" }, + ImportNodeTypes = new HashSet { "import_statement" }, }; private static LanguageDeclarationConfig Java() => new() @@ -85,6 +92,7 @@ private static void Add(Dictionary map, Langu "annotation_type_declaration", "method_declaration", "constructor_declaration", }, TransparentNodeTypes = new HashSet { "class_body", "interface_body", "enum_body", "annotation_type_body" }, + ImportNodeTypes = new HashSet { "import_declaration" }, }; private static LanguageDeclarationConfig Ruby() => new() @@ -103,6 +111,7 @@ private static void Add(Dictionary map, Langu "method_declaration", "function_definition", }, TransparentNodeTypes = new HashSet { "declaration_list" }, + ImportNodeTypes = new HashSet { "namespace_use_declaration" }, }; private static LanguageDeclarationConfig Rust() => new() @@ -117,6 +126,7 @@ private static void Add(Dictionary map, Langu // descends into the impl as well as the struct (both match the `Calculator` segment). NameFieldOverrides = new Dictionary { ["impl_item"] = "type" }, TransparentNodeTypes = new HashSet { "declaration_list" }, + ImportNodeTypes = new HashSet { "use_declaration" }, }; private static LanguageDeclarationConfig Go() => new() @@ -124,5 +134,6 @@ private static void Add(Dictionary map, Langu TreeSitterLanguageName = "Go", DeclarationNodeTypes = new HashSet { "function_declaration", "method_declaration", "type_spec" }, TransparentNodeTypes = new HashSet { "type_declaration" }, + ImportNodeTypes = new HashSet { "import_declaration" }, }; } diff --git a/src/Pennington.TreeSitter/Resolution/NamePathResolver.cs b/src/Pennington.TreeSitter/Resolution/NamePathResolver.cs index b2cf4e2c..5953620e 100644 --- a/src/Pennington.TreeSitter/Resolution/NamePathResolver.cs +++ b/src/Pennington.TreeSitter/Resolution/NamePathResolver.cs @@ -25,7 +25,7 @@ public sealed class NamePathResolver var next = new List(); foreach (var node in frontier) { - foreach (var child in DeclarationChildren(node, config)) + foreach (var child in TreeWalker.ChildrenMatching(node, config.TransparentNodeTypes, config.DeclarationNodeTypes)) { if (EffectiveName(child, config) == segment) { @@ -47,22 +47,4 @@ public sealed class NamePathResolver private static string? EffectiveName(TsNode node, LanguageDeclarationConfig config) => node.GetChildForField(config.NameFieldFor(node.Type))?.Text; - - private static IEnumerable DeclarationChildren(TsNode parent, LanguageDeclarationConfig config) - { - foreach (var child in parent.NamedChildren) - { - if (config.TransparentNodeTypes.Contains(child.Type)) - { - foreach (var descendant in DeclarationChildren(child, config)) - { - yield return descendant; - } - } - else if (config.DeclarationNodeTypes.Contains(child.Type)) - { - yield return child; - } - } - } } diff --git a/src/Pennington.TreeSitter/Resolution/TreeWalker.cs b/src/Pennington.TreeSitter/Resolution/TreeWalker.cs new file mode 100644 index 00000000..97e4ed04 --- /dev/null +++ b/src/Pennington.TreeSitter/Resolution/TreeWalker.cs @@ -0,0 +1,31 @@ +namespace Pennington.TreeSitter.Resolution; + +using TsNode = global::TreeSitter.Node; + +/// Shared config-driven descent over a syntax tree's named children. +internal static class TreeWalker +{ + /// + /// Yields named children of whose type is in , descending + /// transparently through wrapper node types in so container nodes do not hide + /// the declarations beneath them. + /// + public static IEnumerable ChildrenMatching( + TsNode parent, IReadOnlySet transparent, IReadOnlySet match) + { + foreach (var child in parent.NamedChildren) + { + if (transparent.Contains(child.Type)) + { + foreach (var descendant in ChildrenMatching(child, transparent, match)) + { + yield return descendant; + } + } + else if (match.Contains(child.Type)) + { + yield return child; + } + } + } +} diff --git a/tests/Pennington.TreeSitter.Tests/Fragments/SignatureExtractionTests.cs b/tests/Pennington.TreeSitter.Tests/Fragments/SignatureExtractionTests.cs new file mode 100644 index 00000000..22b32d16 --- /dev/null +++ b/tests/Pennington.TreeSitter.Tests/Fragments/SignatureExtractionTests.cs @@ -0,0 +1,77 @@ +namespace Pennington.TreeSitter.Tests.Fragments; + +using Pennington.TreeSitter.Fragments; +using static TreeSitterTestHelper; + +/// Outline extraction: elides member bodies to a marker. +public sealed class SignatureExtractionTests +{ + private static readonly FragmentOptions Signatures = new() { SignaturesOnly = true }; + + [Fact] + public void CSharp_type_renders_member_signatures_with_elided_bodies() + { + const string source = """ + public class Calc + { + public int Add(int a, int b) + { + return a + b; + } + + public int Sub(int a, int b) + { + return a - b; + } + } + """; + + var outline = Extract("csharp", source, "Calc", Signatures); + + outline.ShouldNotBeNull(); + outline.ShouldContain("public class Calc"); + outline.ShouldContain("public int Add(int a, int b)"); + outline.ShouldContain("public int Sub(int a, int b)"); + outline.ShouldContain("{ … }"); + outline.ShouldNotContain("return a + b;"); + outline.ShouldNotContain("return a - b;"); + } + + [Fact] + public void CSharp_single_member_renders_just_its_signature() + { + const string source = """ + public class Calc + { + public int Add(int a, int b) + { + return a + b; + } + } + """; + + var outline = Extract("csharp", source, "Calc.Add", Signatures); + + outline.ShouldNotBeNull(); + outline.ShouldContain("public int Add(int a, int b)"); + outline.ShouldContain("{ … }"); + outline.ShouldNotContain("return a + b;"); + } + + [Fact] + public void Python_type_elides_suites_best_effort() + { + const string source = """ + class Calculator: + def add(self, a, b): + return a + b + """; + + var outline = Extract("python", source, "Calculator", Signatures); + + outline.ShouldNotBeNull(); + outline.ShouldContain("def add(self, a, b):"); + outline.ShouldContain("…"); + outline.ShouldNotContain("return a + b"); + } +} diff --git a/tests/Pennington.TreeSitter.Tests/Fragments/SourceFragmentServiceTests.cs b/tests/Pennington.TreeSitter.Tests/Fragments/SourceFragmentServiceTests.cs index 53ea6904..5147d6bb 100644 --- a/tests/Pennington.TreeSitter.Tests/Fragments/SourceFragmentServiceTests.cs +++ b/tests/Pennington.TreeSitter.Tests/Fragments/SourceFragmentServiceTests.cs @@ -29,7 +29,7 @@ public void Extracts_named_member_from_file() var service = CreateService(); WriteFile("calc.py", "class Calculator:\n def add(self, a, b):\n return a + b\n"); - var result = service.GetFragment("python", "calc.py", "Calculator.add", bodyOnly: false); + var result = service.GetFragment("python", "calc.py", "Calculator.add", FragmentOptions.Default); result.Succeeded.ShouldBeTrue(); result.Text!.ShouldContain("def add(self, a, b):"); @@ -41,7 +41,7 @@ public void Empty_name_path_returns_whole_file() var service = CreateService(); WriteFile("data.json", """{ "a": 1 }"""); - var result = service.GetFragment("json", "data.json", string.Empty, bodyOnly: false); + var result = service.GetFragment("json", "data.json", string.Empty, FragmentOptions.Default); result.Succeeded.ShouldBeTrue(); result.Text.ShouldBe("""{ "a": 1 }"""); @@ -52,7 +52,7 @@ public void Missing_file_fails() { var service = CreateService(); - var result = service.GetFragment("python", "nope.py", "X", bodyOnly: false); + var result = service.GetFragment("python", "nope.py", "X", FragmentOptions.Default); result.Succeeded.ShouldBeFalse(); result.Error!.ShouldContain("File not found"); @@ -63,7 +63,7 @@ public void Path_traversal_is_rejected() { var service = CreateService(); - var result = service.GetFragment("python", "../escape.py", "X", bodyOnly: false); + var result = service.GetFragment("python", "../escape.py", "X", FragmentOptions.Default); result.Succeeded.ShouldBeFalse(); result.Error!.ShouldContain("Invalid file path"); @@ -75,12 +75,86 @@ public void Unresolved_member_fails() var service = CreateService(); WriteFile("calc.py", "class Calculator:\n pass\n"); - var result = service.GetFragment("python", "calc.py", "Calculator.missing", bodyOnly: false); + var result = service.GetFragment("python", "calc.py", "Calculator.missing", FragmentOptions.Default); result.Succeeded.ShouldBeFalse(); result.Error!.ShouldContain("not found"); } + [Fact] + public void IncludeImports_prepends_csharp_usings_above_the_member() + { + var service = CreateService(); + WriteFile("Calc.cs", """ + using System; + using System.Text; + + namespace Sample; + + public class Calc + { + public int Add(int a, int b) => a + b; + } + """); + + var result = service.GetFragment("csharp", "Calc.cs", "Calc.Add", new FragmentOptions { IncludeImports = true }); + + result.Succeeded.ShouldBeTrue(); + result.Text!.ShouldBe(""" + using System; + using System.Text; + + public int Add(int a, int b) => a + b; + """); + } + + [Fact] + public void IncludeImports_prepends_python_imports() + { + var service = CreateService(); + WriteFile("calc.py", "import os\nfrom math import sqrt\n\nclass Calculator:\n def root(self):\n return sqrt(2)\n"); + + var result = service.GetFragment("python", "calc.py", "Calculator.root", new FragmentOptions { IncludeImports = true }); + + result.Succeeded.ShouldBeTrue(); + result.Text!.ShouldStartWith("import os\nfrom math import sqrt\n\n"); + result.Text!.ShouldContain("def root(self):"); + } + + [Fact] + public void IncludeImports_is_a_no_op_when_the_file_has_no_imports() + { + var service = CreateService(); + WriteFile("calc.py", "class Calculator:\n def add(self, a, b):\n return a + b\n"); + + var result = service.GetFragment("python", "calc.py", "Calculator.add", new FragmentOptions { IncludeImports = true }); + + result.Succeeded.ShouldBeTrue(); + result.Text!.ShouldStartWith("def add(self, a, b):"); + } + + [Fact] + public void IncludeImports_composes_with_bodyonly() + { + var service = CreateService(); + WriteFile("Calc.cs", """ + using System; + + public class Calc + { + public int Add(int a, int b) + { + return a + b; + } + } + """); + + var result = service.GetFragment("csharp", "Calc.cs", "Calc.Add", new FragmentOptions { BodyOnly = true, IncludeImports = true }); + + result.Succeeded.ShouldBeTrue(); + result.Text!.ShouldBe("using System;\n\nreturn a + b;"); + } + public void Dispose() { if (Directory.Exists(_root)) diff --git a/tests/Pennington.TreeSitter.Tests/Integration/CodeBlockPipelineTests.cs b/tests/Pennington.TreeSitter.Tests/Integration/CodeBlockPipelineTests.cs index 56f03d36..908d8da5 100644 --- a/tests/Pennington.TreeSitter.Tests/Integration/CodeBlockPipelineTests.cs +++ b/tests/Pennington.TreeSitter.Tests/Integration/CodeBlockPipelineTests.cs @@ -92,6 +92,34 @@ public void Symbol_diff_fence_renders_error_for_unresolved_member() html.ShouldContain("not found"); } + [Fact] + public void Symbol_fence_with_imports_prepends_file_imports() + { + var pipeline = CreatePipeline(); + File.WriteAllText( + Path.Combine(_root, "mathy.py"), + "import math\n\nclass Mathy:\n def root(self, x):\n return math.sqrt(x)\n"); + + var html = pipeline.Render("mathy.py > Mathy.root", "python:symbol,imports"); + + html.ShouldContain("import math"); + html.ShouldContain("def root(self, x):"); + } + + [Fact] + public void Symbol_fence_with_signatures_elides_member_bodies() + { + var pipeline = CreatePipeline(); + File.WriteAllText( + Path.Combine(_root, "Calc.cs"), + "public class Calc\n{\n public int Add(int a, int b)\n {\n return a + b;\n }\n}\n"); + + var html = pipeline.Render("Calc.cs > Calc", "csharp:symbol,signatures"); + + html.ShouldContain("public int Add(int a, int b)"); + html.ShouldNotContain("return a + b"); + } + [Fact] public void Plain_fence_without_symbol_modifier_is_left_to_normal_highlighting() { diff --git a/tests/Pennington.TreeSitter.Tests/Preprocessing/TreeSitterCodeBlockPreprocessorTests.cs b/tests/Pennington.TreeSitter.Tests/Preprocessing/TreeSitterCodeBlockPreprocessorTests.cs index ef4789bb..250d320d 100644 --- a/tests/Pennington.TreeSitter.Tests/Preprocessing/TreeSitterCodeBlockPreprocessorTests.cs +++ b/tests/Pennington.TreeSitter.Tests/Preprocessing/TreeSitterCodeBlockPreprocessorTests.cs @@ -7,51 +7,75 @@ public sealed class TreeSitterCodeBlockPreprocessorTests [Fact] public void ParseLanguageId_without_marker_passes_through() { - var (baseLanguage, modifier, bodyOnly) = TreeSitterCodeBlockPreprocessor.ParseLanguageId("python"); + var (baseLanguage, modifier, options) = TreeSitterCodeBlockPreprocessor.ParseLanguageId("python"); baseLanguage.ShouldBe("python"); modifier.ShouldBeNull(); - bodyOnly.ShouldBeFalse(); + options.BodyOnly.ShouldBeFalse(); } [Fact] public void ParseLanguageId_extracts_symbol_modifier() { - var (baseLanguage, modifier, bodyOnly) = TreeSitterCodeBlockPreprocessor.ParseLanguageId("python:symbol"); + var (baseLanguage, modifier, options) = TreeSitterCodeBlockPreprocessor.ParseLanguageId("python:symbol"); baseLanguage.ShouldBe("python"); modifier.ShouldBe("symbol"); - bodyOnly.ShouldBeFalse(); + options.BodyOnly.ShouldBeFalse(); } [Fact] public void ParseLanguageId_detects_bodyonly_flag() { - var (baseLanguage, modifier, bodyOnly) = TreeSitterCodeBlockPreprocessor.ParseLanguageId("rust:symbol,bodyonly"); + var (baseLanguage, modifier, options) = TreeSitterCodeBlockPreprocessor.ParseLanguageId("rust:symbol,bodyonly"); baseLanguage.ShouldBe("rust"); modifier.ShouldBe("symbol"); - bodyOnly.ShouldBeTrue(); + options.BodyOnly.ShouldBeTrue(); } [Fact] public void ParseLanguageId_extracts_symbol_diff_modifier() { - var (baseLanguage, modifier, bodyOnly) = TreeSitterCodeBlockPreprocessor.ParseLanguageId("python:symbol-diff"); + var (baseLanguage, modifier, options) = TreeSitterCodeBlockPreprocessor.ParseLanguageId("python:symbol-diff"); baseLanguage.ShouldBe("python"); modifier.ShouldBe("symbol-diff"); - bodyOnly.ShouldBeFalse(); + options.BodyOnly.ShouldBeFalse(); } [Fact] public void ParseLanguageId_extracts_symbol_diff_bodyonly() { - var (baseLanguage, modifier, bodyOnly) = TreeSitterCodeBlockPreprocessor.ParseLanguageId("rust:symbol-diff,bodyonly"); + var (baseLanguage, modifier, options) = TreeSitterCodeBlockPreprocessor.ParseLanguageId("rust:symbol-diff,bodyonly"); baseLanguage.ShouldBe("rust"); modifier.ShouldBe("symbol-diff"); - bodyOnly.ShouldBeTrue(); + options.BodyOnly.ShouldBeTrue(); + } + + [Fact] + public void ParseLanguageId_parses_imports_and_bodyonly_flags_together() + { + var (baseLanguage, modifier, options) = TreeSitterCodeBlockPreprocessor.ParseLanguageId("csharp:symbol,bodyonly,imports"); + + baseLanguage.ShouldBe("csharp"); + modifier.ShouldBe("symbol"); + options.BodyOnly.ShouldBeTrue(); + options.IncludeImports.ShouldBeTrue(); + options.SignaturesOnly.ShouldBeFalse(); + } + + [Fact] + public void ParseLanguageId_parses_signatures_flag() + { + var (baseLanguage, modifier, options) = TreeSitterCodeBlockPreprocessor.ParseLanguageId("csharp:symbol,signatures"); + + baseLanguage.ShouldBe("csharp"); + modifier.ShouldBe("symbol"); + options.SignaturesOnly.ShouldBeTrue(); + options.BodyOnly.ShouldBeFalse(); + options.IncludeImports.ShouldBeFalse(); } [Fact] diff --git a/tests/Pennington.TreeSitter.Tests/Resolution/NamePathResolverTests.cs b/tests/Pennington.TreeSitter.Tests/Resolution/NamePathResolverTests.cs index 9ba64a71..36a0dc26 100644 --- a/tests/Pennington.TreeSitter.Tests/Resolution/NamePathResolverTests.cs +++ b/tests/Pennington.TreeSitter.Tests/Resolution/NamePathResolverTests.cs @@ -1,5 +1,6 @@ namespace Pennington.TreeSitter.Tests.Resolution; +using Pennington.TreeSitter.Fragments; using static TreeSitterTestHelper; /// @@ -43,7 +44,7 @@ public int Add(int a, int b) } """; - var fragment = Extract("csharp", source, "Calculator.Add", bodyOnly: true); + var fragment = Extract("csharp", source, "Calculator.Add", new FragmentOptions { BodyOnly = true }); fragment.ShouldNotBeNull(); fragment.ShouldBe("return a + b;"); diff --git a/tests/Pennington.TreeSitter.Tests/TreeSitterTestHelper.cs b/tests/Pennington.TreeSitter.Tests/TreeSitterTestHelper.cs index 50de9cc7..6eae56d2 100644 --- a/tests/Pennington.TreeSitter.Tests/TreeSitterTestHelper.cs +++ b/tests/Pennington.TreeSitter.Tests/TreeSitterTestHelper.cs @@ -12,7 +12,7 @@ public static LanguageDeclarationConfig Config(string languageKey) => LanguageDeclarationConfigDefaults.CreateDefaults()[languageKey]; /// Resolves and extracts a member from inline source, returning null when the path does not resolve. - public static string? Extract(string languageKey, string source, string namePath, bool bodyOnly = false) + public static string? Extract(string languageKey, string source, string namePath, FragmentOptions? options = null) { var config = Config(languageKey); using var language = new TsLanguage(config.TreeSitterLanguageName); @@ -21,7 +21,7 @@ public static LanguageDeclarationConfig Config(string languageKey) => var segments = namePath.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var node = new NamePathResolver().Resolve(tree.RootNode, segments, config); - return node is null ? null : FragmentExtractor.Extract(node, config, bodyOnly); + return node is null ? null : FragmentExtractor.Extract(node, config, options ?? FragmentOptions.Default); } /// Returns the tree-sitter s-expression for inline source — useful for inspecting real node types.