The symbol-capability registry
A cursor in a .loom file rests on one of a fixed set of things: a heading title, an anchor, a warp, a language specifier, a sink, an arrow, a tilde, or prose. Each is a symbol kind, and each kind supports a fixed set of editor features — how it colors, which requests the editor may make on it, and what its navigation resolves to. LoomSymbol holds that answer in one place, so a token's editor behavior is a table entry, not logic scattered across the mappings, the navigation, and the highlighter.
This mirrors how Volar already models the question. Volar's CodeInformation marks, per source span, which language features that span forwards — navigation, semantic, structure, verification, completion. LoomSymbol names the same flags in SpanFeatures, and adds the two things Volar leaves to the plugin: how the token colors, and what its navigation means. One profile per kind; three consumers read it — the frame's mappings, its navigation, and its semantic tokens — so their behavior cannot drift apart.
The kinds
A SymbolKind names one class of token. A section's headingTitle is the name anchors resolve to. A sectionAnchor is a ::[Title] reference to a section; a warpAnchor is a ::[name] reference to a value warp declared in the same section. A warpDef is a {{name = value}} declaration. A specifier is a heading's {Language}, a sink its [file]. An arrow opens a code block and a tilde closes it. A frontmatterTitle is a chapter's name, the identity a table-of-contents entry resolves to. A frontmatterPart is the Part N a chapter belongs to — a reference to the part — and a frontmatterPartName is the part's name, announced once in the part's opening chapter. The chapter number is a plain frontmatterMembership, and a frontmatterValue is the Package or Language a field binds. In the table of contents, a tocPart is a part's heading, which navigates to where the part is announced, and a tocEntry is a chapter's line, which navigates to the chapter it names. Everything else a person reads is prose.
export const SymbolKindSchema = Schema.Literals([
'headingTitle',
'sectionAnchor',
'warpAnchor',
'warpDef',
'specifier',
'sink',
'arrow',
'tilde',
'prose',
'frontmatterMembership',
'frontmatterValue',
'frontmatterTitle',
'frontmatterPart',
'frontmatterPartName',
'tocPart',
'tocEntry',
])
export type SymbolKind = typeof SymbolKindSchema.TypeThe capability profile
A SymbolProfile is what the editor may do with a kind. SpanFeatures carries Volar's own per-span flags, so the frame's mapping for a token forwards exactly the requests the kind supports and no others. semantic names the token's colour, drawn from a small set of the editor's standard semantic-token types. A kind that carries no colour of its own — prose, rendered as Markdown — leaves it None.
profileOf is the table. Each kind maps to its profile through one exhaustive Match, so a new kind fails to compile until it is profiled, and adding a token to Loom is one entry here rather than a change in every consumer.
export interface SpanFeatures {
readonly navigation?: boolean
readonly semantic?: boolean
readonly structure?: boolean
readonly verification?: boolean
readonly completion?: boolean
}
export const SemanticTokenSchema = Schema.Literals([
'namespace',
'variable',
'keyword',
'string',
'operator',
])
export type SemanticToken = typeof SemanticTokenSchema.Type
export interface SymbolProfile {
readonly semantic: Option.Option<SemanticToken>
readonly features: SpanFeatures
}
const profile = (
semantic: Option.Option<SemanticToken>,
features: SpanFeatures,
): SymbolProfile => ({ semantic, features })
export const profileOf = (kind: SymbolKind): SymbolProfile =>
Match.value(kind).pipe(
Match.when('headingTitle', () =>
profile(Option.some('namespace'), { navigation: true, structure: true }),
),
Match.when('sectionAnchor', () =>
profile(Option.some('namespace'), {
navigation: true,
structure: true,
verification: true,
}),
),
Match.when('warpAnchor', () =>
profile(Option.some('variable'), { navigation: true, verification: true }),
),
Match.when('warpDef', () => profile(Option.some('variable'), { navigation: true })),
Match.when('specifier', () => profile(Option.some('keyword'), { verification: true })),
Match.when('sink', () => profile(Option.some('string'), { verification: true })),
Match.when('arrow', () => profile(Option.some('operator'), {})),
Match.when('tilde', () => profile(Option.some('operator'), {})),
Match.when('prose', () => profile(Option.none(), { structure: true })),
Match.when('frontmatterMembership', () =>
profile(Option.some('namespace'), { structure: true }),
),
Match.when('frontmatterValue', () => profile(Option.some('string'), { structure: true })),
Match.when('frontmatterTitle', () =>
profile(Option.some('namespace'), { navigation: true, structure: true }),
),
Match.when('frontmatterPart', () =>
profile(Option.some('namespace'), { navigation: true, structure: true }),
),
Match.when('frontmatterPartName', () =>
profile(Option.some('namespace'), { navigation: true, structure: true }),
),
Match.when('tocPart', () =>
profile(Option.some('namespace'), { navigation: true, structure: true }),
),
Match.when('tocEntry', () =>
profile(Option.some('namespace'), {
navigation: true,
structure: true,
verification: true,
}),
),
Match.exhaustive,
)A located symbol
A Symbol is one token in a source: its kind and the span it occupies. The frame reads a document's symbols to lay its mappings and its colours, and finds the one under a cursor to answer navigation.
export interface Symbol {
readonly kind: SymbolKind
readonly position: Position
}Classifying a document
symbolsOf reads every symbol a document declares, in one pass. Every warp is a warpDef. An anchor is a warpAnchor when its name matches a warp in scope — the warps its section and the document preamble declare — and a sectionAnchor otherwise, since only a warp resolves within the section while a title resolves across the corpus. A heading contributes its title, specifier, and sink; a code block its arrow or tilde. The frontmatter contributes its title, its part, and its field values, and a {TOC} section its parts and chapter entries. symbolAt finds the symbol a cursor rests in, so navigation asks the table which kind it is before resolving.
const anchorKind = (warpsInScope: ReadonlySet<string>, anchor: WarpAnchorToken): SymbolKind =>
warpsInScope.has(anchor.name.value) ? 'warpAnchor' : 'sectionAnchor'
const namesOf = (warps: ReadonlyArray<WarpToken>): ReadonlyArray<string> =>
Array.map(warps, (warp) => warp.name.value)
const at = (kind: SymbolKind, position: Position): Symbol => ({ kind, position })
const optSymbol = (
kind: SymbolKind,
token: { readonly position: Position } | undefined,
): ReadonlyArray<Symbol> =>
Option.toArray(Option.map(Option.fromNullishOr(token), (t) => at(kind, t.position)))
const frontmatterSymbols = (fm: LoomFrontmatter): ReadonlyArray<Symbol> => [
...optSymbol('frontmatterPart', fm.part),
...optSymbol('frontmatterPartName', fm.partName),
...optSymbol('frontmatterMembership', fm.chapter),
...optSymbol('frontmatterTitle', fm.title),
...optSymbol('frontmatterValue', fm.package),
...optSymbol('frontmatterValue', fm.language),
]
const tocSymbols = (weft: TocWeft): ReadonlyArray<Symbol> => [
...optSymbol('tocPart', weft.part),
...optSymbol('tocEntry', weft.title),
]
const headingSymbols = (heading: LoomHeading): ReadonlyArray<Symbol> => [
...Option.toArray(
Option.map(Option.fromNullishOr(heading.title), (t) => at('headingTitle', t.position)),
),
...Option.toArray(
Option.map(Option.fromNullishOr(heading.specifier), (s) => at('specifier', s.label.position)),
),
...Option.toArray(Option.map(Option.fromNullishOr(heading.sink), (s) => at('sink', s.position))),
]
const delimiterOf = (weft: SectionBodyWeft): ReadonlyArray<Symbol> =>
Match.value(weft).pipe(
Match.when({ type: 'ArrowWeft' }, (w) => [at('arrow', w.arrow.position)]),
Match.when({ type: 'TildeWeft' }, (w) => [at('tilde', w.tilde.position)]),
Match.orElse(() => []),
)
const sectionSymbols = (
section: LoomSection,
documentWarps: ReadonlyArray<string>,
): ReadonlyArray<Symbol> => {
const warps = Array.flatMap(section.preamble, (weft) => weft.warps)
const scope = new Set([...documentWarps, ...namesOf(warps)])
const anchors = [
...Array.flatMap(section.preamble, (weft) => weft.anchors),
...Array.flatMap(section.code, (weft) => weft.anchors),
]
return [
...headingSymbols(section.heading),
...Array.map(warps, (warp) => at('warpDef', warp.name.position)),
...Array.map(anchors, (anchor) => at(anchorKind(scope, anchor), anchor.name.position)),
...Array.flatMap(section.code, delimiterOf),
...Array.flatMap(section.entries ?? [], tocSymbols),
]
}
export const symbolsOf = (doc: LoomDocument): ReadonlyArray<Symbol> => {
const warps = Array.flatMap(doc.preamble, (weft) => weft.warps)
const scope = new Set(namesOf(warps))
return [
...Array.flatMap(Option.toArray(Option.fromNullishOr(doc.frontmatter)), frontmatterSymbols),
...Array.map(warps, (warp) => at('warpDef', warp.name.position)),
...Array.map(
Array.flatMap(doc.preamble, (weft) => weft.anchors),
(anchor) => at(anchorKind(scope, anchor), anchor.name.position),
),
...Array.flatMap(doc.sections, (section) => sectionSymbols(section, namesOf(warps))),
]
}
export const symbolAt = (doc: LoomDocument, offset: number): Option.Option<Symbol> =>
Array.findFirst(
symbolsOf(doc),
(symbol) => symbol.position.start.offset <= offset && offset <= symbol.position.end.offset,
)What the module draws on
From Effect come the composition combinators, Match for the exhaustive table and Option and Array for the classification pass. From the AST come the document, section, and frontmatter shapes the pass walks, and the token shapes it reads — the warp, the anchor, the heading's parts, and a toc weft. Position is a source span.
import { Array, Match, Option, Schema } from 'effect'
import { type Position } from '#ast/LoomNode'
import {
type LoomDocument,
type LoomFrontmatter,
type LoomHeading,
type LoomSection,
} from '#ast/LoomAst'
import { type SectionBodyWeft, type TocWeft } from '#ast/Weft'
import { type WarpAnchorToken, type WarpToken } from '#ast/LoomTokens'