SourceTanglingA project's configuration

Part V, Chapter 3

Part V, Chapter 3: A project's configuration

Package: packages/loom-config/src/LoomConfig.ts

Language: TypeScript

Raw source

A project's configuration

@athrio/loom-config reads a Loom project's configuration. A project keeps it in one file, .loom/config.yaml, under the workspace's generated store, and the activation commands write it. This package reads that file and answers, for any .loom file, the configuration that applies to it.

The package reads a plain YAML file, never a .loom source. Reading a .loom file would mean parsing it, and parsing pulls in the language compiler, which already depends on this package. Reading the YAML keeps the dependency pointing one way: the command-line tool writes .loom/config.yaml, and this package only reads it.

The read is synchronous. The editor resolves a file's configuration inside Volar's synchronous projection hook, which cannot wait for a promise, so the resolver reads through Bun's synchronous filesystem. A missing or malformed file resolves to the empty configuration rather than failing, so a project still opens when its configuration is absent or broken.

The pieces a configuration is built from

Four small shapes recur across every configuration. The anchor pair holds the delimiters a section's anchors open and close with, each side optional so a configuration overrides only the one it cares about. A language entry carries the one thing activating a language can override — the service package that gives it editor support — and is otherwise empty, since a language's presence is what activates it. The settings map holds, for each language id, a free-form bag the host passes to that language's service untouched. The variables map holds the named strings a warp interpolates, one value — a version, say — the whole workspace shares.

TypeScript
const AnchorSchema = Schema.Struct({
  open: Schema.optional(Schema.String),
  close: Schema.optional(Schema.String),
})

const LanguageSchema = Schema.Struct({
  service: Schema.optional(Schema.String),
})

const SettingsSchema = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown))

const VariablesSchema = Schema.Record(
  Schema.String,
  Schema.Union([Schema.String, Schema.Number, Schema.Boolean]),
)

What a configuration declares

A project has one configuration, kept in .loom/config.yaml. It names the languages the project activates — one set for the whole project, because the editor runs a single language server over it — and sets the defaults every section inherits: the primary language a specifier-less section falls back to, the anchor delimiters, the per-language settings, the variables a warp interpolates, the width a loom's lines keep to, and the name of the corpus directory looms sit in. A package declares no configuration of its own; a book composes the package layout literately, in its higher-order sinks, rather than in the configuration.

The configuration also lists, under ignore, the looms the project leaves out. Each entry is a pattern written like a line of a .gitignore. A loom that matches one is not part of the project, and no walk over the workspace gathers it. The patterns add to what Loom leaves out already: whatever Git ignores, and the fixtures named *.fixture.loom. The chapter on the looms in a project gives the whole rule.

The width is the column a loom's lines keep to, a hundred when the configuration names none. The formatter breaks a code section's lines to fit it, and an editor soft-wraps the prose at it, so the prose and the code of a loom share one right edge. It is a whole number of columns, at least one.

WorkspaceManifestSchema validates that file. Every field is optional, because a project that has activated no language yet still has a configuration, an empty one.

TypeScript
export const WorkspaceManifestSchema = Schema.Struct({
  languages: Schema.optional(Schema.Record(Schema.String, LanguageSchema)),
  corpus: Schema.optional(Schema.String),
  primary: Schema.optional(Schema.String),
  anchor: Schema.optional(AnchorSchema),
  settings: Schema.optional(SettingsSchema),
  variables: Schema.optional(VariablesSchema),
  ignore: Schema.optional(Schema.Array(Schema.String)),
  width: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
})

export type WorkspaceManifest = typeof WorkspaceManifestSchema.Type

What a reader receives

ResolvedConfig is the resolved shape every reader works with: the workspace defaults that apply to one file. Its anchor, primary, languages, settings, variables, and ignore come straight from the manifest, and so does its width, which falls back to defaultWidth, a hundred columns, when the manifest names none. Three fields complete it. The services map names, for each activated language, the package that serves it — the one the language declared, or the conventional @athrio/loom-service-<id> when it declared none. The packageRoot is the absolute path the resolved file's package tangles under — the directory above the corpus folder its loom sits in — or undefined when the file sits in no corpus folder. The workspaceRoot is the absolute path of the workspace itself, the directory a book's higher-order sink resolves its directories against; it is undefined only when the file belongs to no workspace.

A file that resolves to nothing — no workspace, no configuration — receives the empty configuration: no anchor, no primary, no languages, no services, no root, and the default width.

TypeScript
export interface ResolvedConfig {
  readonly anchor: { readonly open?: string; readonly close?: string } | undefined
  readonly primary: string | undefined
  readonly languages: ReadonlyArray<string>
  readonly settings: Record<string, Record<string, unknown>>
  readonly variables: Record<string, string>
  readonly ignore?: ReadonlyArray<string>
  readonly width: number
  readonly services: Record<string, string>
  readonly packageRoot: string | undefined
  readonly workspaceRoot: string | undefined
  readonly corpusDir: string | undefined
}

export const defaultWidth = 100

const empty: ResolvedConfig = {
  anchor: undefined,
  primary: undefined,
  languages: [],
  settings: {},
  variables: {},
  width: defaultWidth,
  services: {},
  packageRoot: undefined,
  workspaceRoot: undefined,
  corpusDir: undefined,
}

Finding the workspace

A workspace is the directory that holds a configuration, the .loom/config.yaml file. findWorkspace walks up from a starting directory to the first ancestor that holds one, the same ancestor search a package manager runs for a project's lockfile. It stops at the filesystem root and returns nothing when no ancestor holds a configuration, so a path outside any Loom project resolves to the empty configuration.

A .loom folder without a configuration makes no workspace. The install script puts the standalone executable in ~/.loom/bin, and that folder must not turn the home folder into a project that every loose loom below it joins. The language store finds a workspace by the same rule, so the configuration and the store always agree on which workspace a path belongs to.

TypeScript
const storeDirName = '.loom'
const manifestName = 'config.yaml'
const defaultCorpus = 'corpus'

const findWorkspace = (dir: string): string | undefined => {
  if (existsSync(resolvePath(dir, storeDirName, manifestName))) return dir
  const parent = dirname(dir)
  return parent === dir ? undefined : findWorkspace(parent)
}

Reading the manifest

readManifest reads the compiled .loom/config.yaml and decodes it. It parses the file as YAML and validates the result against WorkspaceManifestSchema, yielding the manifest on success. A read failure, a parse failure, or YAML the schema rejects all yield nothing — the resolver then falls back, and the editor never crashes on a broken manifest.

TypeScript
const decodeManifest = Schema.decodeUnknownOption(WorkspaceManifestSchema)

const readManifest = (file: string): WorkspaceManifest | undefined => {
  try {
    return Option.getOrUndefined(decodeManifest(parseYaml(readFileSync(file, 'utf8'))))
  } catch {
    return undefined
  }
}

Resolving the services

The services map is the one resolved field the manifest does not carry directly. Every activated language resolves to the package that serves it: servicesOf maps each language id to its service, taking the language's own service when it set one and the conventional @athrio/loom-service-<id> when it did not.

TypeScript
const serviceFor = (id: string, language: { readonly service?: string }): string =>
  language.service ?? `@athrio/loom-service-${id}`

const servicesOf = (
  languages: Record<string, { readonly service?: string }> | undefined,
): Record<string, string> =>
  Object.fromEntries(
    Object.entries(languages ?? {}).map(([id, language]) => [id, serviceFor(id, language)]),
  )

Resolving the variables

variablesOf shapes the manifest's variables into the strings a warp interpolates. It coerces each declared value to a string, so a number or a boolean a project wrote reaches a warp as its text.

TypeScript
const variablesOf = (
  variables: Record<string, string | number | boolean> | undefined,
): Record<string, string> =>
  Object.fromEntries(Object.entries(variables ?? {}).map(([name, value]) => [name, String(value)]))

Resolving a file's configuration

Resolving a file gives it the workspace defaults and the root its sinks tangle under. The anchor, primary, settings, width, and ignore patterns are the workspace's; the languages are its activated set; the services resolve from those languages.

containerRoot derives the package root by convention. It walks up from the file to the nearest directory named like the corpus — corpus by default — and takes the directory above it, so a loom in pkg/corpus/ tangles into pkg/. A file inside no corpus directory carries no root and tangles beside itself.

corpusDir is the other half of that walk: the corpus directory the file lives in, the one Loom enumerates to discover its sibling looms. It is the same nearest ancestor containerRoot finds, returned whole rather than stepped above. A file in no corpus directory — a workspace whose corpus is ., or a loose file — discovers from its own directory, never climbing out to scan a whole workspace.

TypeScript
const containerRoot = (dir: string, container: string, workspace: string): string | undefined => {
  if (basename(dir) === container) return dirname(dir)
  const parent = dirname(dir)
  return dir === workspace || parent === dir
    ? undefined
    : containerRoot(parent, container, workspace)
}

const corpusDirOf = (start: string, container: string, workspace: string): string => {
  const climb = (dir: string): string => {
    if (basename(dir) === container) return dir
    const parent = dirname(dir)
    return dir === workspace || parent === dir ? start : climb(parent)
  }
  return climb(start)
}

const resolveFromManifest = (
  manifest: WorkspaceManifest,
  workspace: string,
  from: string,
): ResolvedConfig => {
  const container = manifest.corpus ?? defaultCorpus
  return {
    anchor: manifest.anchor,
    primary: manifest.primary,
    languages: Object.keys(manifest.languages ?? {}),
    settings: manifest.settings ?? {},
    variables: variablesOf(manifest.variables),
    ignore: manifest.ignore,
    width: manifest.width ?? defaultWidth,
    services: servicesOf(manifest.languages),
    packageRoot: containerRoot(from, container, workspace),
    workspaceRoot: workspace,
    corpusDir: corpusDirOf(from, container, workspace),
  }
}

The service

LoomConfig is the service the rest of Loom calls. Its resolve takes any path inside a project and returns the configuration that applies to it. The path is usually a .loom file, and then resolve starts from the folder the file sits in. The path can also be a folder, as when the tangler walks one, and then resolve starts from that folder itself. So a workspace's own root resolves to that workspace rather than to whatever lies above it. isFolder tells the two apart, and a path that names nothing on disk, such as an in-memory document's, counts as a file. From its starting folder resolve finds the workspace, reads the compiled manifest, and resolves against it. A path with no workspace, no manifest, or no name at all resolves to the empty configuration.

Its manifest method reads a workspace's whole manifest, found by walking up from a directory. The activation commands and the editor's service loader share that read. Its materialize method writes a whole manifest to the workspace's .loom/config.yaml, creating the store directory if it is absent. loom init uses it, since it writes the file from nothing.

Its edit method changes one value in the file and leaves the rest of the file as it was written. It takes the path to the value, such as variables and then version, and the value to put there. It edits the parsed YAML document, not the decoded manifest, because decoding keeps only the keys this Loom knows. So a key that a newer Loom added survives the edit, and so do the order of the keys and the comments between them. This matters at every release. The published Loom that writes the next version into the file is always a release older than the file it edits.

TypeScript
const isFolder = (path: string): boolean =>
  statSync(path, { throwIfNoEntry: false })?.isDirectory() ?? false

export class LoomConfig extends Context.Service<LoomConfig>()('LoomConfig', {
  make: Effect.succeed({
    resolve: (fromPath: string): Effect.Effect<ResolvedConfig> =>
      Effect.sync(() => {
        if (fromPath === '') return empty
        const from = isFolder(fromPath) ? fromPath : dirname(fromPath)
        const workspace = findWorkspace(from)
        if (workspace === undefined) return empty
        const manifest = readManifest(resolvePath(workspace, storeDirName, manifestName))
        return manifest === undefined ? empty : resolveFromManifest(manifest, workspace, from)
      }),

    manifest: (fromDir: string): Effect.Effect<WorkspaceManifest | undefined> =>
      Effect.sync(() => {
        const workspace = findWorkspace(fromDir)
        return workspace === undefined
          ? undefined
          : readManifest(resolvePath(workspace, storeDirName, manifestName))
      }),

    materialize: (workspace: string, manifest: WorkspaceManifest): Effect.Effect<void> =>
      Effect.sync(() => {
        const store = resolvePath(workspace, storeDirName)
        mkdirSync(store, { recursive: true })
        writeFileSync(resolvePath(store, manifestName), stringifyYaml(manifest))
      }),

    edit: (workspace: string, path: ReadonlyArray<string>, value: unknown): Effect.Effect<void> =>
      Effect.sync(() => {
        const store = resolvePath(workspace, storeDirName)
        const file = resolvePath(store, manifestName)
        mkdirSync(store, { recursive: true })
        const document = parseDocument(existsSync(file) ? readFileSync(file, 'utf8') : '')
        document.setIn(path, value)
        writeFileSync(file, document.toString())
      }),
  }),
}) {
  static readonly layer = Layer.effect(this, this.make)
}

What the module draws on

Effect supplies Schema for every shape, Option for a decode that may yield nothing, the Effect type its methods return, and Context and Layer for the service and its layer. The yaml package parses and serialises the manifest, and parses the file as a document that edit can change in place. Bun's synchronous filesystem reads, writes, and creates directories, and tells a folder from a file; its path module resolves paths, walks to parent directories, and names the platform separator.

TypeScript
import { Context, Effect, Layer, Option, Schema } from 'effect'
import { parse as parseYaml, parseDocument, stringify as stringifyYaml } from 'yaml'
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { basename, dirname, resolve as resolvePath } from 'node:path'
Loom