SourceReading the textBuilding one module

Part III, Chapter 5

Part III, Chapter 5: Building one module

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

Language: TypeScript

Raw source

Building one module

LoomCorpusAstBuilder compiles one .loom file. It runs the file through every pass in turn — read its bytes, parse, build the product — and returns a LoomModule: the parsed document beside its product, the author's code with each anchor resolved to an edge. The product is built here, straight from the parse; nothing stands between the document and its product.

It builds that one module and no more. It discovers no corpus and keeps no cache — LoomCompiler does both, listing the file's sibling looms and memoising each result in LoomMemo. A cross-file ::[…] reference stays unresolved here as well. It is inlined later, at projection, by fromProduct.

The builder owns no files. It reads through a Source the caller hands it, so the same build runs over a real filesystem for the tangler, a Volar document host for the editor, or an in-memory fake for a test.

The source it reads through

Source is what the builder reads through — a small interface the caller provides, never the filesystem directly. Its read method returns a file's text by its path, and fails with a ReadError when the file cannot be read. LoomCompiler supplies the real implementation, DocumentSource, so the builder reads through the caller rather than the filesystem.

list is the other half: it returns the .loom paths under a directory, the looms LoomCompiler discovers as a file's corpus. The compiler hands it the directory and that directory's project, or Option.none for a folder no project holds. The listing keeps only the looms the project holds. list is itself an Option: a caller that hands in a single document in memory carries Option.none, and the compiler then sees a corpus of one. The disk source the CLI and the editor use carries Option.some of the real listing.

ReadError names a file the source cannot read — one that is missing, or one the operating system refuses to open — and carries the path and the underlying cause. Its message reads Cannot read <path>: <cause>. The build catches it and recovers, so a file that cannot be read becomes a diagnostic on that file rather than a crash.

TypeScript
export interface Source {
  readonly read: (path: Path) => Effect.Effect<string, ReadError>
  readonly list: Option.Option<
    (dir: Path, project: Option.Option<LoomProject>) => Effect.Effect<ReadonlyArray<Path>>
  >
}

export class ReadError extends Data.TaggedError('ReadError')<{
  readonly path: Path
  readonly cause: unknown
}> {
  get message(): string {
    return `Cannot read ${this.path}: ${
      this.cause instanceof Error ? this.cause.message : String(this.cause)
    }`
  }
}

The chain of passes

build runs one file through the whole chain, each pass consuming the one before it. parsed reads the file and parses it: LoomSourceRanges cuts the text into line ranges, WeftClassifier then WeftTokeniser turn those into tokenised wefts, and LoomAstBuilder folds the wefts into a LoomDocument. build then runs ProductBuilder over the document to compose its product, and stops there. The passes are peers here — no pass hides another behind a single call. Three settings resolved for the file ride in from the caller and thread through the chain: the anchor delimiters WeftTokeniser cuts with, the primary language ProductBuilder falls back to, and the workspace variables it interpolates into a warp's value.

The chain is total: it never throws, and it always yields a module. Three failures can stop it before a real document exists. A file the source cannot read raises ReadError; a mixed line terminator the parse chain cannot pin to a node raises MixedEOL; and a delimiter pair the package configured badly raises one of the self-describing InvalidAnchorDelims errors. parsed catches all three and recovers to an empty document carrying the error — emptyDocument with the read failure's message, emptyDocumentFor for a mixed terminator, and emptyDocument with the delimiter error's own message. So build runs the product pass over that document like any other. A file that cannot be read still yields a module, one whose health carries the read error. Every other malformation rides in the document's own health.

TypeScript
export class LoomCorpusAstBuilder extends Context.Service<LoomCorpusAstBuilder>()(
  'LoomCorpusAstBuilder',
  {
    make: Effect.gen(function* () {
      const sourceRanges = yield* LoomSourceRanges
      const classify = yield* WeftClassifier
      const tokenise = yield* WeftTokeniser
      const astBuilder = yield* LoomAstBuilder
      const products = yield* ProductBuilder

      const parsed = (
        source: Source,
        path: Path,
        delims: AnchorDelims,
      ): Effect.Effect<{ readonly text: string; readonly doc: LoomDocument }> =>
        source.read(path).pipe(
          Effect.flatMap((text) => {
            const onBadDelims = (err: InvalidAnchorDelims) =>
              Effect.succeed(emptyDocument(text, err.message))
            return checkAnchorDelims(delims).pipe(
              Effect.andThen(sourceRanges.stream(text)),
              Effect.flatMap((ranges) =>
                pipe(
                  ranges,
                  classify.classifyWefts(text),
                  tokenise.tokeniseWefts(text, delims),
                  astBuilder.build,
                ),
              ),
              Effect.catchTag('MixedEOL', (err) => Effect.succeed(emptyDocumentFor(text, err))),
              Effect.catchTags({
                EmptyAnchorDelims: onBadDelims,
                IdenticalAnchorDelims: onBadDelims,
                WhitespaceAnchorDelims: onBadDelims,
                ReservedAnchorDelims: onBadDelims,
              }),
              Effect.map((doc) => ({ text, doc })),
            )
          }),
          Effect.catchTag('ReadError', (err) =>
            Effect.succeed({ text: '', doc: emptyDocument('', err.message) }),
          ),
        )

      const build = (
        source: Source,
        path: Path,
        delims: AnchorDelims = defaultAnchorDelims,
        primaryLanguage?: string,
        variables: Record<string, string> = {},
      ): Effect.Effect<LoomModule> =>
        Effect.gen(function* () {
          const { text, doc } = yield* parsed(source, path, delims)
          const product = yield* products.build(doc, path, primaryLanguage, variables)
          return { path, text, doc, product }
        })

      return { build }
    }),
  },
) {
  static readonly layer = Layer.effect(this, this.make).pipe(
    Layer.provide(
      Layer.mergeAll(
        LoomSourceRanges.layer,
        WeftClassifier.layer,
        WeftTokeniser.layer,
        LoomAstBuilder.layer,
        ProductBuilder.layer,
      ),
    ),
  )
}

What the builder draws on

The builder draws the four parse passes — LoomSourceRanges, WeftClassifier, WeftTokeniser, and LoomAstBuilder, with emptyDocument and emptyDocumentFor for its recoveries — then ProductBuilder for the product pass beyond parsing. From the token layer it takes checkAnchorDelims and the InvalidAnchorDelims errors it raises, which guard the configured delimiters before the parse, alongside AnchorDelims and defaultAnchorDelims. The LoomDocument, LoomModule, and Path types name what parsed returns, what a build returns, and how a file is addressed, and LoomProject names the project a source lists a folder for. Effect supplies the composition primitives, Data for the tagged ReadError among them.

TypeScript
import { Context, Data, Effect, Layer, Option, pipe } from 'effect'
import {
  checkAnchorDelims,
  defaultAnchorDelims,
  type AnchorDelims,
  type InvalidAnchorDelims,
} from '@athrio/loom-ast/LoomTokens'
import { LoomSourceRanges } from '#ast/LineRanges'
import { WeftClassifier } from '#ast/WeftClassifier'
import { WeftTokeniser } from '#ast/WeftTokeniser'
import { LoomAstBuilder, emptyDocument, emptyDocumentFor } from '#ast/LoomAstBuilder'
import type { LoomDocument } from '@athrio/loom-ast/LoomAst'
import { ProductBuilder } from '#ast/ProductBuilder'
import type { LoomModule, Path } from '@athrio/loom-ast/LoomCorpusAst'
import type { LoomProject } from '../LoomProject'
Loom