SourceIn the editorThe projection pass

Part VI, Chapter 3

Part VI, Chapter 3: The projection pass

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

Language: TypeScript

Raw source

The projection pass

LoomVirtualCodeBuilder produces the LoomVirtualCode a .loom presents to Volar, in two projections that share one model. fromProduct walks a section's Code and emits its de re product, inlining transclusions by following Refs across the corpus. fromProse blanks a file's code and emits what remains as Markdown. fromProduct folds its pieces with a monoid, and rootVirtualCode assembles a file's tree — the source as the de dicto root, the product sections and prose beneath it. Pure and total.

Resolving against the corpus

fromProduct resolves a Ref's { path, name } target against the corpus modules. lookup finds the module at the path, takes its de re product, and finds the Code whose origin carries the name. A module the run has not produced yet, or one with no section by that name, resolves to nothing.

TypeScript
const lookup = (modules: ReadonlyMap<Path, LoomModule>, id: SectionId): Option.Option<Code> =>
  pipe(
    Option.fromNullishOr(modules.get(id.path)),
    Option.flatMapNullishOr((m) => m.product),
    Option.flatMap((product) => Array.findFirst(product.code, (c) => c.origin.name === id.name)),
  )

The fold monoid

A childless LoomVirtualCode is the unit a walk produces and folds. concat appends one after another, shifting the later one's mapping offsets past the earlier's code; the first's identity wins. leaf makes a unit, and emptyLeaf is the seed.

TypeScript
const leaf = (
  id: string,
  languageId: string,
  code: string,
  mappings: ReadonlyArray<Mapping>,
): LoomVirtualCode => ({ id, languageId, code, mappings, embeddedCodes: [] })

const emptyLeaf: LoomVirtualCode = leaf('', '', '', [])

const concat = (a: LoomVirtualCode, b: LoomVirtualCode): LoomVirtualCode => ({
  id: a.id,
  languageId: a.languageId,
  code: a.code + b.code,
  mappings: [
    ...a.mappings,
    ...Array.map(b.mappings, (m) => ({
      ...m,
      genStart: m.genStart + a.code.length,
    })),
  ],
  embeddedCodes: a.embeddedCodes,
})

Inlining the product

The de re walk inlines a section's fragments. inlineComposed folds them left to right, carrying both the text emitted so far and each fragment's successor, so it can tell whether an anchor stands alone on its line. A Ref that does is re-indented to that line's indent (the next section), and every other fragment — an anchor with code beside it among them — is appended as it stands. The fold also carries a flag. After an anchor that owned its line, the fragment that follows opens with the spaces the author left past the anchor, so the fold trims them.

inlinePart handles each fragment. A Fragment becomes a product leaf, and a reference is followed and inlined, contributing nothing when its target is unresolved, missing, or cyclic. absorbTrailingNewline, with clipMappings, sheds a transcluded block's trailing newline so the sink's layout becomes the output's.

A leaf maps to its origin only when its section lives in the file being projected, the home file the walk carries down from its root. A mapping is a span in that one file's source, while a Fragment's origin is a span in the file its own section came from. So a block that a cross-file anchor pulls in still lands in the code, and the product type-checks whole, but the block maps nowhere in the home file. Its own file projects it again, with mappings of its own, and an edit to that block belongs there.

TypeScript
const clipMappings = (mappings: ReadonlyArray<Mapping>, genLen: number): ReadonlyArray<Mapping> =>
  Array.getSomes(
    Array.map(mappings, (m) =>
      m.genStart >= genLen
        ? Option.none()
        : Option.some(
            m.genStart + m.genLength <= genLen ? m : { ...m, genLength: genLen - m.genStart },
          ),
    ),
  )

const absorbTrailingNewline = (vc: LoomVirtualCode): LoomVirtualCode => {
  const code = vc.code.replace(/\n+$/, '')
  return code.length === vc.code.length
    ? vc
    : { ...vc, code, mappings: clipMappings(vc.mappings, code.length) }
}

const inlinePart =
  (
    modules: ReadonlyMap<Path, LoomModule>,
    seen: ReadonlySet<string>,
    home: Path,
    mapped: boolean,
  ) =>
  (part: Fragment | Ref): LoomVirtualCode => {
    if (part.type === 'Fragment') {
      const origin: Mapping = {
        genStart: 0,
        genLength: part.text.length,
        source: part.origin,
        kind: 'product',
      }
      return leaf('', '', part.text, mapped ? [origin] : [])
    }
    return pipe(
      part.target,
      Option.filter((t) => !seen.has(keyOf(t))),
      Option.flatMap((t) => Option.map(lookup(modules, t), (node) => [t, node] as const)),
      Option.match({
        onNone: () => emptyLeaf,
        onSome: ([t, node]) =>
          absorbTrailingNewline(inlineComposed(modules, new Set([...seen, keyOf(t)]), home)(node)),
      }),
    )
  }

const inlineComposed =
  (modules: ReadonlyMap<Path, LoomModule>, seen: ReadonlySet<string>, home: Path) =>
  (node: Code): LoomVirtualCode => {
    const build = inlinePart(modules, seen, home, node.origin.path === home)
    const seed: { vc: LoomVirtualCode; trim: boolean } = {
      vc: emptyLeaf,
      trim: false,
    }
    return pipe(
      node.fragments,
      Array.reduce(seed, (acc, part, i) => {
        if (part.type === 'Fragment') {
          return {
            vc: concat(acc.vc, build(acc.trim ? trimLeadingBlank(part) : part)),
            trim: false,
          }
        }
        const owns = aloneBefore(acc.vc.code) && aloneAfter(node.fragments[i + 1])
        return {
          vc: concat(
            acc.vc,
            owns ? indentBlock(build(part), lineIndentOf(acc.vc.code)) : build(part),
          ),
          trim: owns,
        }
      }),
      (acc) => acc.vc,
    )
  }

Re-indenting a transcluded block

An anchor that stands alone on its line carries that line's indentation into what it pulls in. Write ::[body] four spaces deep and every line of the inlined block lands four spaces deep, not just the first. For Python or Scala 3, where indentation marks block structure, this is what lets a transcluded block land at the depth its anchor sits at rather than break the file.

An anchor owns its line when only spaces and tabs sit between the line break and the anchor, and only whitespace between the anchor and the next break. aloneBefore and aloneAfter test the two sides. The trailing whitespace aloneAfter allows is the spacebar an author taps out of habit after the anchor: it never disqualifies the anchor, and trimLeadingBlank strikes it from the next fragment so the block's last line carries no stray spaces. An anchor with real code beside it — result = ::[body], or ::[body] more() — stays inline, its block pulled in as written with no re-indentation.

When the anchor owns its line, the whitespace opening that line is the indent. lineIndentOf reads it off the text folded so far, where the current line is whitespace to the anchor, and it is taken verbatim — the spaces and tabs the author wrote, exactly.

indentBlock prepends that indent to every line of the block but the first, which already sits after the indent its own fragment wrote. A blank line stays blank — it takes no indent — so the output grows no trailing whitespace.

The inserted indent is synthetic. It has no origin in any .loom, so no mapping may claim it, or the editor would map a click on the indent into the source. reindentMapping cuts each mapping at the block's line starts, so the indent always falls in the gap between two pieces and never inside one. A piece then shifts by the indentation added on the lines above it, and re-derives its source per line through advance, walking the block text to the cut. Every mapping tracks its source one-to-one — each is a Fragment's span — so the same derivation serves them all.

lineStartsOf gives each line's offset in the original block, and newStartsOf the offset of its content once the indents are in. lineIndexOf finds which line a cut falls on.

TypeScript
const aloneBefore = (code: string): boolean =>
  /^[\t ]*$/.test(code.slice(code.lastIndexOf('\n') + 1))

const aloneAfter = (next: Fragment | Ref | undefined): boolean =>
  next === undefined || (next.type === 'Fragment' && /^[\t ]*(?:\n|$)/.test(next.text))

const lineIndentOf = (code: string): string => code.slice(code.lastIndexOf('\n') + 1)

const advance = (base: Position['start'], text: string): Position['start'] => {
  const newlines = text.split('\n').length - 1
  const lastNewline = text.lastIndexOf('\n')
  return {
    line: base.line + newlines,
    column:
      base.column === undefined
        ? undefined
        : newlines === 0
          ? base.column + text.length
          : text.length - lastNewline,
    offset: base.offset + text.length,
  }
}

const trimLeadingBlank = (fragment: Fragment): Fragment => {
  const blank = /^[\t ]+/.exec(fragment.text)
  return blank === null
    ? fragment
    : {
        ...fragment,
        text: fragment.text.slice(blank[0].length),
        origin: {
          ...fragment.origin,
          start: advance(fragment.origin.start, blank[0]),
        },
      }
}

const lineStartsOf = (lines: ReadonlyArray<string>): ReadonlyArray<number> =>
  Array.mapAccum(lines, 0, (start, line) => [start + line.length + 1, start])[1]

const newStartsOf = (
  lines: ReadonlyArray<string>,
  oldStarts: ReadonlyArray<number>,
  width: number,
): ReadonlyArray<number> =>
  Array.mapAccum(Array.zip(lines, oldStarts), 0, (indented, [line, start]) => {
    const next = start !== 0 && line !== '' ? indented + 1 : indented
    return [next, start + width * next]
  })[1]

const lineIndexOf = (starts: ReadonlyArray<number>, offset: number): number =>
  pipe(
    Array.findLastIndex(starts, (s) => s <= offset),
    Option.getOrElse(() => 0),
  )

const reindentMapping = (
  m: Mapping,
  block: string,
  oldStarts: ReadonlyArray<number>,
  newStarts: ReadonlyArray<number>,
): ReadonlyArray<Mapping> => {
  const end = m.genStart + m.genLength
  const cuts = [m.genStart, ...Array.filter(oldStarts, (s) => s > m.genStart && s < end), end]
  return Array.map(Array.zip(cuts, Array.drop(cuts, 1)), ([a, b]) => {
    const line = lineIndexOf(oldStarts, a)
    return {
      genStart: newStarts[line] + (a - oldStarts[line]),
      genLength: b - a,
      source: {
        start: advance(m.source.start, block.slice(m.genStart, a)),
        end: advance(m.source.start, block.slice(m.genStart, b)),
      },
      kind: m.kind,
    }
  })
}

const indentBlock = (vc: LoomVirtualCode, indent: string): LoomVirtualCode => {
  if (indent.length === 0) return vc
  const lines = vc.code.split('\n')
  if (lines.length <= 1) return vc
  const oldStarts = lineStartsOf(lines)
  const newStarts = newStartsOf(lines, oldStarts, indent.length)
  return {
    ...vc,
    code: pipe(
      lines,
      Array.map((line, i) => (i === 0 || line === '' ? line : indent + line)),
      Array.join('\n'),
    ),
    mappings: Array.flatMap(vc.mappings, (m) => reindentMapping(m, vc.code, oldStarts, newStarts)),
  }
}

Mapping the symbols

Loom's navigation and colour serve the root mirror alone — the document whose map to the source is the identity. The root carries one whole-file span of the source kind, which Volar forwards for verification only, so a cursor on a token reaches no other Loom feature through it. symbolMappings adds the spans that route the rest: one span per symbol the document declares, each carrying its kind. LoomCompiler reads that kind's profile from LoomSymbol and forwards exactly the features it allows, so go-to on a heading, rename of a warp, and colour on an arrow each reach the right token through the root.

symbolsOf classifies every token in one pass — the heading titles and anchors, the warps, the specifiers and sinks, the arrows and tildes. spanOf makes one identity mapping over a token's span, since the root's code is the source verbatim.

TypeScript
const spanOf = (position: Position, kind: MappingKind): Mapping => ({
  genStart: position.start.offset,
  genLength: position.end.offset - position.start.offset,
  source: position,
  kind,
})

export const symbolMappings = (doc: LoomDocument): ReadonlyArray<Mapping> =>
  Array.map(symbolsOf(doc), (symbol) => spanOf(symbol.position, symbol.kind))

The passes and the tree

fromProduct is the de re pass — a section's product in its own language. Its virtual-code id is the section name lowercased, since Volar requires every embedded-code id to be lowercase. A section absent from the corpus yields an empty document. fromProse is the prose pass — the file as a Markdown document, its languageId prose, every code chunk blanked to spaces so only the prose shows while its offsets stay put. proseMappings maps each prose token to its own source span — the document and section preambles, every heading, and the prose between code blocks — so a mapping is precise per token, never a whole line or the whole file. rootVirtualCode assembles a file's tree: the source itself as the loom root, the prose and product documents its children. The root carries a whole-file identity span of the source kind, so Loom's own diagnostics reach the editor on the file the author opened, and the section-symbol spans beside it, so navigation and rename reach each heading and anchor there.

rootNamesAt names a file's composition roots — the sections no reference in the file folds in. It reads the file's product.code from the corpus, the sections the run produced. A reference to a section in the same file demotes its target: the target becomes a fragment of the referring section's root, sharing that scope, not a unit of its own. A reference into another file demotes nothing here, because its target belongs to that other file, which names its own roots. The sections left over, named by no same-file reference, are the roots. The editor type-checks them and lets each fold in the fragments it names. The names come back lowercased, to match the de re ids fromProduct assigns.

TypeScript
export const fromProduct = (
  modules: ReadonlyMap<Path, LoomModule>,
  root: SectionId,
): LoomVirtualCode =>
  pipe(
    lookup(modules, root),
    Option.match({
      onNone: () => leaf(root.name.toLowerCase(), '', '', []),
      onSome: (node) => ({
        ...inlineComposed(modules, new Set([keyOf(root)]), root.path)(node),
        id: root.name.toLowerCase(),
        languageId: node.languageId,
      }),
    }),
  )

const isCodeWeft = (weft: { readonly type: string }): boolean =>
  weft.type === 'ArrowWeft' || weft.type === 'CodeWeft'

const proseMappings = (doc: LoomDocument): ReadonlyArray<Mapping> =>
  pipe(
    [
      ...doc.preamble,
      ...Array.flatMap(doc.sections, (section) => [
        section.heading,
        ...section.preamble,
        ...Array.filter(section.code, (weft) => !isCodeWeft(weft)),
      ]),
    ],
    Array.map((token) => spanOf(token.position, 'prose')),
  )

export const fromProse = (modules: ReadonlyMap<Path, LoomModule>, path: Path): LoomVirtualCode =>
  Option.match(Option.fromNullishOr(modules.get(path)), {
    onNone: () => leaf('prose', 'prose', '', []),
    onSome: (module) => {
      const code = pipe(
        module.doc.sections,
        Array.flatMap((section) => section.code),
        Array.filter(isCodeWeft),
        Array.reduce(module.text, (acc, weft) => {
          const start = weft.position.start.offset
          const end = weft.position.end.offset
          return acc.slice(0, start) + acc.slice(start, end).replace(/[^\n]/g, ' ') + acc.slice(end)
        }),
      )
      return {
        id: 'prose',
        languageId: 'prose',
        code,
        mappings: proseMappings(module.doc),
        embeddedCodes: [],
      }
    },
  })

export const rootVirtualCode = (
  text: string,
  embeddedCodes: ReadonlyArray<LoomVirtualCode>,
  symbols: ReadonlyArray<Mapping> = [],
): LoomVirtualCode => ({
  id: 'root',
  languageId: 'loom',
  code: text,
  mappings: [
    {
      genStart: 0,
      genLength: text.length,
      source: {
        start: { line: 1, offset: 0 },
        end: { line: 1, offset: text.length },
      },
      kind: 'source',
    },
    ...symbols,
  ],
  embeddedCodes,
})

export const rootNamesAt = (
  modules: ReadonlyMap<Path, LoomModule>,
  path: Path,
): ReadonlySet<string> => {
  const here = modules.get(path)?.product?.code ?? []
  const named = new Set(
    pipe(
      here,
      Array.flatMap((code) => code.fragments),
      Array.map((part) =>
        part.type === 'Fragment'
          ? Option.none()
          : part.target.pipe(
              Option.filter((t) => t.path === path),
              Option.map((t) => t.name.toLowerCase()),
            ),
      ),
      Array.getSomes,
    ),
  )
  return new Set(
    pipe(
      here,
      Array.map((code) => code.origin.name.toLowerCase()),
      Array.filter((id) => !named.has(id)),
    ),
  )
}

The service

LoomVirtualCodeBuilder wraps fromProduct as an Effect service for callers already inside Effect — the corpus compiler; the single-file editor path uses the functions directly.

TypeScript
export class LoomVirtualCodeBuilder extends Context.Service<LoomVirtualCodeBuilder>()(
  'LoomVirtualCodeBuilder',
  {
    make: Effect.succeed({
      fromProduct: (
        modules: ReadonlyMap<Path, LoomModule>,
        root: SectionId,
      ): Effect.Effect<LoomVirtualCode> => Effect.sync(() => fromProduct(modules, root)),
    }),
  },
) {
  static readonly layer = Layer.effect(this, this.make)
}

What this module draws on

Effect supplies Array, Option, and pipe for the folds and the resolution, and Effect itself for the service. The de re node types and keyOf come from ProductAst, Position from the foundation, and the LoomModule and Path the resolution walks from the corpus model. The parse LoomDocument comes from the AST, and symbolsOf from LoomSymbol classifies its tokens for the symbol mappings. The virtual-code types come from LoomVirtualCode.

TypeScript
import { Array, Context, Effect, Layer, Option, pipe } from 'effect'
import {
  keyOf,
  type Code,
  type Fragment,
  type Ref,
  type SectionId,
} from '@athrio/loom-ast/ProductAst'
import { type Position } from '@athrio/loom-ast/LoomNode'
import { type LoomModule, type Path } from '@athrio/loom-ast/LoomCorpusAst'
import { type LoomDocument } from '@athrio/loom-ast/LoomAst'
import { symbolsOf } from '@athrio/loom-ast/LoomSymbol'
import {
  type LoomVirtualCode,
  type Mapping,
  type MappingKind,
} from '@athrio/loom-ast/LoomVirtualCode'
Loom