Repository navigation
Description
Summary
A type (or module, class, interface, enum, struct, object) declared inside another declaration is
invisible to the outline model, and its members are attributed to the enclosing declaration.
This is not a missing grammar node and not a rule-text problem: it is the traversal.
OutlineItemIter is file-scoped, and collect_scoped_members calls traversal.skip_subtree() on every
matched member, so a nested declaration is never collected as a container. Five bundled rule sets already
describe nested types as member rules (C++ cpp-nested-*, Python python-nested-class, Kotlin
kotlin-member-nested-*, Ruby ruby-module-member-class, Rust rust-mod-member-*) — the rule data says
"a nested type is a member", and the extraction then discards that member's own contents.
Consumers see the wrong shape in two distinct ways:
- the nested declaration produces no element — the enclosing item claims its members, so counts are
inflated for the outer type and absent for the inner one; - where the enclosing item's rule has no member scope at all, a whole subtree is dropped silently
(namespace A { namespace B { function f(){} } }→ onlyA).
Reproduction
class O {
static class I { int f; void m() {} }
}ast-grep outline prints one item (O) whose members are f and m. I has no element, and f/m are
reported as members of O.
The same shape reproduces in other languages with their own bundled rules:
| source | actual | missing |
|---|---|---|
Rust mod a { mod b { fn f() {} } } |
item a + member f |
b |
C++ class A { class B { int x; void n() {} }; void m() {} }; |
item A + member B |
x, n |
Python class A: class B: def m |
item A + member B |
m |
Kotlin class A { object O {} } |
item A + member O |
O's members |
Ruby class A\n class B\n def m |
item A + member m (wrong owner) |
B |
C struct S { struct T { int y; }; int z; }; |
item S + two members |
T |
Proposed change — a member may itself be a container
The smallest change that makes the existing rule data work and keeps the two placement roles intact:
- Model (
crates/outline/src/model.rs) —OutlineMembergains amembersfield, empty for a leaf
member.Item= a declaration matched at file/module level,Member= structure inside an item; a
member that is itself a declaration carries its own members. - Traversal (
crates/outline/src/combined_extractor.rs) — descend instead of pruning when the matched
member is itself a declaration; the child scope defaults to the enclosing scope when the rule declares
none, so the five languages that already emit a nested type as a member start collecting that member's
own members with no rule change. - Validation (
crates/outline/src/extractor.rs) — a member rule may reference another member rule in
parentRuleIds, so a language can give a nested declaration its own child scope. - CLI (
crates/cli/src/outline/…) — serialize the deeper tree. The JSON change is additive (members
is skipped when empty, so an outline without nesting serializes byte-identically); the text views print
an item's direct members only (see below).
A fix along these lines is proposed in an accompanying PR.
Measured impact (reference consumer)
On a 94-file Java Spring corpus, before vs after a fix along these lines:
| metric | before | after |
|---|---|---|
files reported incomplete |
22 | 0 |
Σ classes_per_file (49-file subset) |
49 | 93 |
methods_per_class for a type owning 3 methods (per element, same run) |
6 (absorbed its nested type's members) | 3 |
Deliberately out of scope of this issue
- Text-view rendering of nested members. The nested members are carried in
--json; the text views
(names/digest/expanded) print an item's direct members only, so a nested type appears as a single
member line without its own members. Making the text views recurse is left out. - Anonymous and local classes declared inside a method body are not covered (a method is a leaf member,
so its subtree is pruned), and an anonymous class has no name to match. A local class declared in a
static/instance initializer block is currently captured as a member because the block is not pruned; this
edge is not specially handled. Broad support for local/anonymous declarations is deferred. - A reproducible C++ defect unrelated to nesting —
class A { void m() {} class B {…}; }never emits
m, whileclass A { void m() {} }does — is left for a separate report rather than folded in here.
Open questions for maintainers
- Should a member rule referencing a member rule id (
parentRuleIds) be allowed, or is enclosing-scope
inheritance intended to be the only mechanism? The former is needed for a nested enum's constants. - Is the model's doc-comment wording right with the added clause, or should the contract be stated as
"an item is a declaration matched at file/module level; a member may be a container"?
References
crates/outline/src/model.rscrates/outline/src/combined_extractor.rscrates/outline/src/extractor.rscrates/outline/src/default_rules/{java,cpp,python,kotlin,ruby,rust}.yml
Activity
HerringtonDarkholme commentedon Oct 12, 2026
Close as wontfix.
The reasoning here is that ast-grep only supports top-level items. Nested items are considered as impl details.