The multi-file AST
LoomCorpusAst is the multi-file AST — pure data, no pipeline (that is LoomCorpusAstBuilder). Every loaded .loom is a LoomModule keyed by its path, holding its source, parsed doc, its composed product, and import edges. The corpus is just a map of those modules. The product is what the product pass builds for each module; a cross-file reference stays an edge there, resolved later at projection by fromProduct.
The module and the corpus
A Path is a resolved .loom path, a module's identity. A LoomModule carries one file's product beside its text and parsed doc. The product is the module's composition graph, built straight from the parse, so every loaded module carries one. A LoomCorpusAst is the map of modules by path.
export type Path = string
export const LoomModuleSchema = Schema.Struct({
path: Schema.String,
text: Schema.String,
doc: LoomDocumentSchema,
product: ProductSchema,
})
export type LoomModule = typeof LoomModuleSchema.Type
export const LoomCorpusAstSchema = Schema.Struct({
modules: Schema.ReadonlyMap(Schema.String, LoomModuleSchema),
})
export type LoomCorpusAst = typeof LoomCorpusAstSchema.TypeThe module map
A Modules map keys each loom in a corpus by its path. The corpus is one map; the graph over it is the anchor graph, where a section in one loom composes a section in another. The forward reach a run and a tangle scope to, and the reverse reach an edit reprojects, both walk that graph, so both live beside it.
type Modules = ReadonlyMap<Path, LoomModule>Resolving names
Names resolve here — the lexical lookups that turn an ::[Name] anchor into the section it points at. A SectionRef locates that section: the module it lives in, and the section itself.
An anchor names a section by its raw title, and resolves that title in one file. A bare ::[Name] resolves in the anchor's own file. A ::[Name](path.loom) resolves in the file the path names, taken relative to the anchor's own. The rule holds wherever the anchor stands, in a section's code as in its prose. So a name is lexical, not a corpus-wide symbol, and two files reuse a title freely.
titlesIn indexes one file's titled sections by their raw title. The first of any in-file duplicate wins, and the duplicate itself is a fault the diagnostics raise. titleIndexes builds that index for every file, once. anchorModule picks the file an anchor resolves in: the anchor's own file for a bare anchor, or the file its path names. resolveRelative resolves that path against the directory of the anchor's own file, dropping the file's last segment and folding . and .. as it goes. resolveAnchor is the lookup itself: it finds the anchor's title in the file anchorModule picks. The product pass picks the file through anchorModule too, so the tangle and the editor agree on where a name lives.
export type SectionRef = { readonly module: Path; readonly section: LoomSection }
const resolveRelative = (from: Path, rel: string): Path =>
pipe(
rel.split('/'),
Array.filter((seg) => seg !== '' && seg !== '.'),
Array.reduce(from.split('/').slice(0, -1), (acc, seg) =>
seg === '..' ? acc.slice(0, -1) : [...acc, seg],
),
Array.join('/'),
)
const titlesIn = (module: LoomModule): ReadonlyMap<string, SectionRef> =>
pipe(
module.doc.sections,
Array.map((section) =>
Option.map(
Option.fromNullishOr(section.heading.title),
(title) => [title.source, { module: module.path, section }] as const,
),
),
Array.getSomes,
Array.reduce(new Map<string, SectionRef>(), (index, [title, ref]) =>
index.has(title) ? index : new Map(index).set(title, ref),
),
)
export type TitleIndexes = ReadonlyMap<Path, ReadonlyMap<string, SectionRef>>
export const titleIndexes = (modules: Modules): TitleIndexes =>
new Map(Array.fromIterable(modules.values()).map((m) => [m.path, titlesIn(m)] as const))
export const anchorModule = (from: Path, anchor: WarpAnchorToken): Path =>
anchor.target === undefined ? from : resolveRelative(from, anchor.target.value)
export const resolveAnchor = (
indexes: TitleIndexes,
from: Path,
anchor: WarpAnchorToken,
): Option.Option<SectionRef> =>
pipe(
Option.fromNullishOr(indexes.get(anchorModule(from, anchor))),
Option.flatMap((index) => Option.fromNullishOr(index.get(anchor.name.value))),
)anchorsOf gathers every anchor a section carries — across its preamble and its body, code and prose alike — since the reachability walk and the navigation read the same set.
const anchorsOf = (section: LoomSection): ReadonlyArray<WarpAnchorToken> =>
pipe(
[...section.preamble, ...section.code],
Array.flatMap((weft) => weft.anchors),
)Reaching through the corpus
A run and a tangle need only part of the corpus: the entry and the sections it composes. reachable is the forward reach over the anchor graph — the entry, every module a section in it names through an ::[…] anchor, and the modules those name in turn. Tangling one file never composes an unrelated loom, and a loom the entry never anchors never fails its build. transitiveDependents is the reverse — every module that composes a section in a given file, transitively — so an edit reprojects each file that inlined it.
reach grows a set along one direction of the edges until it adds nothing more, guarding a cycle so the walk ends. anchorEdges reads the corpus once: each anchor a module carries becomes an edge to the module that holds the section it names.
const reach = (
seeds: ReadonlyArray<Path>,
step: (module: Path) => ReadonlyArray<Path>,
): ReadonlySet<Path> => {
const grow = (acc: ReadonlySet<Path>, frontier: ReadonlyArray<Path>): ReadonlySet<Path> => {
const next = pipe(
frontier,
Array.flatMap(step),
Array.filter((p) => !acc.has(p)),
Array.dedupe,
)
return next.length === 0 ? acc : grow(new Set([...acc, ...next]), next)
}
return grow(new Set(seeds), seeds)
}
const anchorEdges = (
modules: Modules,
): ReadonlyArray<{ readonly owner: Path; readonly target: Path }> => {
const indexes = titleIndexes(modules)
return pipe(
Array.fromIterable(modules.values()),
Array.flatMap((m) =>
Array.getSomes(
Array.map(Array.flatMap(m.doc.sections, anchorsOf), (anchor) =>
Option.map(resolveAnchor(indexes, m.path, anchor), (target) => ({
owner: m.path,
target: target.module,
})),
),
),
),
)
}
export const reachable = (modules: Modules, entry: Path): ReadonlyArray<Path> => {
const edges = anchorEdges(modules)
return Array.fromIterable(
reach([entry], (module) =>
Array.getSomes(
Array.map(edges, (edge) =>
edge.owner === module ? Option.some(edge.target) : Option.none(),
),
),
),
)
}
export const transitiveDependents = (modules: Modules, entry: Path): ReadonlyArray<Path> => {
const edges = anchorEdges(modules)
return pipe(
Array.fromIterable(
reach([entry], (module) =>
Array.getSomes(
Array.map(edges, (edge) =>
edge.target === module ? Option.some(edge.owner) : Option.none(),
),
),
),
),
Array.filter((path) => path !== entry),
)
}Navigation over the corpus
An anchor reaches a section, and a warp anchor reaches the value warp it names — the editor follows both, and symbolAt decides which. It reads the kind under a cursor from LoomSymbol's table, so a section anchor resolves a title across the corpus while a warp anchor resolves a name within its own section, each verb dispatching on the same classifier the mappings and colours read. A CorpusLocation is where a step lands: the module and the span, in the source's own offsets, which the editor turns into its own coordinates.
export type CorpusLocation = { readonly path: Path; readonly position: Position }Each step starts from the node under a cursor. anchorAt finds the anchor whose ::[…] span covers a source offset. titledSectionAt finds the heading whose title covers it, and sectionRefAt pairs that section with its module.
const anchorAt = (modules: Modules, path: Path, offset: number): Option.Option<WarpAnchorToken> =>
pipe(
Option.fromNullishOr(modules.get(path)),
Option.flatMap((m) =>
Array.findFirst(
Array.flatMap(m.doc.sections, anchorsOf),
(anchor) => anchor.position.start.offset <= offset && offset <= anchor.position.end.offset,
),
),
)
const titledSectionAt = (
modules: Modules,
path: Path,
offset: number,
): Option.Option<LoomSection> =>
pipe(
Option.fromNullishOr(modules.get(path)),
Option.flatMap((m) =>
Array.findFirst(m.doc.sections, (section) => {
const title = section.heading.title
return (
title !== undefined &&
title.position.start.offset <= offset &&
offset <= title.position.end.offset
)
}),
),
)
const sectionRefAt = (modules: Modules, path: Path, offset: number): Option.Option<SectionRef> =>
Option.map(titledSectionAt(modules, path, offset), (section) => ({
module: path,
section,
}))A resolved section then locates its heading and the anchors that reach it. titleLocation points at a section's heading title. targetAt reads the section under a cursor — the heading it sits in, or the section an anchor under it resolves to. anchorsResolvingTo gathers every anchor across the corpus that resolves to a given section, each by the span a caller picks: the whole anchor for a reference, its name alone for a rename.
const titleLocation = (ref: SectionRef): Option.Option<CorpusLocation> =>
Option.map(Option.fromNullishOr(ref.section.heading.title), (title): CorpusLocation => ({
path: ref.module,
position: title.position,
}))
const targetAt = (
indexes: TitleIndexes,
modules: Modules,
path: Path,
offset: number,
): Option.Option<SectionRef> =>
Option.orElse(sectionRefAt(modules, path, offset), () =>
Option.flatMap(anchorAt(modules, path, offset), (anchor) =>
resolveAnchor(indexes, path, anchor),
),
)
const anchorsResolvingTo = (
indexes: TitleIndexes,
modules: Modules,
target: SectionRef,
spanOf: (anchor: WarpAnchorToken) => Position,
): ReadonlyArray<CorpusLocation> =>
pipe(
Array.fromIterable(modules.values()),
Array.flatMap((m) =>
Array.getSomes(
Array.map(Array.flatMap(m.doc.sections, anchorsOf), (anchor) =>
pipe(
resolveAnchor(indexes, m.path, anchor),
Option.filter((ref) => ref.module === target.module && ref.section === target.section),
Option.map((): CorpusLocation => ({ path: m.path, position: spanOf(anchor) })),
),
),
),
),
)sectionDefinitionAt follows a section anchor to the heading it names — in the anchor's own file, or the file its path names. sectionReferencesAt reads the section under a cursor, a heading or a resolved anchor either way, and returns that heading beside every anchor across the corpus that resolves to it. The exported verbs call these once symbolAt names the cursor a section.
const sectionDefinitionAt = (
corpus: LoomCorpusAst,
path: Path,
offset: number,
): Option.Option<CorpusLocation> => {
const indexes = titleIndexes(corpus.modules)
return pipe(
anchorAt(corpus.modules, path, offset),
Option.flatMap((anchor) => resolveAnchor(indexes, path, anchor)),
Option.flatMap(titleLocation),
)
}
const sectionReferencesAt = (
corpus: LoomCorpusAst,
path: Path,
offset: number,
): ReadonlyArray<CorpusLocation> => {
const modules = corpus.modules
const indexes = titleIndexes(modules)
return Option.match(targetAt(indexes, modules, path, offset), {
onNone: () => [],
onSome: (ref) => [
...Option.toArray(titleLocation(ref)),
...anchorsResolvingTo(indexes, modules, ref, (anchor) => anchor.position),
],
})
}A warp anchor resolves within its section, not across the corpus. ::[ratio] names the value warp ratio its section declares, or the document's when the section declares none — the same scope symbolsOf classifies a warp anchor by, so the editor and the classifier agree. valueWarpsIn reads a preamble's value warps, the lang warp excepted. sectionCovering gives the section an offset sits in, and warpDefFor picks the warp a name sees there: the section's own, else the document's, so a section warp shadows the document's. warpNameAt reads the name under a cursor, whether a {{ratio = …}} definition or a ::[ratio] anchor. warpBindingAt ties them together — the definition and every anchor that binds to it — and warpDefLocation and warpReferences shape that binding into the definition span and the reference spans a caller picks.
const covers = (position: Position, offset: number): boolean =>
position.start.offset <= offset && offset <= position.end.offset
const valueWarpsIn = (
preamble: ReadonlyArray<{ readonly warps: ReadonlyArray<WarpToken> }>,
): ReadonlyArray<WarpToken> =>
Array.filter(
Array.flatMap(preamble, (weft) => weft.warps),
(warp) => warp.name.value !== 'lang',
)
const allValueWarpsOf = (doc: LoomDocument): ReadonlyArray<WarpToken> => [
...valueWarpsIn(doc.preamble),
...Array.flatMap(doc.sections, (section) => valueWarpsIn(section.preamble)),
]
const allAnchorsOf = (doc: LoomDocument): ReadonlyArray<WarpAnchorToken> => [
...Array.flatMap(doc.preamble, (weft) => weft.anchors),
...Array.flatMap(doc.sections, anchorsOf),
]
const sectionCovering = (module: LoomModule, offset: number): Option.Option<LoomSection> =>
Array.findFirst(module.doc.sections, (section) => covers(section.position, offset))
const warpDefFor = (
module: LoomModule,
section: Option.Option<LoomSection>,
name: string,
): Option.Option<WarpToken> =>
pipe(
section,
Option.flatMap((s) => Array.findFirst(valueWarpsIn(s.preamble), (w) => w.name.value === name)),
Option.orElse(() =>
Array.findFirst(valueWarpsIn(module.doc.preamble), (w) => w.name.value === name),
),
)
const warpNameAt = (module: LoomModule, offset: number): Option.Option<string> =>
Option.orElse(
Option.map(
Array.findFirst(allValueWarpsOf(module.doc), (w) => covers(w.name.position, offset)),
(w) => w.name.value,
),
() =>
Option.map(
Array.findFirst(allAnchorsOf(module.doc), (a) => covers(a.name.position, offset)),
(a) => a.name.value,
),
)
const warpBindingAt = (
module: LoomModule,
offset: number,
): Option.Option<{
readonly def: WarpToken
readonly anchors: ReadonlyArray<WarpAnchorToken>
}> =>
pipe(
warpNameAt(module, offset),
Option.flatMap((name) =>
Option.map(warpDefFor(module, sectionCovering(module, offset), name), (def) => ({
def,
anchors: Array.filter(
allAnchorsOf(module.doc),
(anchor) =>
anchor.name.value === name &&
Option.getOrUndefined(
warpDefFor(module, sectionCovering(module, anchor.position.start.offset), name),
) === def,
),
})),
),
)
const warpDefLocation = (
module: LoomModule,
path: Path,
offset: number,
): Option.Option<CorpusLocation> =>
Option.map(warpBindingAt(module, offset), (binding): CorpusLocation => ({
path,
position: binding.def.name.position,
}))
const warpReferences = (
module: LoomModule,
path: Path,
offset: number,
spanOf: (anchor: WarpAnchorToken) => Position,
): ReadonlyArray<CorpusLocation> =>
Option.match(warpBindingAt(module, offset), {
onNone: () => [],
onSome: (binding) => [
{ path, position: binding.def.name.position },
...Array.map(binding.anchors, (anchor): CorpusLocation => ({
path,
position: spanOf(anchor),
})),
],
})A table-of-contents entry resolves differently from an anchor: it names a chapter by title, across the whole corpus, not a section within one file. A chapter's identity is its frontmatter, not its heading — a heading can differ from the title the book lists, or be missing. chapterTitleToken reads that identity: the frontmatter title, and the first heading only for a loom that carries no frontmatter. chapterIndex maps the title to where the chapter declares it, the first of any duplicate winning. tocEntryTitleAt reads the entry title under a cursor and frontmatterTitleAt the title in a chapter's own frontmatter; chapterNameAt takes either, so a cursor on the entry and a cursor on the chapter reach the same name. chapterDefinitionAt follows that name to the chapter the index holds. unresolvedTocEntriesIn is the other side: every entry whose title names no chapter, for the diagnostics to fault. tocNameGroup gathers the whole name — the chapter's own title and every entry that names it — so references and rename move together, and chapterGroupAt reads that group from the cursor. A part resolves the same way, keyed by its number rather than its title. A part is announced once, in its opening chapter's standalone Part R: name, whose partName is the part's identity; partOpenerIndex maps each part number to that announcement. The number under a cursor comes from a chapter's Part R through frontmatterPartNumberAt, or from a contents part heading through tocPartNumberAt, which reads it out of the Part R — name label; partNumberAt takes either. partDefinitionAt follows the number to the opener, and partGroup gathers the whole part — the announcement, every chapter's Part R, and every contents heading that names it — which partGroupAt reads from the cursor.
const firstHeadingTitleOf = (module: LoomModule): Option.Option<HeadingTitleToken> =>
Option.flatMap(
Array.findFirst(module.doc.sections, (section) => section.heading.title !== undefined),
(section) => Option.fromNullishOr(section.heading.title),
)
const chapterTitleToken = (
module: LoomModule,
): Option.Option<{ readonly source: string; readonly position: Position }> =>
Option.orElse(Option.fromNullishOr(module.doc.frontmatter?.title), () =>
firstHeadingTitleOf(module),
)
const chapterIndex = (modules: Modules): ReadonlyMap<string, CorpusLocation> =>
pipe(
Array.fromIterable(modules.values()),
Array.map((module) =>
Option.map(
chapterTitleToken(module),
(title) => [title.source, { path: module.path, position: title.position }] as const,
),
),
Array.getSomes,
Array.reduce(new Map<string, CorpusLocation>(), (index, [title, location]) =>
index.has(title) ? index : new Map(index).set(title, location),
),
)
const tocEntriesOf = (module: LoomModule): ReadonlyArray<TocWeft> =>
Array.flatMap(module.doc.sections, (section) => section.entries ?? [])
const tocEntryTitleAt = (modules: Modules, path: Path, offset: number): Option.Option<string> =>
pipe(
Option.fromNullishOr(modules.get(path)),
Option.flatMap((module) =>
Array.findFirst(
tocEntriesOf(module),
(entry) => entry.title !== undefined && covers(entry.title.position, offset),
),
),
Option.flatMap((entry) => Option.fromNullishOr(entry.title)),
Option.map((title) => title.value),
)
const frontmatterTitleAt = (modules: Modules, path: Path, offset: number): Option.Option<string> =>
pipe(
Option.fromNullishOr(modules.get(path)),
Option.flatMap((module) => Option.fromNullishOr(module.doc.frontmatter?.title)),
Option.filter((title) => covers(title.position, offset)),
Option.map((title) => title.value),
)
const chapterNameAt = (modules: Modules, path: Path, offset: number): Option.Option<string> =>
Option.orElse(tocEntryTitleAt(modules, path, offset), () =>
frontmatterTitleAt(modules, path, offset),
)
const chapterDefinitionAt = (
corpus: LoomCorpusAst,
path: Path,
offset: number,
): Option.Option<CorpusLocation> =>
Option.flatMap(chapterNameAt(corpus.modules, path, offset), (title) =>
Option.fromNullishOr(chapterIndex(corpus.modules).get(title)),
)
const isChapterTitled = (module: LoomModule, title: string): boolean =>
Option.getOrElse(
Option.map(chapterTitleToken(module), (token) => token.source === title),
() => false,
)
const titleDeclarationsOf = (module: LoomModule): ReadonlyArray<CorpusLocation> =>
Option.toArray(
Option.map(chapterTitleToken(module), (token) => ({
path: module.path,
position: token.position,
})),
)
const tocMentionsOf = (module: LoomModule, title: string): ReadonlyArray<CorpusLocation> =>
Array.getSomes(
Array.map(tocEntriesOf(module), (entry) =>
pipe(
Option.fromNullishOr(entry.title),
Option.filter((token) => token.value === title),
Option.map((token) => ({ path: module.path, position: token.position })),
),
),
)
const tocNameGroup = (modules: Modules, title: string): ReadonlyArray<CorpusLocation> =>
Array.flatMap(Array.fromIterable(modules.values()), (module) => [
...(isChapterTitled(module, title) ? titleDeclarationsOf(module) : []),
...tocMentionsOf(module, title),
])
const chapterGroupAt = (
corpus: LoomCorpusAst,
path: Path,
offset: number,
): ReadonlyArray<CorpusLocation> =>
Option.match(chapterNameAt(corpus.modules, path, offset), {
onNone: (): ReadonlyArray<CorpusLocation> => [],
onSome: (title) => tocNameGroup(corpus.modules, title),
})
export const unresolvedTocEntriesIn = (
corpus: LoomCorpusAst,
module: LoomModule,
): ReadonlyArray<TocTitleToken> => {
const index = chapterIndex(corpus.modules)
return Array.getSomes(
Array.map(tocEntriesOf(module), (entry) =>
pipe(
Option.fromNullishOr(entry.title),
Option.filter((title) => !index.has(title.value)),
),
),
)
}
const partOpenerIndex = (modules: Modules): ReadonlyMap<string, CorpusLocation> =>
pipe(
Array.fromIterable(modules.values()),
Array.map((module) =>
pipe(
Option.fromNullishOr(module.doc.frontmatter),
Option.flatMap((fm) =>
Option.all({
part: Option.fromNullishOr(fm.part),
partName: Option.fromNullishOr(fm.partName),
}),
),
Option.map(
({ part, partName }) =>
[part.value, { path: module.path, position: partName.position }] as const,
),
),
),
Array.getSomes,
Array.reduce(new Map<string, CorpusLocation>(), (index, [num, location]) =>
index.has(num) ? index : new Map(index).set(num, location),
),
)
const onToken = (token: { readonly position: Position } | undefined, offset: number): boolean =>
Option.match(Option.fromNullishOr(token), {
onNone: () => false,
onSome: (t) => covers(t.position, offset),
})
const frontmatterPartNumberAt = (module: LoomModule, offset: number): Option.Option<string> =>
pipe(
Option.fromNullishOr(module.doc.frontmatter),
Option.filter((fm) => onToken(fm.part, offset) || onToken(fm.partName, offset)),
Option.flatMap((fm) => Option.fromNullishOr(fm.part)),
Option.map((part) => part.value),
)
const tocPartsOf = (module: LoomModule): ReadonlyArray<TocPartToken> =>
Array.getSomes(Array.map(tocEntriesOf(module), (entry) => Option.fromNullishOr(entry.part)))
const partNumberMarker = /^Part\s+(\S+)/
const tocPartNumberAt = (module: LoomModule, offset: number): Option.Option<string> =>
pipe(
Array.findFirst(tocPartsOf(module), (part) => covers(part.position, offset)),
Option.flatMap((part) => Option.fromNullishOr(partNumberMarker.exec(part.value)?.[1])),
)
const partNumberAt = (modules: Modules, path: Path, offset: number): Option.Option<string> =>
pipe(
Option.fromNullishOr(modules.get(path)),
Option.flatMap((module) =>
Option.orElse(frontmatterPartNumberAt(module, offset), () => tocPartNumberAt(module, offset)),
),
)
const partDefinitionAt = (
corpus: LoomCorpusAst,
path: Path,
offset: number,
): Option.Option<CorpusLocation> =>
Option.flatMap(partNumberAt(corpus.modules, path, offset), (num) =>
Option.fromNullishOr(partOpenerIndex(corpus.modules).get(num)),
)
const frontmatterPartLocations = (module: LoomModule, num: string): ReadonlyArray<CorpusLocation> =>
pipe(
Option.fromNullishOr(module.doc.frontmatter?.part),
Option.filter((part) => part.value === num),
Option.map((part) => ({ path: module.path, position: part.position })),
Option.toArray,
)
const tocPartLocations = (module: LoomModule, num: string): ReadonlyArray<CorpusLocation> =>
Array.getSomes(
Array.map(tocPartsOf(module), (part) =>
pipe(
Option.fromNullishOr(partNumberMarker.exec(part.value)?.[1]),
Option.filter((n) => n === num),
Option.map(() => ({ path: module.path, position: part.position })),
),
),
)
const partGroup = (modules: Modules, num: string): ReadonlyArray<CorpusLocation> => [
...Option.toArray(Option.fromNullishOr(partOpenerIndex(modules).get(num))),
...Array.flatMap(Array.fromIterable(modules.values()), (module) => [
...frontmatterPartLocations(module, num),
...tocPartLocations(module, num),
]),
]
const partGroupAt = (
corpus: LoomCorpusAst,
path: Path,
offset: number,
): ReadonlyArray<CorpusLocation> =>
Option.match(partNumberAt(corpus.modules, path, offset), {
onNone: (): ReadonlyArray<CorpusLocation> => [],
onSome: (num) => partGroup(corpus.modules, num),
})definitionAt follows the reference under a cursor to its definition — a section anchor to its heading, a warp anchor to its warp, a contents entry or a chapter's own title to that chapter, and a Part R or a contents part heading to where the part is announced. referencesAt returns every place the thing under the cursor is named — a section and its anchors, a warp and its anchors, a chapter's title and the entries that name it, or a part's announcement and every Part R and contents heading that names it. Each reads the kind from symbolAt and dispatches on it through one Match.
export const definitionAt = (
corpus: LoomCorpusAst,
path: Path,
offset: number,
): Option.Option<CorpusLocation> =>
pipe(
Option.fromNullishOr(corpus.modules.get(path)),
Option.flatMap((module) =>
Option.flatMap(symbolAt(module.doc, offset), (symbol) =>
Match.value(symbol.kind).pipe(
Match.when('warpAnchor', () => warpDefLocation(module, path, offset)),
Match.when('sectionAnchor', () => sectionDefinitionAt(corpus, path, offset)),
Match.whenOr('tocEntry', 'frontmatterTitle', () =>
chapterDefinitionAt(corpus, path, offset),
),
Match.whenOr('tocPart', 'frontmatterPart', 'frontmatterPartName', () =>
partDefinitionAt(corpus, path, offset),
),
Match.orElse(() => Option.none<CorpusLocation>()),
),
),
),
)
export const referencesAt = (
corpus: LoomCorpusAst,
path: Path,
offset: number,
): ReadonlyArray<CorpusLocation> =>
Option.match(
pipe(
Option.fromNullishOr(corpus.modules.get(path)),
Option.flatMap((module) =>
Option.map(symbolAt(module.doc, offset), (symbol) => ({ module, symbol })),
),
),
{
onNone: () => [],
onSome: ({ module, symbol }) =>
Match.value(symbol.kind).pipe(
Match.whenOr('warpAnchor', 'warpDef', () =>
warpReferences(module, path, offset, (anchor) => anchor.position),
),
Match.whenOr('headingTitle', 'sectionAnchor', () =>
sectionReferencesAt(corpus, path, offset),
),
Match.whenOr('tocEntry', 'frontmatterTitle', () => chapterGroupAt(corpus, path, offset)),
Match.whenOr('tocPart', 'frontmatterPart', 'frontmatterPartName', () =>
partGroupAt(corpus, path, offset),
),
Match.orElse((): ReadonlyArray<CorpusLocation> => []),
),
},
)Renaming a section
A rename is one edit across the scope a name spans: a section's title and every anchor that resolves to it, a warp's definition and every anchor that binds to it, or a chapter's frontmatter title and every contents entry that names it, move together so the name and its references never drift. sectionRenameAt gathers a section's spans; the exported renameAt reads symbolAt, sending a warp to warpReferences and a chapter to chapterGroupAt. Each span is a name alone — a title, or the name inside a ::[…] — so the brackets and any (path) stay put. A part is not renamed here yet: its name is embedded in the contents label and its chapter references are bare numbers, so a clean rename waits on the tokeniser splitting that label into its number and name.
const sectionRenameAt = (
corpus: LoomCorpusAst,
path: Path,
offset: number,
): ReadonlyArray<CorpusLocation> => {
const modules = corpus.modules
const indexes = titleIndexes(modules)
return Option.match(targetAt(indexes, modules, path, offset), {
onNone: () => [],
onSome: (ref) => [
...Option.toArray(titleLocation(ref)),
...anchorsResolvingTo(indexes, modules, ref, (anchor) => anchor.name.position),
],
})
}
export const renameAt = (
corpus: LoomCorpusAst,
path: Path,
offset: number,
): ReadonlyArray<CorpusLocation> =>
Option.match(
pipe(
Option.fromNullishOr(corpus.modules.get(path)),
Option.flatMap((module) =>
Option.map(symbolAt(module.doc, offset), (symbol) => ({ module, symbol })),
),
),
{
onNone: () => [],
onSome: ({ module, symbol }) =>
Match.value(symbol.kind).pipe(
Match.whenOr('warpAnchor', 'warpDef', () =>
warpReferences(module, path, offset, (anchor) => anchor.name.position),
),
Match.whenOr('headingTitle', 'sectionAnchor', () =>
sectionRenameAt(corpus, path, offset),
),
Match.whenOr('tocEntry', 'frontmatterTitle', () => chapterGroupAt(corpus, path, offset)),
Match.orElse((): ReadonlyArray<CorpusLocation> => []),
),
},
)navigationRangeAt is the span a navigable symbol occupies — the whole title, anchor, warp, or Part R, read from the same classification the mappings and colours read. It is the range a definition link underlines on hover and the range a rename box covers, so the editor takes the whole name rather than the one word the cursor sits in. The rule is the capability: a kind whose profileOf sets navigation spans its whole token, and everything that draws a selection reads it here. renameRangeAt narrows that to the renameable — the span only where renameAt has edits to make — so a part, navigable but not yet renameable, underlines on hover yet opens no rename box.
export const navigationRangeAt = (
corpus: LoomCorpusAst,
path: Path,
offset: number,
): Option.Option<CorpusLocation> =>
pipe(
Option.fromNullishOr(corpus.modules.get(path)),
Option.flatMap((module) => symbolAt(module.doc, offset)),
Option.filter((symbol) => profileOf(symbol.kind).features.navigation === true),
Option.map((symbol): CorpusLocation => ({ path, position: symbol.position })),
)
export const renameRangeAt = (
corpus: LoomCorpusAst,
path: Path,
offset: number,
): Option.Option<CorpusLocation> =>
Option.filter(
navigationRangeAt(corpus, path, offset),
() => renameAt(corpus, path, offset).length > 0,
)Diagnostics across the corpus
Every node carries a health, and a health holds diagnostics. diagnosticsIn is the walk that reads them out. It descends any node, gathers the diagnostics it carries, and skips the health field itself, so a node's diagnostics are collected once rather than again at every child. It keeps every severity — error, warning, and informational note alike — because the editor surfaces them all.
moduleDiagnostics is the per-file view: one module's diagnostics from both tiers, the grammatical health on its parsed document and the semantic health on its product. The product pass lands each anchor fault on the part it builds for that anchor. An unresolved or ambiguous anchor leaves an empty Fragment carrying the fault, and a cross-language anchor leaves a NameRef carrying it. So the walk reads the product.code alone. A tangle sink's File repeats that same Code, and reading it too would count every sink's diagnostics twice.
An anchor into another file is the one anchor no single module can judge. Its edge names a section the product pass never saw, so the compiler judges it against the whole corpus. The compiler adds what it finds to the module's own diagnostics, both for the open .loom in the editor and for the tangle.
corpusErrors is the whole-corpus view the tangler takes, narrowed to errors. It reads each module's diagnostics through diagnosticsOf, keeps the error-severity ones, and returns every failing module with them. So the tangler refuses a broken build with one report rather than failing at the first bad file. diagnosticsOf defaults to moduleDiagnostics, and the compiler passes a reader that adds the cross-file judgement. A warning never blocks a tangle; only an error does.
const diagnosticsIn = (node: unknown): ReadonlyArray<Diagnostic> => {
if (Array.isArray(node)) return node.flatMap(diagnosticsIn)
if (node === null || typeof node !== 'object') return []
const self =
'health' in node
? (node as { health: { diagnostics: ReadonlyArray<Diagnostic> } }).health.diagnostics
: []
const nested = Object.entries(node).flatMap(([key, value]) =>
key === 'health' ? [] : diagnosticsIn(value),
)
return [...self, ...nested]
}
export const moduleDiagnostics = (module: LoomModule): ReadonlyArray<Diagnostic> => [
...diagnosticsIn(module.doc),
...diagnosticsIn(module.product.code),
]
export const corpusErrors = (
corpus: LoomCorpusAst,
diagnosticsOf: (module: LoomModule) => ReadonlyArray<Diagnostic> = moduleDiagnostics,
): ReadonlyArray<{
readonly path: Path
readonly diagnostics: ReadonlyArray<Diagnostic>
}> =>
pipe(
Array.fromIterable(corpus.modules.values()),
Array.map((m) => {
const diagnostics = Array.filter(diagnosticsOf(m), (d) => d.severity === 'error')
return diagnostics.length === 0 ? Option.none() : Option.some({ path: m.path, diagnostics })
}),
Array.getSomes,
)What this module draws on
Effect supplies Schema for the model, Array, Option, and pipe for the queries, and Match for the navigation's per-kind dispatch. The module bundles the parse model LoomDocument and the product model ProductSchema, with LoomSection, the unit the corpus walks, the WarpAnchorToken an anchor is, the WarpToken a value warp is defined by, the TocWeft a contents line is — with the TocTitleToken it names a chapter and the TocPartToken it names a part — and Diagnostic and Position, what a corpus walk gathers and a diagnostic lands on. symbolAt and profileOf from LoomSymbol name the token under a cursor and read its features, so the navigation reads the same table the mappings and colours do.
import { Array, Match, Option, pipe, Schema } from 'effect'
import { type Diagnostic, type Position } from '#ast/LoomNode'
import {
type HeadingTitleToken,
type TocPartToken,
type TocTitleToken,
type WarpAnchorToken,
type WarpToken,
} from '#ast/LoomTokens'
import { type TocWeft } from '#ast/Weft'
import { ProductSchema } from '#ast/ProductAst'
import { LoomDocumentSchema, type LoomDocument, type LoomSection } from '#ast/LoomAst'
import { profileOf, symbolAt } from '#ast/LoomSymbol'