Skip to content

outline: a nested declaration produces no element and its members are attributed to the enclosing one #2957

Closed

Description

@michal-michaluk

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(){} } } → only A).

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:

  1. Model (crates/outline/src/model.rs) — OutlineMember gains a members field, 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.
  2. 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.
  3. 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.
  4. 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, while class A { void m() {} } does — is left for a separate report rather than folded in here.

Open questions for maintainers

  1. 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.
  2. 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.rs
  • crates/outline/src/combined_extractor.rs
  • crates/outline/src/extractor.rs
  • crates/outline/src/default_rules/{java,cpp,python,kotlin,ruby,rust}.yml

Activity

  1. HerringtonDarkholme commented on Oct 12, 2026

    @HerringtonDarkholme
    Member

    Close as wontfix.

    The reasoning here is that ast-grep only supports top-level items. Nested items are considered as impl details.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    wontfixThis will not be worked on

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions