Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part 16 — Symbols micro-DSL: outline, breadcrumbs, workspace symbols

The Symbols micro-DSL answers the editor's structural-navigation questions through three LSP capabilities: textDocument/documentSymbol (what symbols does this file contain — feeds the outline panel and breadcrumbs), workspace/symbol (project-wide fuzzy symbol search — feeds Cmd+T), and textDocument/foldingRange (where can the editor collapse code regions). All three are derived from the kernel's @Concept and @ChildLink declarations with no per-DSL hand-written code.

Symbols also populates the kernel's workspace symbol index — the same index Hover (article 10), CodeLens (article 12), and Refactoring (article 17) consult. The index is conceptually shared but ownership is clear: Symbols populates, others read. The boundary against Views (article 14) is one of UX: Symbols feeds keyboard-driven navigation; Views feeds browse-driven navigation.

Concern

The author opens a .req.ts file with three Epics, each containing several Features, each with multiple Acceptance Criteria. They press Cmd+Shift+O (Go to Symbol). The expected list: every Epic, every Feature, every AC, in document order, hierarchically grouped, fuzzy-matchable as the user types. They press Cmd+T (Go to Symbol in Workspace). The expected list: every Epic, Feature, and AC across every .req.ts file in the workspace, indexed lazily on first query, refreshed via the EditLog, fast for thousands of symbols.

Hand-writing these requires implementing textDocument/documentSymbol, walking the AST, building the symbol tree, implementing workspace/symbol, building and maintaining the workspace index. The Symbols micro-DSL derives all of this from @ChildLink cardinality declarations on the Concepts.

The Surface

import { Symbol, Outline, WorkspaceSymbol } from '@frenchexdev/ide-dsl-symbols';
import { Concept, Property, ChildLink } from '@frenchexdev/ide-dsl-kernel';

@Concept({ id: 'cmf.req.Epic' })
@Outline({ kind: 'namespace', icon: 'lucide:folder' })
export class Epic {
  @Property({ type: 'string' })
  @Symbol({ asLabel: true })
  id!: string;

  @Property({ type: 'string' })
  @Symbol({ asDetail: true })
  title!: string;

  @ChildLink({ target: 'cmf.req.Feature', card: '0..*' })
  features!: Feature[];
}

@Concept({ id: 'cmf.req.Feature' })
@Outline({ kind: 'class', icon: 'lucide:flag' })
@WorkspaceSymbol({ searchable: true })
export class Feature {
  @Property({ type: 'string' })
  @Symbol({ asLabel: true })
  id!: string;

  @Property({ type: 'string' })
  @Symbol({ asDetail: true })
  title!: string;

  @ChildLink({ target: 'cmf.req.AcceptanceCriterion', card: '1..*', outlineLabel: 'AC' })
  acceptance!: AcceptanceCriterion[];
}

@Outline declares the LSP SymbolKind for the Concept and the icon for the outline panel. @Symbol({ asLabel }) and @Symbol({ asDetail }) mark which property feeds the LSP name and detail fields. @WorkspaceSymbol({ searchable }) marks the Concept as eligible for workspace/symbol queries — not every Concept needs to be (an AcceptanceCriterion might be too granular for workspace search).

The hierarchy comes from @ChildLink declarations. The Symbols micro-DSL walks every @ChildLink({ outlineLabel }) to compose the document outline; the breadcrumb shows the ancestry chain by walking parent links.

Kernel boundary

Reads:

  • The Structure Model — for @Outline and @Symbol declarations.
  • The current document AST — for outline construction.
  • The workspace AST through the kernel — for workspace-symbol indexing.

Writes:

  • The kernel's typed workspace symbol index, populated by Symbols and exposed through kernel.workspaceSymbols.findById(id) for Hover, kernel.workspaceSymbols.findByName(name) for fuzzy search, kernel.workspaceSymbols.matchPattern(pattern) for Refactoring impact analysis.

The index is the kernel-shaped intermediary the article 03 no horizontal imports rule depends on. Symbols is the one micro-DSL that populates it; every other micro-DSL reads through the kernel. If the index needed write access from elsewhere, the architecture would have a hole; it does not, by construction.

Emitted artefacts

// LSP server registration (generated, in server/handlers/symbols.generated.ts)
import { SymbolInformation, DocumentSymbol, FoldingRange } from 'vscode-languageserver';
import { kernel } from '@frenchexdev/ide-dsl-kernel';
import { symbolRegistry } from './_registry.generated';

export async function handleDocumentSymbol(params): Promise<DocumentSymbol[]> {
  const doc = kernel.getDocument(params.textDocument.uri);
  if (!doc) return [];
  return symbolRegistry.outline(doc);
}

export async function handleWorkspaceSymbol(params): Promise<SymbolInformation[]> {
  return symbolRegistry.workspaceMatching(params.query);
}

export async function handleFoldingRange(params): Promise<FoldingRange[]> {
  const doc = kernel.getDocument(params.textDocument.uri);
  if (!doc) return [];
  return symbolRegistry.foldingRanges(doc);
}

Plus the kernel-side population code that subscribes to EditLog events and incrementally updates the workspace index.

Composition with peers

  • Hover (article 10) — consumes the workspace index for cross-file resolution.
  • Refactoring (article 17) — consumes the workspace index for impact analysis on rename.
  • CodeLens (article 12) — consumes the workspace index for dynamic counts.
  • Views (article 14) — different UX (panel vs. keyboard-driven).
  • LSP host (article 20) — routes the three LSP capabilities.

MPS aspect referent

MPS' Find Usages and the Project Tree both consult MPS' equivalent of a workspace index — built by walking declared Concepts and their references. We adopt the index-driven approach with a twist: the index is a kernel concern (the type and the API), populated by one micro-DSL (Symbols), consumed by several. The MPS equivalent is internal to MPS' workbench; ours is a typed kernel API any host can consume.

Boundary justification

Why not in Views? UX argument: keyboard-driven vs. browsable. Same data, two surfaces. Splitting lets the keyboard-driven user (Symbols / Cmd+T) and the browse-driven user (Views / sidebar tree) each get the optimised UX without coupling.

Why does Symbols own the workspace index when several micro-DSLs read it? Because the index is the kind of state article 02 placed in the kernel as a type but kept out as a living value. The kernel exposes the index API; one micro-DSL (the obvious owner) populates it; everyone reads. Picking a different population owner would not change the architecture; we picked Symbols because the population logic and the consumption logic are the same shape (walk @ChildLinks, build entries).

Requirements

FEAT-MICRODSL-16 in assets/features.ts:

  • outlineDerivedFromKernelChildLinks — the Surface section shows the @ChildLink({ outlineLabel }) driving the outline.
  • workspaceSymbolIndexingStrategyStated — the Kernel boundary section names the population pattern and the EditLog incremental update.
  • breadcrumbCompositionExplained — the Concern section names the ancestry walk producing breadcrumbs.
  • boundaryAgainstViewsJustified — the Boundary justification section names the keyboard-vs-browse argument.

Article 17 picks up with Refactoring, the structural-transform surface that consumes the same index.

⬇ Download