Part 10 — Hover micro-DSL: patterns, content providers, cross-file resolution
Hover is the editor's informational surface: mouse over a token, get a tooltip with type information, documentation, or whatever the language has decided is useful. The capability is textDocument/hover in LSP. The Hover micro-DSL declares which tokens carry hover content and how to compute that content from the kernel state, including cross-file lookups. Like Completion (article 08), it routes through the LSP host (article 20); unlike Completion, the cost budget is more generous (hover requests are infrequent, latency under 200ms is acceptable).
The boundary against Diagnostics matters and is named in the Boundary justification section: hover informs, diagnostics flag. Both attach metadata to a position; only diagnostics carry severity and warrant a fix. Hover is the channel for "here is what this is"; diagnostics is the channel for "here is what is wrong".
Concern
The author opens feature-checkout.req.ts. Inside, a method body cites Requirements.FEATURE-156 — a reference to a feature declared in feature-cart.req.ts. The author wants to know, without leaving their file, what is FEATURE-156? — what's its title, who satisfies it, what are its acceptance criteria, what's its priority. Hovering on the token should produce a markdown tooltip with the right summary, drawn from the canonical declaration.
Doing this from scratch requires writing an LSP hover handler, parsing the cursor's surrounding text to detect the Requirements.FEATURE-... shape, walking the workspace AST to find the canonical Feature declaration with that id, formatting a markdown summary. The Hover micro-DSL collapses this into one @Hover decorator over a Concept method.
The Surface
import { Hover } from '@frenchexdev/ide-dsl-hover';
import { Concept, Property, ChildLink, ReferenceLink } from '@frenchexdev/ide-dsl-kernel';
@Concept({ id: 'cmf.req.Feature' })
export class Feature {
@Property({ type: 'string', constraint: /^FEATURE-\d+$/ })
id!: string;
@Property({ type: 'string', i18n: true })
title!: string;
@ChildLink({ target: 'cmf.req.AcceptanceCriterion', card: '1..*' })
acceptance!: AcceptanceCriterion[];
@Hover({ pattern: /^Requirements\.(FEATURE-\d+)$/ })
static async hoverForReference(ctx, [, idMatch]) {
const target = await ctx.workspace.findFeatureById(idMatch);
if (!target) return null;
return {
contents: [
`### ${target.title}`,
`**Priority:** ${target.priority}`,
`**Acceptance criteria:** ${target.acceptance.length}`,
'',
target.acceptance.map(ac => `- ${ac.title}`).join('\n'),
].join('\n'),
};
}
}import { Hover } from '@frenchexdev/ide-dsl-hover';
import { Concept, Property, ChildLink, ReferenceLink } from '@frenchexdev/ide-dsl-kernel';
@Concept({ id: 'cmf.req.Feature' })
export class Feature {
@Property({ type: 'string', constraint: /^FEATURE-\d+$/ })
id!: string;
@Property({ type: 'string', i18n: true })
title!: string;
@ChildLink({ target: 'cmf.req.AcceptanceCriterion', card: '1..*' })
acceptance!: AcceptanceCriterion[];
@Hover({ pattern: /^Requirements\.(FEATURE-\d+)$/ })
static async hoverForReference(ctx, [, idMatch]) {
const target = await ctx.workspace.findFeatureById(idMatch);
if (!target) return null;
return {
contents: [
`### ${target.title}`,
`**Priority:** ${target.priority}`,
`**Acceptance criteria:** ${target.acceptance.length}`,
'',
target.acceptance.map(ac => `- ${ac.title}`).join('\n'),
].join('\n'),
};
}
}@Hover declares a pattern (a regex over the token text, with capture groups exposed to the handler) and a handler function returning either a Hover value or null. The handler receives a kernel context (ctx) and the regex match array. The kernel context exposes the workspace AST through typed accessors — findFeatureById, walkConcepts, findReferences — that the Hover micro-DSL guarantees are non-mutating.
A simpler form is available for inline hover on a Concept's own properties:
@Concept({ id: 'cmf.req.Feature' })
export class Feature {
@Property({ type: 'string', i18n: true })
@Hover((self) => `*${self.title}* — ${self.priority}`)
title!: string;
}@Concept({ id: 'cmf.req.Feature' })
export class Feature {
@Property({ type: 'string', i18n: true })
@Hover((self) => `*${self.title}* — ${self.priority}`)
title!: string;
}When the cursor is on the title property of a Feature instance (in either declaration or reference position), the inline form produces the hover content. The micro-DSL routes both forms through the same LSP handler.
Kernel boundary
Reads:
- The Structure Model — to recognise Concept references.
- The current document AST — to identify the token at the cursor.
- The workspace AST through the kernel's
findReferencesandfindByIdaccessors — for cross-file resolution.
Writes:
- None.
The cross-file aspect deserves a sentence: the kernel maintains a workspace-wide index of declared Concept ids, refreshed via the EditLog (article 05). Hover queries this index by id; the index returns the canonical declaration. No file system walk happens at hover time; the index is pre-built and incrementally maintained.
Emitted artefacts
// LSP server registration (generated, in server/handlers/hover.generated.ts)
import { Hover, TextDocumentPositionParams } from 'vscode-languageserver';
import { kernel } from '@frenchexdev/ide-dsl-kernel';
import { hoverRegistry } from './_registry.generated';
export async function handleHover(params: TextDocumentPositionParams): Promise<Hover | null> {
const ctx = kernel.contextFor(params);
const matches = hoverRegistry.matchAt(ctx);
for (const match of matches) {
const result = await match.handler(ctx, match.captures);
if (result) return toLspHover(result);
}
return null;
}// LSP server registration (generated, in server/handlers/hover.generated.ts)
import { Hover, TextDocumentPositionParams } from 'vscode-languageserver';
import { kernel } from '@frenchexdev/ide-dsl-kernel';
import { hoverRegistry } from './_registry.generated';
export async function handleHover(params: TextDocumentPositionParams): Promise<Hover | null> {
const ctx = kernel.contextFor(params);
const matches = hoverRegistry.matchAt(ctx);
for (const match of matches) {
const result = await match.handler(ctx, match.captures);
if (result) return toLspHover(result);
}
return null;
}The first matching handler returning a non-null result wins. The order is determined by registration order, which is determined by extraction order, which is deterministic.
Composition with peers
- Symbols (article 16) — both consult the kernel's workspace index. Symbols populates the index from
@ChildLinkdeclarations; Hover consumes it for cross-file resolution. No coupling between the two; the index is the kernel-shaped intermediary. - Diagnostics (article 11) — orthogonal concerns at the same position; both can attach metadata to a token, but the user sees them in separate UI surfaces (tooltip vs. squiggle).
- CodeLens (article 12) — addresses the same Concept references but in a different surface (above-the-line annotation vs. tooltip). Often both are present; the author chooses which information goes where.
- LSP host (article 20) — routes
textDocument/hover.
MPS aspect referent
MPS Constraints aspect, specifically the inspector sub-aspect that surfaces typed information about a node. MPS shows this in a side panel, anchored to the cell currently in focus; we render it in a tooltip, anchored to the token under the cursor. The conceptual move — the metamodel knows enough about each Concept to produce a useful summary on demand — is preserved; the surface differs because we live in text.
Boundary justification
Why not in Diagnostics? Diagnostics carry severity (error, warning, info, hint), they appear as squiggles in the editor, they aggregate in the Problems panel, they can have associated code actions. Hover content is purely informational: no severity, no panel, no fix. Forcing them into one shape would either give every diagnostic a hover (visually noisy) or every hover a diagnostic shape (semantically wrong — "FEATURE-156 has 3 ACs" is not a problem). The boundary is the user's mental model.
Why not in Symbols? Tempting because Hover and Symbols both consult cross-file information. The reason to keep them separate: Symbols builds and maintains the index; Hover consumes it. Symbols' job is populate; Hover's job is render. Two responsibilities, two micro-DSLs, both consulting the kernel's typed state.
Requirements
FEAT-MICRODSL-10 in assets/features.ts:
- hoverPatternMatchingSurfaceShown — the Surface section shows
@Hover({ pattern })with capture groups, plus the simpler inline form. - crossFileResolutionViaKernelAstExplained — the Kernel boundary section names the workspace index, the EditLog refresh, the
findByIdaccessor. - markdownContentProviderShape — the worked handler returns a
contentsfield with markdown formatting; the LSP host'stoLspHoveradapter preserves the markdown. - boundaryAgainstDiagnosticsJustified — the Boundary justification section names the severity / squiggle / Problems-panel argument.
Article 11 picks up with Diagnostics, the corrective counterpart of Hover.