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[];
}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
@Outlineand@Symboldeclarations. - 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);
}// 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.