SourceThe productThe product pass

Part IV, Chapter 1

Part IV: The product

Chapter 1: The product pass

Package: packages/loom-lang/src/ast/ProductBuilder.ts

Language: TypeScript

Raw source

The product pass

Loom's pipeline is a chain of small passes between explicit models. This module is one of them, and it is the one that composes: it folds a parsed .loom document — a LoomDocument of sections — straight into its Product, the author's own code with each ::[…] anchor turned into an edge. A section's name anchors already form the document's composition graph, so the pass reads that graph off the parse and builds the product from it, rather than generating a program to compute the same result.

The pass is a build, the same shape as the parser before it. The parser folds a stream of wefts into the Loom AST; this folds that AST's sections into the product. It is total by construction. Every section becomes one Code, and a section that names a file to write — with a [file] sink, or a {Tangle} specifier — becomes a File as well. An anchor that resolves to nothing becomes an empty Fragment carrying error health on its own node — a fault the editor reports, never a thrown exception. Grammatical health, a malformed anchor say, is already on the parse; semantic health, an anchor that resolves to nothing or crosses a language, is attached here, where the pass has the names to judge it.

What an anchor edge carries is an identity, not a copy. A NameRef records the SectionId its anchor resolves to and nothing more. fromProduct follows that edge across the corpus and inlines the target later, at projection.

So this pass stays within one document. A bare anchor resolves against the document's own sections, and the pass judges it in full. An anchor with a path, ::[Saying hello](greeting.loom), names a section in another file, which this pass never sees. Its edge records the file the path names and the name the section must carry there. crossFileFaults judges that edge later, once the compiler holds the whole corpus.

What this module draws on

The pass reads the Loom AST and writes the product AST, so it draws from both. From effect come the array and option combinators, Match for dispatching on a weft, Order for sorting a run's anchors by position, and pipe for the spine. From the Loom side come the document and section types, the warp and anchor tokens a section carries, and the section-body weft a code run is made of. The fault catalog gives faulty and the three anchor faults this pass raises, and the tokeniser's normaliseTitle folds a heading into the name that identifies its section. From the product side come the schema constructors for every node the pass builds — the Code of a section, its Fragment and NameRef leaves, and the File a {Tangle} section emits — together with the SectionId that stamps each one. From the corpus model come anchorModule, which picks the file an anchor resolves in, and the LoomModule the cross-file judgement reads, with the Diagnostic it returns.

TypeScript
import { Array, Context, Effect, Layer, Match, Option, Order, pipe } from 'effect'
import type { LoomDocument, LoomFrontmatter, LoomSection } from '@athrio/loom-ast/LoomAst'
import {
  okHealth,
  type Diagnostic,
  type Health,
  type Point,
  type Position,
} from '@athrio/loom-ast/LoomNode'
import { anchorModule, type LoomModule } from '@athrio/loom-ast/LoomCorpusAst'
import type { SinkToken, WarpAnchorToken, WarpToken } from '@athrio/loom-ast/LoomTokens'
import type { SectionBodyWeft } from '@athrio/loom-ast/Weft'
import {
  type Code,
  CodeSchema,
  type File,
  FileSchema,
  type Fragment,
  FragmentSchema,
  type NameRef,
  NameRefSchema,
  type Part,
  type Product,
  ProductSchema,
  type SectionId,
} from '@athrio/loom-ast/ProductAst'
import {
  AmbiguousAnchor,
  CrossLanguageAnchor,
  faulty,
  UnknownVariable,
  UnresolvedAnchor,
} from '#ast/LoomFault'
import { normaliseTitle } from '#ast/WeftTokeniser'

The builder and its spine

ProductBuilder is the service the rest of the pipeline calls. Its one method, build, wraps the pure buildProduct in an Effect so callers compose it like any other pass. It takes the document's file path alongside the document, because each section's identity is its path and name, and the path is known only to the caller. It also takes the package's primary language, the default a section inherits when the frontmatter declares no Language, which the caller resolves from the file's configuration.

buildProduct is the spine, and it runs from the document's head to its tail. It resolves the primary language and the output package from the frontmatter, indexes the sections by the name each anchor reaches them by, then maps every section to a Built — its Code, and the File a {Tangle} section or a tangle sink adds — and folds those into the module's Product.

TypeScript
export class ProductBuilder extends Context.Service<ProductBuilder>()('ProductBuilder', {
  make: Effect.succeed({
    build: (
      doc: LoomDocument,
      path: string,
      packageLanguage?: string,
      variables: Record<string, string> = {},
    ): Effect.Effect<Product> =>
      Effect.sync(() => buildProduct(doc, path, packageLanguage, variables)),
  }),
}) {
  static readonly layer = Layer.effect(this, this.make)
}

export const buildProduct = (
  doc: LoomDocument,
  modulePath: string,
  packageLanguage?: string,
  variables: Record<string, string> = {},
): Product => {
  const fm = Option.fromNullishOr(doc.frontmatter)
  const pkg = pipe(
    fm,
    Option.flatMapNullishOr((f) => f.package),
    Option.map((p) => p.value),
  )
  const lang = pipe(
    frontmatterLanguage(fm),
    Option.orElse(() => Option.fromNullishOr(packageLanguage)),
    Option.getOrElse(() => 'plaintext'),
  )
  const index = nameIndex(doc.sections, lang, pkg)
  return pipe(
    doc.sections,
    Array.map(buildSection(index, lang, modulePath, pkg, variables)),
    Array.reduce(emptyProduct, appendSection),
  )
}

const emptyProduct: Product = ProductSchema.make({ code: [], files: [] })

const appendSection = (product: Product, built: Built): Product =>
  ProductSchema.make({
    code: [...product.code, built.code],
    files: [...product.files, ...Option.toArray(built.file)],
  })

Resolving names

A name anchor reaches a section by its heading title — the one handle every section has. The index is keyed by that title, and each title maps to the section the title names: its title normalised to an identifier.

A title can repeat, and the index keeps every section that claims one rather than letting the last overwrite the rest. So a title maps to a list of sections, not a single one. A list of one is a clean resolution; a longer list is an ambiguity the anchor reports rather than guesses through.

The primary language is the one a specifier-less section inherits, resolved from three sources in order. The frontmatter's Language field sets it, lowercased to a language id. The field is optional: when the frontmatter omits it, the language falls back to the package's configured language, passed in as packageLanguage, and then to plaintext when the package configures none. frontmatterLanguage reads the field and yields nothing when it is absent, so the fallback chain walks the three sources in turn.

A tangle sink resolves its language a fourth way: it carries no specifier, only a path, so extensionLanguage reads the language off the file's name. It maps .ts and its siblings to typescript, .tsx to tsx, the JavaScript extensions to javascript and jsx, .json to json, .md to markdown, and .yml to yaml. Those last two are the short forms of names a frontmatter writes in full, so a Markdown chapter can anchor a section into its own .md sink. An unlisted extension falls back to itself, so a new language reads as its bare extension until a service claims it. A .json sink composes as JSON even in a TypeScript document.

A file with no extension takes its whole name as its language, read from the last path segment only. A Dockerfile composes dockerfile, a LICENSE composes license, and a .gitignore, whose one dot only marks it hidden, composes gitignore. A chapter that declares the same language can then anchor into it.

sectionLanguage resolves any section's language by these rules: its specifier, a sink's extension, or the document default. The weave labels each code block by the same rule, so a page names a section's language exactly as the tangler composes it. nameIndex records the language beside each title, so when an anchor binds, the pass compares the composing section's language to the section it reaches and marks a cross-language transclusion when they differ.

TypeScript
type NameEntry = {
  readonly name: string
  readonly pos: Position
  readonly language: string
}
type NameIndex = ReadonlyMap<string, ReadonlyArray<NameEntry>>

const specifierLanguage = (
  label: string,
  sink: Option.Option<SinkToken>,
  defaultLang: string,
  pkg: Option.Option<string>,
): string => {
  const id = label.toLowerCase()
  if (id === 'tangle')
    return Option.match(pkg, {
      onNone: () => defaultLang,
      onSome: (p) => extensionLanguage(tangleFilePath(p, sink)),
    })
  if (id === 'toc') return 'prose'
  return id
}

export const sectionLanguage = (
  section: LoomSection,
  defaultLang: string,
  pkg: Option.Option<string>,
): string => {
  const { specifier, sink } = section.heading
  if (specifier !== undefined)
    return specifierLanguage(specifier.label.value, Option.fromNullishOr(sink), defaultLang, pkg)
  if (sink === undefined) return defaultLang
  return extensionLanguage(sink.file.value)
}

const nameIndex = (
  sections: ReadonlyArray<LoomSection>,
  defaultLang: string,
  pkg: Option.Option<string>,
): NameIndex =>
  pipe(
    sections,
    Array.map((s) =>
      Option.map(
        Option.fromNullishOr(s.heading.title),
        (title) =>
          [
            title.source,
            {
              name: sectionNameOf(s),
              pos: title.position,
              language: sectionLanguage(s, defaultLang, pkg),
            },
          ] as const,
      ),
    ),
    Array.getSomes,
    Array.reduce(new Map<string, ReadonlyArray<NameEntry>>(), (index, [title, entry]) =>
      new Map(index).set(title, [...(index.get(title) ?? []), entry]),
    ),
  )

const frontmatterLanguage = (frontmatter: Option.Option<LoomFrontmatter>): Option.Option<string> =>
  pipe(
    frontmatter,
    Option.flatMapNullishOr((f) => f.language),
    Option.map((l) => l.value.trim().toLowerCase()),
  )

const languageByExtension: ReadonlyMap<string, string> = new Map([
  ['ts', 'typescript'],
  ['mts', 'typescript'],
  ['cts', 'typescript'],
  ['tsx', 'tsx'],
  ['js', 'javascript'],
  ['mjs', 'javascript'],
  ['cjs', 'javascript'],
  ['jsx', 'jsx'],
  ['json', 'json'],
  ['md', 'markdown'],
  ['yml', 'yaml'],
])

const extensionLanguage = (path: string): string => {
  const name = path.slice(path.lastIndexOf('/') + 1).toLowerCase()
  const dot = name.lastIndexOf('.')
  const ext = dot < 0 ? name : name.slice(dot + 1)
  return languageByExtension.get(ext) ?? ext
}

Value warps

A warp binds a name its section's anchors compose. A value warp binds the name to a literal — a string, a number — and ::[n] substitutes that value where the anchor stood. A value needs no edge and no section: the pass drops it in place. A quoted string yields its content — {{kw = "const"}} lands const — and the author writes any surrounding syntax, the quotes for a string slot, around the anchor itself.

A value may also draw on the workspace. Writing ${name} in a value interpolates the workspace variable name, declared under variables: in the configuration, so a version or any value the whole corpus shares lives in one place. The reference resolves bare or inside a string, since the pass interpolates after it decodes the literal. A ${name} that no variable declares stays in the text and raises an UnknownVariable on the warp, so a typo is loud rather than silent. Interpolation touches a warp's value alone, never a section's code, so a ${…} a target language writes is left untouched.

valueWarps gathers them. It keeps each warp that carries a value, decodes and interpolates it, and maps the name to that text, its position, and its health. decodeLiteral unwraps a quoted string to its content and leaves a bare number as written, and interpolate then fills each ${name} from the variables, so a warp resolves to a literal value, never an evaluated expression. A warp with no value binds nothing — a fault the tokeniser already raised. The document's lang warp carries an annotation rather than a value, so it never enters the map.

TypeScript
interface ValueWarp {
  readonly text: string
  readonly pos: Position
  readonly health: Health
}

const decodeLiteral = (text: string): string => {
  const s = text.trim()
  const quoted = /^"(?:\\.|[^"\\])*"$/.test(s) || /^'(?:\\.|[^'\\])*'$/.test(s)
  return quoted ? s.slice(1, -1).replace(/\\(.)/g, (_, c) => c) : text
}

const variablePattern = /[$][{]([A-Za-z_][A-Za-z0-9_]*)[}]/g

const interpolate = (
  text: string,
  variables: Record<string, string>,
): { readonly text: string; readonly missing: Option.Option<string> } => ({
  text: text.replace(variablePattern, (whole, name: string) =>
    name in variables ? variables[name]! : whole,
  ),
  missing: pipe(
    Array.fromIterable(text.matchAll(variablePattern)),
    Array.map((match) => match[1]!),
    Array.filter((name) => !(name in variables)),
    Array.head,
  ),
})

const valueWarps = (
  warps: ReadonlyArray<WarpToken>,
  variables: Record<string, string>,
): ReadonlyMap<string, ValueWarp> =>
  new Map(
    Array.getSomes(
      Array.map(warps, (w) =>
        Option.map(Option.fromNullishOr(w.default), (def) => {
          const { text, missing } = interpolate(decodeLiteral(def.value), variables)
          return [
            w.name.value,
            {
              text,
              pos: def.position,
              health: Option.match(missing, {
                onNone: () => okHealth,
                onSome: (name) => faulty(UnknownVariable({ name }), def.position),
              }),
            },
          ] as const
        }),
      ),
    ),
  )

Building one section

A section's heading decides what it contributes. Every section composes to one Code: its identity, its language, and its fragments in composition order. A section that names a file — through a [file] sink, or a {Tangle} specifier — contributes a File on top, the same Code paired with the path it tangles to.

A Built is the pair a section yields: its Code, which always lands in the product, and the File a {Tangle} section or a tangle sink adds, which an ordinary section leaves absent. buildSection gathers the section's value warps and interpolates each against the workspace variables, resolves its language and identity, and composes its code.

TypeScript
interface Built {
  readonly code: Code
  readonly file: Option.Option<File>
}

const buildSection =
  (
    index: NameIndex,
    defaultLang: string,
    modulePath: string,
    pkg: Option.Option<string>,
    variables: Record<string, string>,
  ) =>
  (section: LoomSection): Built => {
    const values = valueWarps(
      section.preamble.flatMap((w) => w.warps),
      variables,
    )
    const origin: SectionId = { path: modulePath, name: sectionNameOf(section) }
    const languageId = sectionLanguage(section, defaultLang, pkg)
    const code = buildCode(section, values, index, origin, languageId)
    const file = tangleFile(section, pkg, code)
    return { code, file }
  }

tangleFile decides whether a section names a file, and filePath decides where. A heading names a file two ways: a [file] sink under no specifier, or a {Tangle} specifier. Any other specifier is a language label, so the section composes in place and writes nothing. filePath then resolves the target. Under a frontmatter Package the file lands there, where tangleFilePath appends the [file] tag to a directory package and passes a file package through unchanged. Under no package the sink names the file by itself, and the compiler resolves where it lands. So the smallest program that tangles is a heading with a sink, an arrow, and its code: # Hi there! [mini.ts] writes mini.ts, its language read from the extension.

TypeScript
const tangleFilePath = (pkg: string, sink: Option.Option<SinkToken>): string =>
  pkg.endsWith('/')
    ? pkg +
      Option.getOrElse(
        Option.map(sink, (s) => s.file.value),
        () => '',
      )
    : pkg

const filePath = (
  pkg: Option.Option<string>,
  sink: Option.Option<SinkToken>,
): Option.Option<string> =>
  Option.match(pkg, {
    onSome: (p) => Option.some(tangleFilePath(p, sink)),
    onNone: () => Option.map(sink, (s) => s.file.value),
  })

const tangleFile = (
  section: LoomSection,
  pkg: Option.Option<string>,
  code: Code,
): Option.Option<File> => {
  const sink = Option.fromNullishOr(section.heading.sink)
  const target = Option.match(Option.fromNullishOr(section.heading.specifier), {
    onNone: () => Option.flatMap(sink, () => filePath(pkg, sink)),
    onSome: (spec) =>
      Match.value(spec.label.value.toLowerCase()).pipe(
        Match.when('tangle', () => filePath(pkg, sink)),
        Match.orElse(() => Option.none<string>()),
      ),
  })
  return Option.map(target, (path) => FileSchema.make({ path, code }))
}

Composing a section's code

buildCode turns a section's code into one Code. It walks each code run, binding each anchor against the value warps and the name index, and gathers the fragments and edges the runs yield. The Code carries the section's identity and language; a section with no code composes an empty one.

A code piece is the unit of composition: one line from a code weft, or the inline code after an arrow, extended to the line's end so its trailing newline is kept. pieceOf lifts a weft to a piece, and a prose weft yields none.

A run sheds its blank lines at the edges but keeps them inside. The section's last run trims both edges, since it closes the section; every earlier run keeps a single trailing blank as its seam to the next code chunk. So a section that interleaves code and prose composes with one blank line at each seam, never a doubled gap. codeRuns splits the section into these runs, one per => arrow, and discards the prose between them.

walkRun turns one run into its parts. The run's anchors, sorted by position, partition its text: each anchor contributes the fragment before it and then its edge, and a closing fragment runs to the end. Each fragment carries its .loom span as the origin it maps back to, and the text is the source slice itself — no escaping, since a fragment is data the projection reads, not a literal it must quote. Each boundary is computed from the anchor's index, so no cursor threads through the text.

TypeScript
const buildCode = (
  section: LoomSection,
  values: ReadonlyMap<string, ValueWarp>,
  index: NameIndex,
  origin: SectionId,
  languageId: string,
): Code => {
  const bind = bindAnchor(index, values, languageId, origin.path)
  const fragments = pipe(codeRuns(section.code), Array.flatMap(walkRun(bind)))
  return CodeSchema.make({ origin, languageId, fragments })
}

interface CodePiece {
  readonly source: string
  readonly position: Position
  readonly anchors: ReadonlyArray<WarpAnchorToken>
}

const pieceOf = (w: SectionBodyWeft): Option.Option<CodePiece> =>
  pipe(
    Match.value(w),
    Match.when({ type: 'CodeWeft' }, (c) =>
      Option.some({ source: c.source, position: c.position, anchors: c.anchors }),
    ),
    Match.when({ type: 'ArrowWeft' }, (a) =>
      Option.map(Option.fromNullishOr(a.code), (code) => ({
        source: a.source.slice(code.position.start.offset - a.position.start.offset),
        position: between(code.position.start, a.position.end),
        anchors: a.anchors,
      })),
    ),
    Match.orElse(() => Option.none<CodePiece>()),
  )

const isBlank = (p: CodePiece): boolean => p.source.trim().length === 0

const dropLeadingBlank = (run: ReadonlyArray<CodePiece>): ReadonlyArray<CodePiece> =>
  Array.dropWhile(run, isBlank)

const dropTrailingBlank = (run: ReadonlyArray<CodePiece>): ReadonlyArray<CodePiece> =>
  pipe(run, Array.reverse, Array.dropWhile(isBlank), Array.reverse)

const trimBlank = (run: ReadonlyArray<CodePiece>): ReadonlyArray<CodePiece> =>
  dropTrailingBlank(dropLeadingBlank(run))

const seamTrailing = (run: ReadonlyArray<CodePiece>): ReadonlyArray<CodePiece> => {
  const lead = dropLeadingBlank(run)
  const body = dropTrailingBlank(lead)
  return Option.match(Array.get(lead, body.length), {
    onNone: () => body,
    onSome: (blank) => [...body, blank],
  })
}

const sealRuns = (
  runs: ReadonlyArray<ReadonlyArray<CodePiece>>,
): ReadonlyArray<ReadonlyArray<CodePiece>> =>
  Array.map(runs, (run, i) => (i === runs.length - 1 ? trimBlank(run) : seamTrailing(run)))

const codeRuns = (code: ReadonlyArray<SectionBodyWeft>): ReadonlyArray<ReadonlyArray<CodePiece>> =>
  Array.isReadonlyArrayNonEmpty(code)
    ? pipe(
        code,
        Array.groupWith((a, _b) => a.type !== 'ArrowWeft'),
        Array.map((block) => Array.getSomes(Array.map(block, pieceOf))),
        Array.filter((run) => Array.some(run, (p) => !isBlank(p))),
        sealRuns,
      )
    : []

const walkRun =
  (bind: (anchor: WarpAnchorToken) => Part) =>
  (pieces: ReadonlyArray<CodePiece>): ReadonlyArray<Part> => {
    const { start, end } = spanOf(pieces)
    const text = sourceOf(pieces)
    const slice = (from: Point, to: Point): string =>
      text.slice(from.offset - start.offset, to.offset - start.offset)
    const fragment = (from: Point, to: Point): ReadonlyArray<Fragment> => {
      const lit = slice(from, to)
      return lit.length === 0 ? [] : [FragmentSchema.make({ text: lit, origin: between(from, to) })]
    }
    const anchors = pipe(
      pieces,
      Array.flatMap((p) => p.anchors),
      Array.sort(byStart),
    )
    const opensAt = (i: number): Point =>
      pipe(
        Array.get(anchors, i - 1),
        Option.match({ onNone: () => start, onSome: (a) => a.position.end }),
      )
    return [
      ...Array.flatMap(anchors, (anchor, i) => [
        ...fragment(opensAt(i), anchor.position.start),
        bind(anchor),
      ]),
      ...fragment(opensAt(anchors.length), end),
    ]
  }

const byStart: Order.Order<WarpAnchorToken> = Order.mapInput(
  Order.Number,
  (a) => a.position.start.offset,
)

Binding an anchor

bindAnchor turns one anchor into one part. A value warp composes its literal, a named section composes an edge to that section, and an anchor that resolves to nothing composes an empty fragment in its place. So a broken or unresolvable anchor never leaves a dangling reference: the section always composes, and the product code around the anchor keeps its language service.

An anchor with a path is bound first, because it names a file before it names a section. anchorModule resolves the path against this document's own path, and when it lands on another file, crossFileRef composes a NameRef to that file at once. The edge carries the name normaliseTitle folds from the anchor, which is the name the target section's own Code carries. This pass sees no other file, so it cannot tell whether that section exists, and the edge leaves here healthy. crossFileFaults, in the next section, judges it against the corpus. A path that lands back on this document binds like a bare anchor.

An anchor that stays in this document takes one of two paths. When the name is a value warp, the anchor composes that warp's text as a Fragment where the anchor stood, carrying the warp's health so an undeclared ${name} raises its fault at the warp. No edge, since a literal reaches no section. Otherwise the name is looked up among the section titles. One match resolves to a NameRef, the edge that carries the target section's identity; the projection follows it and inlines the section there. A match in another language is a cross-language transclusion: the edge stands, since the section is real, but the pass marks the anchor, because a JSON section composed into a TypeScript one shares no file.

An anchor that cannot resolve leaves an empty fragment instead, and what rides on that fragment depends on why it failed. A grammatically broken anchor — an empty ::[] or an unclosed ::[n — already carries the parser's fault on its own span, so the fragment adds none. A well-formed name that matches no section is unresolved; one that matches more than one is ambiguous. The fragment carries that diagnostic and lands it on the anchor.

TypeScript
const bindAnchor =
  (index: NameIndex, values: ReadonlyMap<string, ValueWarp>, host: string, modulePath: string) =>
  (anchor: WarpAnchorToken): Part => {
    const name = anchor.name.value
    const at = anchor.name.position
    if (anchor.health.status !== 'ok') return blankFragment(anchor.position)
    const file = anchorModule(modulePath, anchor)
    if (file !== modulePath) return crossFileRef(file, name, at)
    const value = values.get(name)
    if (value !== undefined) {
      return FragmentSchema.make({ text: value.text, origin: at, health: value.health })
    }
    return Array.matchLeft(index.get(name) ?? [], {
      onEmpty: () => blankFragment(anchor.position, unresolved(name, at)),
      onNonEmpty: (entry, rest) =>
        rest.length === 0
          ? NameRefSchema.make({
              target: Option.some<SectionId>({
                path: modulePath,
                name: entry.name,
              }),
              anchor: at,
              health:
                entry.language === host ? okHealth : crossLanguage(name, at, host, entry.language),
            })
          : blankFragment(anchor.position, ambiguous(name, at, rest.length + 1)),
    })
  }

const crossFileRef = (path: string, title: string, at: Position): NameRef =>
  NameRefSchema.make({
    target: Option.some<SectionId>({ path, name: normaliseTitle(title) }),
    anchor: at,
  })

const blankFragment = (at: Position, health: Health = okHealth): Fragment =>
  FragmentSchema.make({ text: '', origin: at, health })

const unresolved = (name: string, at: Position): Health => faulty(UnresolvedAnchor({ name }), at)

const ambiguous = (name: string, at: Position, count: number): Health =>
  faulty(AmbiguousAnchor({ name, count }), at)

const crossLanguage = (name: string, at: Position, host: string, found: string): Health =>
  faulty(CrossLanguageAnchor({ name, host, found }), at)

Judging an edge across files

An edge into another file leaves the product pass unjudged. crossFileFaults judges it once the compiler holds the whole corpus. It reads one module's product with every other module beside it, and returns a diagnostic for each edge into another file that cannot compose. The compiler adds these to the module's own diagnostics. So the editor marks a broken cross-file anchor where the author wrote it, and the tangle refuses to write a file that would silently drop one.

The judgement asks the three questions the pass asks of a local anchor, this time of the file the path names. A name that titles no section there is unresolved, and so is a name whose path names no loom in the corpus. A name that titles more than one section is ambiguous. A section in another language is a cross-language transclusion, measured against the language of the section that holds the anchor. An edge that passes all three composes, and fromProduct inlines it.

A NameRef keeps only the folded name, so the judgement reads the anchor's raw name from the module's source, under the edge's anchor span. crossFileEdge picks the parts worth judging: an edge whose target lies in another file. codesTitled finds the sections in the target file whose raw title matches the name, and returns the Code each one composed, so edgeHealth reads a candidate's language off its product. languageHealth is the last question alone: the found language against the host's.

TypeScript
export const crossFileFaults = (
  modules: ReadonlyMap<string, LoomModule>,
  module: LoomModule,
): ReadonlyArray<Diagnostic> =>
  pipe(
    module.product.code,
    Array.flatMap((host) =>
      Array.flatMap(host.fragments, (part) =>
        pipe(
          crossFileEdge(module, part),
          Option.map(
            ({ ref, target }) => edgeHealth(modules, module, host, ref, target).diagnostics,
          ),
          Option.getOrElse((): ReadonlyArray<Diagnostic> => []),
        ),
      ),
    ),
  )

const crossFileEdge = (
  module: LoomModule,
  part: Part,
): Option.Option<{ readonly ref: NameRef; readonly target: SectionId }> =>
  part.type === 'NameRef'
    ? pipe(
        part.target,
        Option.filter((target) => target.path !== module.path),
        Option.map((target) => ({ ref: part, target })),
      )
    : Option.none()

const codesTitled = (module: LoomModule, title: string): ReadonlyArray<Code> =>
  pipe(
    module.doc.sections,
    Array.filter((section) => section.heading.title?.source === title),
    Array.flatMap((section) =>
      Option.toArray(
        Array.findFirst(module.product.code, (code) => code.origin.name === sectionNameOf(section)),
      ),
    ),
  )

const edgeHealth = (
  modules: ReadonlyMap<string, LoomModule>,
  module: LoomModule,
  host: Code,
  ref: NameRef,
  target: SectionId,
): Health => {
  const at = ref.anchor
  const name = module.text.slice(at.start.offset, at.end.offset)
  const found = pipe(
    Option.fromNullishOr(modules.get(target.path)),
    Option.map((there) => codesTitled(there, name)),
    Option.getOrElse((): ReadonlyArray<Code> => []),
  )
  return Array.matchLeft(found, {
    onEmpty: () => unresolved(name, at),
    onNonEmpty: (code, rest) =>
      rest.length > 0
        ? ambiguous(name, at, rest.length + 1)
        : languageHealth(name, at, host.languageId, code.languageId),
  })
}

const languageHealth = (name: string, at: Position, host: string, found: string): Health =>
  found === host ? okHealth : crossLanguage(name, at, host, found)

A section's name and its spans

A section's name is its heading title normalised to an identifier, through the tokeniser's normaliseTitle — Multiplier Function becomes MultiplierFunction. Every heading has a title, so the function is total, and the name pairs with the module path to make the section's corpus-wide identity. The formatter reads the same name, to pair a section with the Code the pass builds for it.

The span helpers read a run's source and bounds. sourceOf joins a piece list's source, spanOf takes the span from its first and last positions, and between pairs two points into a span.

TypeScript
export const sectionNameOf = (section: LoomSection): string =>
  normaliseTitle(section.heading.title?.source ?? '')

const sourceOf = (nodes: ReadonlyArray<{ readonly source: string }>): string =>
  nodes.map((n) => n.source).join('')

const spanOf = (nodes: ReadonlyArray<{ readonly position: Position }>): Position => ({
  start: nodes[0]!.position.start,
  end: nodes[nodes.length - 1]!.position.end,
})

const between = (start: Point, end: Point): Position => ({ start, end })

The module

The tangle composes the sections in the source's own order — the spine first, then the helpers it reaches for, each section named by its title, the local anchor form Loom prefers.

Loom