Part 12 — CodeLens micro-DSL: anchors, commands, dynamic counts
CodeLens are the inline grey annotations above method declarations and class headers in modern editors — "3 references", "4 satisfying tests", "Run test", "Generate now". They are clickable: each lens carries a command id; clicking invokes the command. The CodeLens micro-DSL declares per-Concept anchors and lens templates; the kernel's workspace index supplies the dynamic counts; the Commands micro-DSL (article 13) owns the actual command implementations. CodeLens is therefore one of the most compositional micro-DSLs in the suite — it is the surface, not the action.
Concern
The user opens a Feature declaration. They want to see, at a glance: how many tests satisfy this Feature? (count drawn from the workspace index of @FeatureTest decorators). How many other Features declare it as a blocker? (count drawn from @ReferenceLink('blocks') walks). Is it satisfied by code, by tests, by both? They want one click to navigate to the satisfying tests, one click to run those tests, one click to generate the compliance report row for this Feature. Five lenses, all derived, all clickable.
Producing this requires writing an LSP code-lens handler that walks the document for relevant Concepts, computes the lens count via cross-file lookup, returns a CodeLens per anchor, then implementing codeLens/resolve to fill in the command ids and arguments. The micro-DSL collapses the lookup-and-format work behind one @CodeLens decorator.
The Surface
import { CodeLens } from '@frenchexdev/ide-dsl-codelens';
import { Concept, Property } from '@frenchexdev/ide-dsl-kernel';
@Concept({ id: 'cmf.req.Feature' })
export class Feature {
@Property({ type: 'string' })
id!: string;
@CodeLens({
anchor: 'declaration',
command: 'requirements.showSatisfyingTests',
title: (count) => `${count} satisfying test${count === 1 ? '' : 's'}`,
count: (self, ctx) => ctx.workspace.findReferences(self.id, '@FeatureTest').length,
})
static lensSatisfyingTests = Symbol();
@CodeLens({
anchor: 'declaration',
command: 'requirements.runFeatureTests',
title: 'Run satisfying tests',
visibleIf: (self, ctx) => ctx.workspace.findReferences(self.id, '@FeatureTest').length > 0,
})
static lensRunTests = Symbol();
}import { CodeLens } from '@frenchexdev/ide-dsl-codelens';
import { Concept, Property } from '@frenchexdev/ide-dsl-kernel';
@Concept({ id: 'cmf.req.Feature' })
export class Feature {
@Property({ type: 'string' })
id!: string;
@CodeLens({
anchor: 'declaration',
command: 'requirements.showSatisfyingTests',
title: (count) => `${count} satisfying test${count === 1 ? '' : 's'}`,
count: (self, ctx) => ctx.workspace.findReferences(self.id, '@FeatureTest').length,
})
static lensSatisfyingTests = Symbol();
@CodeLens({
anchor: 'declaration',
command: 'requirements.runFeatureTests',
title: 'Run satisfying tests',
visibleIf: (self, ctx) => ctx.workspace.findReferences(self.id, '@FeatureTest').length > 0,
})
static lensRunTests = Symbol();
}@CodeLens declares: an anchor (which span on the Concept the lens decorates — 'declaration', 'header', or a custom callback); a command id (referencing the Commands micro-DSL); a title (static string or callback receiving the dynamic count); an optional count function for dynamic numerals; an optional visibleIf predicate. The lens is registered via a static class member that exists as a marker (same Symbol() idiom as Snippets).
The article 03 dependency-direction theorem holds here: the CodeLens micro-DSL references command ids; it does not own commands. Article 13's Commands micro-DSL declares the command implementations; the CodeLens micro-DSL just names the id and trusts the Commands registry to resolve it at click time.
Kernel boundary
Reads:
- The current document AST — to identify Concepts to attach lenses to.
- The workspace AST through the kernel's index — for cross-file count queries.
- The Commands registry (declared by Commands but exposed through the kernel's typed
CommandRegistryshape) — to validate that referenced command ids exist.
Writes:
- None.
The lens is read-only; the click is a command invocation, not a CodeLens mutation.
Emitted artefacts
// LSP server registration (generated, in server/handlers/codelens.generated.ts)
import { CodeLens, CodeLensParams } from 'vscode-languageserver';
import { kernel } from '@frenchexdev/ide-dsl-kernel';
import { codeLensRegistry } from './_registry.generated';
export async function handleCodeLens(params: CodeLensParams): Promise<CodeLens[]> {
const doc = kernel.getDocument(params.textDocument.uri);
if (!doc) return [];
const lenses = await codeLensRegistry.lensesFor(doc);
return lenses.map(toLspCodeLens);
}
export async function resolveCodeLens(lens: CodeLens): Promise<CodeLens> {
return codeLensRegistry.resolve(lens);
}// LSP server registration (generated, in server/handlers/codelens.generated.ts)
import { CodeLens, CodeLensParams } from 'vscode-languageserver';
import { kernel } from '@frenchexdev/ide-dsl-kernel';
import { codeLensRegistry } from './_registry.generated';
export async function handleCodeLens(params: CodeLensParams): Promise<CodeLens[]> {
const doc = kernel.getDocument(params.textDocument.uri);
if (!doc) return [];
const lenses = await codeLensRegistry.lensesFor(doc);
return lenses.map(toLspCodeLens);
}
export async function resolveCodeLens(lens: CodeLens): Promise<CodeLens> {
return codeLensRegistry.resolve(lens);
}Plus a contribution declaration registering both handlers with the LSP host.
Composition with peers
- Commands (article 13) — every CodeLens references a command id; the Commands micro-DSL owns the implementations. The article 03 dependency direction holds: CodeLens references through the kernel's
CommandRegistry, not through a direct import of the Commands package. - Symbols (article 16) — both walk Concept structure; Symbols populates the workspace index that CodeLens queries for dynamic counts.
- Views (article 14) — orthogonal: CodeLens lives inline above code; Views lives in a sidebar tree. The same Concept can have lenses and be a tree item.
- LSP host (article 20) — routes
textDocument/codeLensandcodeLens/resolve.
MPS aspect referent
MPS Editor aspect's inspector and intentions mechanisms — annotations and one-click actions attached to the cell currently in focus. Our CodeLens is the textual translation: annotations attached to a line span rather than a cell. The mass-production angle survives: one declarative @CodeLens produces work for the LSP handler, the workspace index, and the Commands resolver — the author writes one decorator and the suite handles the rest.
Boundary justification
Why not in Views? Both are UI surfaces. The user-mental-model distinction: CodeLens is inline at the relevant code; Views is aggregated in a sidebar panel. A user who wants "all my Features in one place" opens the Views tree. A user who wants "how many tests satisfy the Feature I am reading" uses CodeLens. Same data, two UI surfaces, two micro-DSLs.
Why not in Commands? CodeLens is the display surface; Commands is the action surface. Coupling them would force every command to declare its CodeLens visibility (every command needs to know whether it appears as a lens), which is the wrong factoring. Commands declares actions; CodeLens declares which actions appear inline; the two compose at consumption time.
Requirements
FEAT-MICRODSL-12 in assets/features.ts:
- anchorDeclarativeSurface — the Surface section shows
@CodeLens({ anchor })with the three anchor kinds. - dynamicCountsViaKernelQueries — the Surface section shows the
countcallback receivingctx; the Kernel boundary section names the workspace index reads. - commandWiringExplained — the Surface section shows the
commandfield; the Composition with peers section names the indirect coupling throughCommandRegistry. - boundaryAgainstViewsJustified — the Boundary justification section names the inline-vs-sidebar argument.
Article 13 picks up with the Commands micro-DSL — the action surface CodeLens references.