SourceIn the editorFormatting a loom

Part VI, Chapter 8

Part VI, Chapter 8: Formatting a loom

Package: packages/loom-lang/

Language: TypeScript

Raw source

Formatting a loom

A loom holds two kinds of text, and they want different layouts. Prose is written one paragraph to a line, and the editor soft-wraps it to the window, so a paragraph reads the same at any width and changes as one line in a diff. Code is the other way round: its line breaks are the author's, and a formatter breaks a long line to fit the project's width. LoomFormatter gives each its layout in one pass. It joins the lines of every prose paragraph into one, and it hands each section's code to that language's own formatter.

A section's code is formatted as code on its own, at its own first column, for its own readability. An anchor in it is one more line of that code, and the depth the formatter gives that line is the depth the code it names will land at, since an anchor alone on its line places every line it brings in at its own depth. So the anchor carries the indentation, and the section it names keeps its own, wherever and however often it is used.

The pass works from the text it is given, never from the copy on disk or the corpus the editor last built. An editor formats the buffer it holds, which may be ahead of both, so the pass parses that text itself and returns the same text formatted. The editor's Loom language and the loom format command call the same pass.

An edit

Both halves of the pass describe their work as edits to the loom. An Edit replaces the text between two offsets in the loom with new text: a join replaces a line break with a space, and a formatter's change replaces a few characters of a section's code.

applyEdits applies a set of edits to a text. It works from the last edit to the first, so an edit never moves the offsets of the ones still to apply.

TypeScript
export interface Edit {
  readonly start: number
  readonly end: number
  readonly text: string
}

const byStart: Order.Order<Edit> = Order.combine(
  Order.mapInput(Order.Number, (edit: Edit) => edit.start),
  Order.mapInput(Order.Number, (edit: Edit) => edit.end),
)

export const applyEdits = (text: string, edits: ReadonlyArray<Edit>): string =>
  pipe(
    edits,
    Array.sort(Order.flip(byStart)),
    Array.reduce(
      text,
      (done, edit) => done.slice(0, edit.start) + edit.text + done.slice(edit.end),
    ),
  )

The prose joins and the code changes touch different lines, but a set of edits is only safe to apply when no two of them claim the same text. settle orders the edits and keeps the first of any two that overlap or start at the same place, so the set it returns always applies cleanly.

TypeScript
const clashes = (kept: Edit, next: Edit): boolean =>
  next.start < kept.end || next.start === kept.start

const settle = (edits: ReadonlyArray<Edit>): ReadonlyArray<Edit> =>
  pipe(
    edits,
    Array.sort(byStart),
    Array.reduce([] as ReadonlyArray<Edit>, (kept, next) =>
      Option.match(Array.last(kept), {
        onNone: () => [next],
        onSome: (last) => (clashes(last, next) ? kept : [...kept, next]),
      }),
    ),
  )

One paragraph to a line

A loom's prose is every line the parser reads as prose: the lines before a section's first code block and the lines after each ~. proseLinesOf collects them in order, each with its line number, its offset in the loom, and its text.

TypeScript
interface ProseLine {
  readonly line: number
  readonly start: number
  readonly text: string
}

const isProseWeft = (weft: SectionBodyWeft): boolean => weft.type === 'ProseWeft'

const proseLinesOf = (doc: LoomDocument, text: string): ReadonlyArray<ProseLine> =>
  pipe(
    [
      ...doc.preamble,
      ...Array.flatMap(doc.sections, (section) => [
        ...section.preamble,
        ...Array.filter(section.code, isProseWeft),
      ]),
    ],
    Array.map((weft) => ({
      line: weft.position.start.line,
      start: weft.position.start.offset,
      text: text.slice(weft.position.start.offset, weft.position.end.offset).replace(/\r?\n$/, ''),
    })),
    Array.sort(Order.mapInput(Order.Number, (line: ProseLine) => line.start)),
  )

Not every line of prose belongs to a paragraph. A Markdown line sorts into one of four kinds, and its kind decides whether it joins the line above it.

  • A blank line ends a paragraph.
  • A fixed line keeps its line break on both sides. That is any line of a fenced block, the fence lines included, a note's opening and closing lines, a heading, a block quote, a table row, a line of HTML, a link definition, a thematic break, a line that only declares a warp, and a line that holds nothing but an anchor, which composes a whole block of prose in its place.
  • An item line opens a list item. It never joins the line above, since each item starts on a line of its own, but the lines that continue it join it.
  • Any other line is text, and it joins the text or item line above it.

A fence turns every line inside it fixed, so kindsOf folds over the lines and carries whether the fold is inside one. A fence opens and closes on the same kind of line, three backticks, so each fence line flips the state. The anchor's delimiters are the project's own, so the line that holds only an anchor is recognised by them.

TypeScript
type LineKind = 'blank' | 'fixed' | 'item' | 'text'

interface KindedLine extends ProseLine {
  readonly kind: LineKind
}

const fenceLine = /^\s*```/
const noteLine = /^\s*:::(?:\[Note\].*|\s*)$/
const blockLine = /^ {0,3}(?:#{1,6}(?:\s|$)|>|\||<|\{\{|\[[^\]]+\]:\s)/
const thematicBreak = /^ {0,3}([-*_])(?:\s*\1){2,}\s*$/
const itemLine = /^\s*(?:[-*+]|\d{1,9}[.)])\s/

const onlyAnchor = (line: string, delims: AnchorDelims): boolean => {
  const trimmed = line.trim()
  return (
    trimmed.startsWith(delims.open) &&
    trimmed.endsWith(delims.close) &&
    trimmed.indexOf(delims.open, delims.open.length) === -1
  )
}

const kindOf = (line: string, delims: AnchorDelims): LineKind =>
  Match.value(line).pipe(
    Match.when(
      (text) => text.trim() === '',
      () => 'blank' as const,
    ),
    Match.when(
      (text) =>
        noteLine.test(text) ||
        blockLine.test(text) ||
        thematicBreak.test(text) ||
        onlyAnchor(text, delims),
      () => 'fixed' as const,
    ),
    Match.when(
      (text) => itemLine.test(text),
      () => 'item' as const,
    ),
    Match.orElse(() => 'text' as const),
  )

const kindsOf = (
  lines: ReadonlyArray<ProseLine>,
  delims: AnchorDelims,
): ReadonlyArray<KindedLine> =>
  Array.mapAccum(lines, false, (inFence, line) => {
    const fence = fenceLine.test(line.text)
    const kind = inFence || fence ? 'fixed' : kindOf(line.text, delims)
    return [inFence !== fence, { ...line, kind }]
  })[1]

A line that ends in two spaces or a backslash asks Markdown for a line break, so the line after it never joins it. Every other pair of neighbouring lines joins when the lower one is text and the upper one is text or an item. proseEdits replaces the break between them with one space: the upper line's trailing spaces, the line break, and the lower line's indentation all go. The two lines must be neighbours in the loom too, since a code block or a heading between two prose lines keeps them apart.

TypeScript
const hardBreak = /(?: {2,}|\\)$/

const joins = (upper: KindedLine, lower: KindedLine): boolean =>
  lower.line === upper.line + 1 &&
  lower.kind === 'text' &&
  (upper.kind === 'text' || upper.kind === 'item') &&
  !hardBreak.test(upper.text)

const proseEdits = (doc: LoomDocument, text: string, delims: AnchorDelims): ReadonlyArray<Edit> => {
  const lines = kindsOf(proseLinesOf(doc, text), delims)
  return pipe(
    Array.zip(lines, Array.drop(lines, 1)),
    Array.filter(([upper, lower]) => joins(upper, lower)),
    Array.map(([upper, lower]) => ({
      start: upper.start + upper.text.trimEnd().length,
      end: lower.start + lower.text.length - lower.text.trimStart().length,
      text: ' ',
    })),
  )
}

The difference between two texts

A language's formatter returns its code whole, laid out afresh. To carry that layout back into the loom, the pass needs the changes themselves, as small as they can be, so each one falls inside a single section's code. diffOf finds them with Eugene Myers's algorithm for the shortest edit script, the one diff itself uses.

hunksOf is that algorithm over two sequences. It searches outward one edit at a time. After d edits, each diagonal k of the edit graph holds the furthest point the search has reached on it, and a point slides along matching items for free, the snake Myers names. The search ends when a diagonal reaches the end of both sequences. Walking the recorded frontiers back from there recovers each edit, and neighbouring edits gather into hunks. A hunk replaces the items from from up to to in the first sequence with items from the second.

The search costs time in proportion to the number of edits, not to the square of the length, which suits a formatter's output: most of a formatted file is the file as it was.

TypeScript
interface Hunk<A> {
  readonly from: number
  readonly to: number
  readonly items: ReadonlyArray<A>
}

type Frontier = ReadonlyMap<number, number>

const slide = <A>(a: ReadonlyArray<A>, b: ReadonlyArray<A>, x: number, y: number): number => {
  const room = Math.min(a.length - x, b.length - y)
  return room <= 0
    ? x
    : x +
        pipe(
          Array.findFirstIndex(Array.range(0, room - 1), (i) => a[x + i] !== b[y + i]),
          Option.getOrElse(() => room),
        )
}

const diagonals = (d: number): ReadonlyArray<number> =>
  Array.map(Array.range(0, d), (i) => 2 * i - d)

const movesDown = (k: number, d: number, previous: Frontier): boolean =>
  k === -d || (k !== d && (previous.get(k - 1) ?? -1) < (previous.get(k + 1) ?? -1))

const frontiersOf = <A>(a: ReadonlyArray<A>, b: ReadonlyArray<A>): ReadonlyArray<Frontier> => {
  const search = (d: number, trace: ReadonlyArray<Frontier>): ReadonlyArray<Frontier> => {
    const previous = trace[trace.length - 1] ?? new Map([[1, 0]])
    const frontier: Frontier = new Map(
      Array.map(diagonals(d), (k) => {
        const start = movesDown(k, d, previous)
          ? (previous.get(k + 1) ?? 0)
          : (previous.get(k - 1) ?? 0) + 1
        return [k, slide(a, b, start, start - k)] as const
      }),
    )
    const reached = Array.some(
      diagonals(d),
      (k) => (frontier.get(k) ?? 0) >= a.length && (frontier.get(k) ?? 0) - k >= b.length,
    )
    return reached ? [...trace, frontier] : search(d + 1, [...trace, frontier])
  }
  return search(0, [])
}

type Step<A> =
  | { readonly drop: true; readonly at: number }
  | { readonly drop: false; readonly at: number; readonly item: A }

const stepsOf = <A>(a: ReadonlyArray<A>, b: ReadonlyArray<A>): ReadonlyArray<Step<A>> => {
  const frontiers = frontiersOf(a, b)
  return frontiers.length < 2
    ? []
    : pipe(
        Array.range(1, frontiers.length - 1),
        Array.reverse,
        Array.mapAccum(
          { x: a.length, y: b.length },
          (point, d): [{ x: number; y: number }, Step<A>] => {
            const previous = frontiers[d - 1] ?? new Map()
            const k = point.x - point.y
            const down = movesDown(k, d, previous)
            const fromK = down ? k + 1 : k - 1
            const fromX = previous.get(fromK) ?? 0
            const fromY = fromX - fromK
            return [
              { x: fromX, y: fromY },
              down ? { drop: false, at: fromX, item: b[fromY] as A } : { drop: true, at: fromX },
            ]
          },
        ),
        ([, steps]) => Array.reverse(steps),
      )
}

const hunksOf = <A>(a: ReadonlyArray<A>, b: ReadonlyArray<A>): ReadonlyArray<Hunk<A>> =>
  Array.reduce(stepsOf(a, b), [] as ReadonlyArray<Hunk<A>>, (hunks, step) => {
    const open = Option.filter(Array.last(hunks), (hunk) => hunk.to === step.at)
    const grown = (hunk: Hunk<A>): Hunk<A> =>
      step.drop ? { ...hunk, to: hunk.to + 1 } : { ...hunk, items: [...hunk.items, step.item] }
    return Option.match(open, {
      onNone: () => [...hunks, grown({ from: step.at, to: step.at, items: [] })],
      onSome: (hunk) => [...Array.dropRight(hunks, 1), grown(hunk)],
    })
  })

diffOf runs the algorithm twice. It compares the two texts line by line first, which is quick because a formatter leaves most lines as they were. It then compares each changed run of lines character by character, so a change shrinks to the characters that differ. A changed run whose old and new text together pass refineLimit characters stays a single change, since a comparison that long costs more than the smaller change is worth.

TypeScript
const refineLimit = 4000

const linesOf = (text: string): ReadonlyArray<string> => (text === '' ? [] : text.split(/(?<=\n)/))

const diffOf = (before: string, after: string): ReadonlyArray<Edit> => {
  const lines = linesOf(before)
  const offsets = Array.mapAccum(lines, 0, (at, line) => [at + line.length, at])[1]
  const offsetOf = (index: number): number => offsets[index] ?? before.length
  return Array.flatMap(hunksOf(lines, linesOf(after)), (hunk) => {
    const start = offsetOf(hunk.from)
    const end = offsetOf(hunk.to)
    const old = before.slice(start, end)
    const replacement = hunk.items.join('')
    return old.length + replacement.length > refineLimit
      ? [{ start, end, text: replacement }]
      : Array.map(hunksOf(old.split(''), replacement.split('')), (piece) => ({
          start: start + piece.from,
          end: start + piece.to,
          text: piece.items.join(''),
        }))
  })
}

Placing a change in the loom

A formatter's change is a change to the text it was given, and that text was cut from the loom. A Span records where each stretch of it came from: the stretch's offset and length in the formatter's text, and the offset in the loom where the author wrote it. placed follows the spans to carry a change back to the loom.

A change that removes or replaces text must fall inside one span. A change that only inserts text lands at one point, and that point is ambiguous at the edge of a span, where the text the formatter read joins two stretches the loom keeps apart. So an insertion is placed outright only strictly inside a span, or where two spans meet and agree on the point.

Three kinds of insertion are placed at an edge all the same, because their meaning says which side they belong to.

  • What a formatter inserts where a stretch ends is nearly always something that closes: the semicolon that ends a statement, the comma after an item, a closing bracket. Each finishes the code before it, so it lands at the end of the span that ends there.
  • Spaces a formatter inserts in a line's indentation indent the code that follows them, so they land at the start of the span that begins there.
  • The start and the end of the formatter's text belong to nothing but the first and the last span, so an insertion there lands at that span's edge.
TypeScript
interface Span {
  readonly genStart: number
  readonly genLength: number
  readonly sourceStart: number
}

const spanEnd = (span: Span): number => span.genStart + span.genLength

const closing = /^[;,)\]}]/
const spaces = /^[ \t]+$/

const inIndentation = (code: string, at: number): boolean =>
  /^[ \t]*$/.test(code.slice(code.lastIndexOf('\n', at - 1) + 1, at))

const insertedAt = (point: number, change: Edit): Edit => ({
  start: point,
  end: point,
  text: change.text,
})

const startOf = (spans: ReadonlyArray<Span>, at: number, change: Edit): Option.Option<Edit> =>
  Option.map(
    Array.findFirst(spans, (span) => span.genStart === at),
    (span) => insertedAt(span.sourceStart, change),
  )

const endOf = (spans: ReadonlyArray<Span>, at: number, change: Edit): Option.Option<Edit> =>
  Option.map(
    Array.findFirst(spans, (span) => spanEnd(span) === at),
    (span) => insertedAt(span.sourceStart + span.genLength, change),
  )

const atEdge = (spans: ReadonlyArray<Span>, change: Edit, code: string): Option.Option<Edit> =>
  Match.value(change).pipe(
    Match.when(
      (edit) => edit.start !== edit.end,
      () => Option.none(),
    ),
    Match.when(
      (edit) => closing.test(edit.text) || edit.start === code.length,
      (edit) => endOf(spans, edit.start, edit),
    ),
    Match.when(
      (edit) => (spaces.test(edit.text) && inIndentation(code, edit.start)) || edit.start === 0,
      (edit) => startOf(spans, edit.start, edit),
    ),
    Match.orElse(() => Option.none()),
  )

const placed =
  (spans: ReadonlyArray<Span>, code: string) =>
  (change: Edit): Option.Option<Edit> => {
    const holders = Array.filter(
      spans,
      (span) => span.genStart <= change.start && change.end <= spanEnd(span),
    )
    const points = Array.dedupe(
      Array.map(holders, (span) => span.sourceStart + change.start - span.genStart),
    )
    const settled =
      change.start < change.end ||
      holders.length > 1 ||
      Array.some(holders, (span) => span.genStart < change.start && change.start < spanEnd(span))
    return points.length === 1 && settled
      ? Option.some({
          start: points[0],
          end: points[0] + change.end - change.start,
          text: change.text,
        })
      : atEdge(holders, change, code)
  }

editsOf turns a formatter's answer into edits to the loom. It diffs the text the formatter read against the text it returned, and places every change.

A change the pass cannot place is dropped when it only moves whitespace, since no language Loom formats reads that whitespace as meaning. Any other change it cannot place leaves the whole text as it was. Dropping half a pair of parentheses would break the code, so the text takes all of a formatter's real changes or none of them.

TypeScript
const blankChange = (code: string, change: Edit): boolean =>
  code.slice(change.start, change.end).trim() === '' && change.text.trim() === ''

const editsOf = (
  before: string,
  after: string,
  spans: ReadonlyArray<Span>,
): Option.Option<ReadonlyArray<Edit>> => {
  const place = placed(spans, before)
  const placements = Array.map(diffOf(before, after), (change) => ({
    change,
    edit: place(change),
  }))
  return Array.some(
    placements,
    ({ change, edit }) => Option.isNone(edit) && !blankChange(before, change),
  )
    ? Option.none()
    : Option.some(Array.getSomes(Array.map(placements, ({ edit }) => edit)))
}

A section's code, with its anchors held

A section's code is the code blocks it writes, each run of code lines between a => and the ~ or heading that ends it. A section may stop for prose and go on, so its blocks are one section's code split by the prose, and they read as one text, joined in order. A block's blank first and last lines belong to the layout around the code, so blocksOf trims them, and each block keeps its own lines from its first written line to its last.

TypeScript
interface Block {
  readonly start: number
  readonly end: number
}

const lineEnd = (weft: CodeWeft, text: string): number =>
  weft.position.start.offset +
  text.slice(weft.position.start.offset, weft.position.end.offset).replace(/\r?\n$/, '').length

const written =
  (text: string) =>
  (weft: CodeWeft): boolean =>
    text.slice(weft.position.start.offset, lineEnd(weft, text)).trim() !== ''

const runsOf = (section: LoomSection): ReadonlyArray<ReadonlyArray<CodeWeft>> =>
  Array.reduce(section.code, [[]] as ReadonlyArray<ReadonlyArray<CodeWeft>>, (runs, weft) =>
    weft.type === 'CodeWeft'
      ? [
          ...Array.dropRight(runs, 1),
          [...Option.getOrElse(Array.last(runs), (): ReadonlyArray<CodeWeft> => []), weft],
        ]
      : [...runs, []],
  )

const blocksOf = (section: LoomSection, text: string): ReadonlyArray<Block> =>
  pipe(
    runsOf(section),
    Array.map(Array.filter(written(text))),
    Array.map((run) =>
      Option.map(Option.all([Array.head(run), Array.last(run)]), ([first, last]) => ({
        start: first.position.start.offset,
        end: lineEnd(last, text),
      })),
    ),
    Array.getSomes,
  )

An anchor in a section's code is a line of that code like any other. It stands where the code it names will go, and the depth of its line is the depth that code lands at. The formatter cannot read an anchor, since the anchor is Loom's syntax and not the language's, so each one is held while the section formats. A placeholder takes its place: an identifier as long as the anchor, so a line keeps its length, and valid where code can stand, as a statement, an item in a list, a member of an object, an argument, or the text inside a string.

An anchor either stands alone on its line or has code beside it, and the two compose differently. One alone on its line takes its line's depth into the code it brings in, and one with code beside it brings the code in as it stands. ownsLine tells them apart, the same test the projection applies.

TypeScript
interface HeldAnchor {
  readonly at: number
  readonly text: string
  readonly placeholder: string
  readonly ownsLine: boolean
}

const ownsLine = (text: string, start: number, end: number): boolean => {
  const next = text.indexOf('\n', end)
  return (
    text.slice(text.lastIndexOf('\n', start - 1) + 1, start).trim() === '' &&
    text.slice(end, next === -1 ? text.length : next).trim() === ''
  )
}

const placeholderFor = (index: number, length: number): string => `_lm${index}`.padEnd(length, '_')

heldOf cuts a section's code out of the loom. It joins the blocks with a line break between each two, records a span for every block, and swaps every anchor for its placeholder. The text with the anchors written out is what the section reads as now, and the text with the placeholders is what the formatter reads.

TypeScript
interface Held {
  readonly written: string
  readonly code: string
  readonly spans: ReadonlyArray<Span>
  readonly anchors: ReadonlyArray<HeldAnchor>
}

const anchorsIn = (section: LoomSection): ReadonlyArray<WarpAnchorToken> =>
  Array.flatMap(section.code, (weft) => (weft.type === 'CodeWeft' ? weft.anchors : []))

const heldOf = (section: LoomSection, text: string): Held => {
  const blocks = blocksOf(section, text)
  const spans = Array.mapAccum(blocks, 0, (at, block): [number, Span] => [
    at + block.end - block.start + 1,
    { genStart: at, genLength: block.end - block.start, sourceStart: block.start },
  ])[1]
  const written = Array.map(blocks, (block) => text.slice(block.start, block.end)).join('\n')
  const anchors = pipe(
    anchorsIn(section),
    Array.map((token) => ({ start: token.position.start.offset, end: token.position.end.offset })),
    Array.map((anchor) =>
      Option.map(
        Array.findFirst(
          spans,
          (span) =>
            span.sourceStart <= anchor.start && anchor.end <= span.sourceStart + span.genLength,
        ),
        (span) => ({ ...anchor, at: span.genStart + anchor.start - span.sourceStart }),
      ),
    ),
    Array.getSomes,
    Array.map((anchor, index): HeldAnchor => ({
      at: anchor.at,
      text: text.slice(anchor.start, anchor.end),
      placeholder: placeholderFor(index, anchor.end - anchor.start),
      ownsLine: ownsLine(text, anchor.start, anchor.end),
    })),
  )
  const code = pipe(
    anchors,
    Array.sort(Order.flip(Order.mapInput(Order.Number, (anchor: HeldAnchor) => anchor.at))),
    Array.reduce(
      written,
      (done, anchor) =>
        done.slice(0, anchor.at) + anchor.placeholder + done.slice(anchor.at + anchor.text.length),
    ),
  )
  return { written, code, spans, anchors }
}

restored writes the anchors back into the formatter's answer. Each placeholder must still be there, once and whole, or the formatter has done something to the code that stands for an anchor and the section stays as written. An anchor that stood alone on its line must still stand alone, and one with code beside it must still have code beside it, since the two compose differently and the formatter must not turn one into the other. A formatter often ends a placeholder alone on its line with the semicolon of a statement or the comma of an item, and the anchor takes neither: the code it brings in ends itself.

TypeScript
const identifierPart = /[\w$]/

const restoredOne = (code: string, anchor: HeldAnchor): Option.Option<string> => {
  const at = code.indexOf(anchor.placeholder)
  const after = at + anchor.placeholder.length
  const whole =
    at !== -1 &&
    code.indexOf(anchor.placeholder, after) === -1 &&
    !identifierPart.test(code.charAt(at - 1)) &&
    !identifierPart.test(code.charAt(after))
  const ending = anchor.ownsLine && /^[;,]/.test(code.slice(after)) ? 1 : 0
  const back = code.slice(0, at) + anchor.text + code.slice(after + ending)
  return whole && ownsLine(back, at, at + anchor.text.length) === anchor.ownsLine
    ? Option.some(back)
    : Option.none()
}

const restored = (code: string, anchors: ReadonlyArray<HeldAnchor>): Option.Option<string> =>
  Array.reduce(anchors, Option.some(code), (done, anchor) =>
    Option.flatMap(done, (text) => restoredOne(text, anchor)),
  )

Formatting a section on its own

Each section's code is formatted on its own, at its own column 0. A section that several anchors use takes one shape, wherever they place it, because nothing about any one of those places goes into the section. Its depth belongs to each anchor, and its readability to itself, so the width it keeps to counts from its own first column.

aloneEdits hands the held code to the language's formatter, writes the anchors back, and places the changes. It answers with nothing when the formatter cannot read the section by itself, or when the answer would move an anchor, so the caller can try the fallback below.

TypeScript
type Formatter = NonNullable<LanguageService['format']>

const aloneEdits = (
  held: Held,
  format: Formatter,
  path: string,
  width: number,
): Effect.Effect<Option.Option<ReadonlyArray<Edit>>> =>
  held.code.trim() === ''
    ? Effect.succeed(Option.some([]))
    : format({ code: held.code, path, width }).pipe(
        Effect.map((formatted) =>
          Option.flatMap(restored(formatted.trimEnd(), held.anchors), (after) =>
            editsOf(held.written, after, held.spans),
          ),
        ),
        Effect.catchCause(() => Effect.succeed(Option.none())),
      )

A section is formatted when its language's service offers format. The section and the Code the product built for it share a name, the one sectionNameOf gives. A section whose => line carries code of its own is left as written, since that code sits on a line of Loom's syntax.

A section is formatted under the name of the file its code tangles into, the same path the tangler writes, so the formatter reads the project's settings for that file. A section that no tangled file composes is formatted under the loom's own name.

TypeScript
interface Formattable {
  readonly section: LoomSection
  readonly name: string
  readonly format: Formatter
}

const formatterFor = (
  languages: ReadonlyArray<LanguageService>,
  languageId: string,
): Option.Option<Formatter> =>
  pipe(
    Array.findFirst(languages, (language) => language.id === languageId),
    Option.flatMapNullishOr((language) => language.format),
  )

const codeNamed = (module: LoomModule, name: string): Option.Option<Code> =>
  Array.findFirst(module.product?.code ?? [], (code) => code.origin.name === name)

const formattableOf = (
  module: LoomModule,
  languages: ReadonlyArray<LanguageService>,
): ReadonlyArray<Formattable> =>
  pipe(
    module.doc.sections,
    Array.filter(
      (section) =>
        !Array.some(section.code, (weft) => weft.type === 'ArrowWeft' && weft.code !== undefined),
    ),
    Array.map((section) =>
      pipe(
        codeNamed(module, sectionNameOf(section)),
        Option.flatMap((code) =>
          Option.map(formatterFor(languages, code.languageId), (format) => ({
            section,
            name: code.origin.name,
            format,
          })),
        ),
      ),
    ),
    Array.getSomes,
  )

const refsOf = (module: LoomModule, name: string): ReadonlyArray<string> =>
  pipe(
    codeNamed(module, name),
    Option.map((code) =>
      Array.getSomes(
        Array.map(code.fragments, (part) =>
          part.type === 'Fragment'
            ? Option.none()
            : Option.map(
                Option.filter(part.target, (target) => target.path === module.path),
                (target) => target.name,
              ),
        ),
      ),
    ),
    Option.getOrElse((): ReadonlyArray<string> => []),
  )

const membersOf = (
  module: LoomModule,
  name: string,
  seen: ReadonlySet<string> = new Set(),
): ReadonlySet<string> =>
  seen.has(name)
    ? seen
    : Array.reduce(
        refsOf(module, name),
        new Set([...seen, name]) as ReadonlySet<string>,
        (members, ref) => membersOf(module, ref, members),
      )

const fileOf = (module: LoomModule, settings: BuildSettings, name: string): string =>
  pipe(
    Array.findFirst(module.product?.files ?? [], (file) =>
      membersOf(module, file.code.origin.name).has(name),
    ),
    Option.map((file) =>
      outputPath(Option.fromNullishOr(settings.packageRoot), module.path, file.path),
    ),
    Option.getOrElse(() => module.path),
  )

When a section does not parse alone

Some sections are not code by themselves. A section that lists the members of an object, or that opens a brace another section closes, means something only where an anchor places it, and a formatter cannot read it alone. Such a section is formatted where it is used, inside the composed file that holds it, but only when exactly one anchor in the loom uses it. Then there is one place it lands, and the shape the formatter gives it there is its shape. Its width counts where it lands, since that is the only place it can be read. A section that several anchors use and that does not parse alone stays as written, since no one place speaks for all of them.

placedIn formats the root that holds such a section and keeps the changes that fall in the section's own code. The rest of the root has had its turn already, section by section, so only this section's changes are taken, and only when every real change within its code can be placed.

TypeScript
const usesOf = (module: LoomModule, name: string): number =>
  Array.reduce(module.product?.code ?? [], 0, (uses, code) =>
    Array.reduce(refsOf(module, code.origin.name), uses, (count, ref) =>
      ref === name ? count + 1 : count,
    ),
  )

const rootsOf = (module: LoomModule): ReadonlyArray<Code> => {
  const roots = rootNamesAt(new Map([[module.path, module]]), module.path)
  return Array.filter(module.product?.code ?? [], (code) =>
    roots.has(code.origin.name.toLowerCase()),
  )
}

const keepEdges = (before: string, after: string): string =>
  before.trim() === ''
    ? before
    : `${/^\s*/.exec(before)?.[0] ?? ''}${after.trim()}${before.slice(before.trimEnd().length)}`

const verbatimSpans = (text: string, composed: LoomVirtualCode): ReadonlyArray<Span> =>
  pipe(
    composed.mappings,
    Array.filter(
      (mapping) =>
        mapping.kind === 'product' &&
        text.slice(mapping.source.start.offset, mapping.source.start.offset + mapping.genLength) ===
          composed.code.slice(mapping.genStart, mapping.genStart + mapping.genLength),
    ),
    Array.map((mapping) => ({
      genStart: mapping.genStart,
      genLength: mapping.genLength,
      sourceStart: mapping.source.start.offset,
    })),
  )

const placedIn = (
  module: LoomModule,
  settings: BuildSettings,
  languages: ReadonlyArray<LanguageService>,
  name: string,
): Effect.Effect<ReadonlyArray<Edit>> => {
  const blocks = pipe(
    Array.findFirst(module.doc.sections, (section) => sectionNameOf(section) === name),
    Option.map((section) => blocksOf(section, module.text)),
    Option.getOrElse((): ReadonlyArray<Block> => []),
  )
  const within = (start: number, end: number): boolean =>
    Array.some(blocks, (block) => block.start <= start && end <= block.end)
  const root = Array.filter(rootsOf(module), (code) =>
    membersOf(module, code.origin.name).has(name),
  )
  return pipe(
    Array.head(root),
    Option.filter(() => root.length === 1),
    Option.flatMap((code) =>
      Option.map(formatterFor(languages, code.languageId), (format) => ({ code, format })),
    ),
    Option.match({
      onNone: () => Effect.succeed([] as ReadonlyArray<Edit>),
      onSome: ({ code, format }) => {
        const composed = fromProduct(new Map([[module.path, module]]), code.origin)
        const spans = verbatimSpans(module.text, composed)
        const own = Array.filter(spans, (span) =>
          within(span.sourceStart, span.sourceStart + span.genLength),
        )
        const from = Math.min(...Array.map(own, (span) => span.genStart))
        const to = Math.max(...Array.map(own, spanEnd))
        const place = placed(spans, composed.code)
        return format({
          code: composed.code,
          path: fileOf(module, settings, code.origin.name),
          width: settings.width,
        }).pipe(
          Effect.map((formatted) => {
            const placements = pipe(
              diffOf(composed.code, keepEdges(composed.code, formatted)),
              Array.filter((change) => from <= change.start && change.end <= to),
              Array.map((change) => ({ change, edit: place(change) })),
            )
            return Array.some(
              placements,
              ({ change, edit }) => Option.isNone(edit) && !blankChange(composed.code, change),
            )
              ? []
              : Array.filter(Array.getSomes(Array.map(placements, ({ edit }) => edit)), (edit) =>
                  within(edit.start, edit.end),
                )
          }),
          Effect.catchCause(() => Effect.succeed([] as ReadonlyArray<Edit>)),
        )
      },
    }),
  )
}

The composed files still hold

A section formatted on its own reads well by itself, but the file it tangles into has the last word. So after the sections are formatted the pass composes every root again and asks the language's formatter to read it. A root that the formatter can no longer read puts back every section it composes. The check is the formatter's own parse, so it holds whatever the language: code that composed before composes after.

A root that names a section in another loom, or a part that failed to compose, has a gap where that code goes, and the pass reads only this one file. Such a root is not checked, and its sections stand on the checks made while they were formatted alone.

TypeScript
const composesWhole = (module: LoomModule, root: string): boolean =>
  Array.every(Array.fromIterable(membersOf(module, root)), (name) =>
    Option.exists(codeNamed(module, name), (code) =>
      Array.every(code.fragments, (part) =>
        part.type === 'Fragment'
          ? part.health.status === 'ok'
          : Option.exists(part.target, (target) => target.path === module.path),
      ),
    ),
  )

const brokenRoots = (
  module: LoomModule,
  settings: BuildSettings,
  languages: ReadonlyArray<LanguageService>,
): Effect.Effect<ReadonlyArray<string>> =>
  Effect.map(
    Effect.forEach(rootsOf(module), (root) =>
      pipe(
        formatterFor(languages, root.languageId),
        Option.filter(() => composesWhole(module, root.origin.name)),
        Option.match({
          onNone: () => Effect.succeed(Option.none<string>()),
          onSome: (format) =>
            format({
              code: fromProduct(new Map([[module.path, module]]), root.origin).code,
              path: fileOf(module, settings, root.origin.name),
              width: settings.width,
            }).pipe(
              Effect.as(Option.none<string>()),
              Effect.catchCause(() => Effect.succeed(Option.some(root.origin.name))),
            ),
        }),
      ),
    ),
    Array.getSomes,
  )

The service

LoomFormatter runs the pass. Its format takes a loom's path, the text to format, and the languages the project serves, and answers with the text formatted. It reads the file's build settings for the anchor delimiters, the primary language and the width, builds the module from the text it was given rather than from disk, and works in three steps.

  1. It joins the prose and formats every section that its language can read alone.
  2. It composes the file again and puts back the sections of any root that no longer reads. When it put any back it looks a second time, and a second look that still finds one puts back every section, so the code stays as written and the prose is still joined.
  3. It formats, inside their files, the sections that could not be read alone and that one anchor uses, and keeps that step only if every root still reads after it.

The file's module is built through a Source that knows only this one text, so the build never reads another file and never sees an older copy of this one.

Its looms names the looms a path covers, for a caller that formats more than one. A file is itself. A folder is every loom under it that the project holds, by the same walk tangle takes, so format and tangle agree on what a folder contains.

TypeScript
const textSource = (path: Path, text: string): Source => ({
  read: (at) =>
    at === path
      ? Effect.succeed(text)
      : Effect.fail(
          new ReadError({ path: at, cause: 'the formatter reads only the file it formats' }),
        ),
  list: Option.none(),
})

interface SectionEdits {
  readonly name: string
  readonly edits: ReadonlyArray<Edit>
}

export class LoomFormatter extends Context.Service<LoomFormatter>()('LoomFormatter', {
  make: Effect.gen(function* () {
    const config = yield* PackageConfig
    const corpus = yield* LoomCorpusAstBuilder
    return {
      format: (
        path: Path,
        text: string,
        languages: ReadonlyArray<LanguageService>,
      ): Effect.Effect<string> =>
        Effect.gen(function* () {
          const settings = yield* config.resolve(path)
          const build = (source: string) =>
            corpus.build(
              textSource(path, source),
              path,
              settings.delims,
              settings.primaryLanguage,
              settings.variables,
            )
          const module = yield* build(text)
          const prose = proseEdits(module.doc, text, settings.delims)
          const sections = formattableOf(module, languages)
          const alone = yield* Effect.forEach(sections, (section) =>
            Effect.map(
              aloneEdits(
                heldOf(section.section, text),
                section.format,
                fileOf(module, settings, section.name),
                settings.width,
              ),
              (edits) => ({ name: section.name, edits }),
            ),
          )
          const formattedAlone = Array.getSomes(
            Array.map(alone, ({ name, edits }) =>
              Option.map(edits, (found) => ({ name, edits: found })),
            ),
          )
          const textWith = (kept: ReadonlyArray<SectionEdits>): string =>
            applyEdits(text, settle([...prose, ...Array.flatMap(kept, ({ edits }) => edits)]))
          const holding = (kept: ReadonlyArray<SectionEdits>) =>
            Effect.gen(function* () {
              const next = yield* build(textWith(kept))
              const broken = yield* brokenRoots(next, settings, languages)
              const lost = new Set(
                Array.flatMap(broken, (root) => Array.fromIterable(membersOf(next, root))),
              )
              return Array.filter(kept, ({ name }) => !lost.has(name))
            })
          const once = yield* holding(formattedAlone)
          const second = once.length === formattedAlone.length ? once : yield* holding(once)
          const kept = second.length === once.length ? once : []
          const settledText = textWith(kept)
          const settledModule = yield* build(settledText)
          const fallback = Array.filter(
            alone,
            ({ name, edits }) => Option.isNone(edits) && usesOf(settledModule, name) === 1,
          )
          const placedEdits = yield* Effect.forEach(fallback, ({ name }) =>
            placedIn(settledModule, settings, languages, name),
          )
          const withFallback = applyEdits(settledText, settle(Array.flatten(placedEdits)))
          const broken = yield* Effect.flatMap(build(withFallback), (next) =>
            brokenRoots(next, settings, languages),
          )
          return broken.length === 0 ? withFallback : settledText
        }),
      looms: (path: Path): Effect.Effect<ReadonlyArray<Path>> =>
        statSync(path, { throwIfNoEntry: false })?.isDirectory() === true
          ? Effect.map(config.resolve(path), (settings) => loomsIn(path, projectIn(settings)))
          : Effect.succeed([path]),
    }
  }),
}) {
  static readonly layer = Layer.effect(this, this.make).pipe(
    Layer.provide(LoomCorpusAstBuilder.layer),
  )
}

What this module draws on

Effect supplies the service, the Option and Order the pass leans on, Match for a line's kind and an insertion's edge, and Array for every fold. The projection gives fromProduct, which composes a root with its mappings, and rootNamesAt, which names a file's roots, and the product pass gives sectionNameOf, the name a section and its Code share. The corpus builder parses the text into a module through a Source, the compiler gives outputPath, the tangler's rule for where a sink writes, and PackageConfig gives a file's build settings. loomsIn and projectIn walk a folder for the looms its project holds, and Node's statSync tells a folder from a file. The AST supplies the shapes the pass reads: the document and its sections, their prose and code wefts and the anchor tokens in them, the module, the product's Code, the composed LoomVirtualCode, and the project's anchor delimiters. The LanguageService contract names a language's formatter.

TypeScript
import { Array, Context, Effect, Layer, Match, Option, Order, pipe } from 'effect'
import { fromProduct, rootNamesAt } from '#ast/LoomVirtualCodeBuilder'
import { sectionNameOf } from '#ast/ProductBuilder'
import { LoomCorpusAstBuilder, ReadError, type Source } from '#ast/LoomCorpusAstBuilder'
import { outputPath } from './LoomCompiler'
import { type BuildSettings, PackageConfig } from './PackageConfig'
import { loomsIn, projectIn } from './LoomProject'
import { statSync } from 'node:fs'
import type { LoomDocument, LoomSection } from '@athrio/loom-ast/LoomAst'
import type { CodeWeft, SectionBodyWeft } from '@athrio/loom-ast/Weft'
import type { LoomModule, Path } from '@athrio/loom-ast/LoomCorpusAst'
import type { Code } from '@athrio/loom-ast/ProductAst'
import type { LoomVirtualCode } from '@athrio/loom-ast/LoomVirtualCode'
import type { AnchorDelims, WarpAnchorToken } from '@athrio/loom-ast/LoomTokens'
import type { LanguageService } from '@athrio/loom-lang-services/LanguageService'

Testing the formatter

Each case formats a small loom and reads back the text. A loom written inside this loom's code would be read as part of it, so a fixture is a list of its lines, each one quoted and indented, and lines joins them into the text a file holds. anchor spells an anchor in two pieces for the same reason.

The fixtures live at a path no workspace holds, so they format under the defaults: Loom's own anchor delimiters and a width of a hundred. Each declares TypeScript in its frontmatter. A case that needs a TypeScript formatter supplies a stand-in service. squeezeAt turns every run of spaces inside a line into one space, and squeezes only when it is asked for the width it was made with, so a case that expects the change also checks that the width arrived. formatterOf makes a stand-in from any function of the code.

tidy stands in for a real formatter's layout. It indents every line by the braces open around it, two spaces a level, and squeezes the spaces inside each line. It also refuses code that does not hold together by itself, the way a parser would: braces that do not balance, or a line at the outermost level that ends in a comma, as the members of an object do.

TypeScript
import { describe, expect, it as it_ } from 'bun:test'
import { effectify } from '@athrio/effect-test'
import { Array, Effect, Layer, Option } from 'effect'
import { LoomFormatter } from '../src/LoomFormatter'
import { PackageConfig } from '../src/PackageConfig'
import {
  defineLanguageService,
  type LanguageService,
  ServiceError,
} from '@athrio/loom-lang-services/LanguageService'

const it = effectify(it_)

const lines = (...rows: ReadonlyArray<string>): string => `${rows.join('\n')}\n`

const anchor = (name: string): string => `::${'['}${name}]`

const path = '/nowhere/formatted.loom'

const squeezeAt = (width: number): LanguageService =>
  defineLanguageService({
    id: 'typescript',
    displayName: 'Squeeze',
    extensions: ['.ts'],
    plugins: () => Effect.succeed([]),
    format: (request) =>
      Effect.succeed(
        request.width === width ? request.code.replace(/(\S) {2,}(?=\S)/g, '$1 ') : request.code,
      ),
  })

const formatterOf = (format: (code: string) => Option.Option<string>): LanguageService =>
  defineLanguageService({
    id: 'typescript',
    displayName: 'Stand-in',
    extensions: ['.ts'],
    plugins: () => Effect.succeed([]),
    format: (request) =>
      Option.match(format(request.code), {
        onNone: () =>
          Effect.fail(new ServiceError({ service: 'typescript', reason: 'unreadable' })),
        onSome: (code) => Effect.succeed(code),
      }),
  })

const count = (text: string, mark: string): number => text.split(mark).length - 1

const tidy = formatterOf((code) => {
  const rows = Array.map(code.split('\n'), (row) => row.trim().replace(/(\S) {2,}(?=\S)/g, '$1 '))
  const [depth, laid] = Array.mapAccum(rows, 0, (open, row) => {
    const own = row.startsWith('}') ? open - 1 : open
    return [open + count(row, '{') - count(row, '}'), { row, own, open }]
  })
  const holds =
    depth === 0 &&
    Array.every(laid, ({ row, own, open }) => own >= 0 && !(open === 0 && row.endsWith(',')))
  return holds
    ? Option.some(
        Array.map(laid, ({ row, own }) => (row === '' ? '' : '  '.repeat(own) + row)).join('\n'),
      )
    : Option.none()
})

const formatted = (text: string, languages: ReadonlyArray<LanguageService> = []) =>
  LoomFormatter.pipe(
    Effect.flatMap((formatter) => formatter.format(path, text, languages)),
    Effect.provide(LoomFormatter.layer.pipe(Layer.provide(PackageConfig.layer))),
  )

const frontmatter = ['---', 'Language: TypeScript', '---', '']

Joining prose

A paragraph written over three lines comes back on one, its words joined by single spaces. The other blocks of Markdown keep their lines: a list item takes in the line that continues it but not the item after it, a fence keeps every line inside it, a heading and a table keep theirs, and a line that ends in two spaces keeps its break.

TypeScript
describe('LoomFormatter — prose', () => {
  it.effect('joins the lines of a paragraph into one', () =>
    Effect.gen(function* () {
      const text = lines(
        ...frontmatter,
        '# Notes',
        '',
        'One paragraph',
        'written over',
        '   three lines.',
        '',
      )
      expect(yield* formatted(text)).toBe(
        lines(...frontmatter, '# Notes', '', 'One paragraph written over three lines.', ''),
      )
    }),
  )

  it.effect('keeps the lines of lists, fences, tables and hard breaks', () =>
    Effect.gen(function* () {
      const text = lines(
        ...frontmatter,
        '# Notes',
        '',
        '- an item',
        '  that continues',
        '- the next item',
        '',
        '```',
        'kept',
        'as is',
        '```',
        '',
        '| a | b |',
        '| c | d |',
        '',
        'a break  ',
        'stays',
        '',
      )
      expect(yield* formatted(text)).toBe(
        lines(
          ...frontmatter,
          '# Notes',
          '',
          '- an item that continues',
          '- the next item',
          '',
          '```',
          'kept',
          'as is',
          '```',
          '',
          '| a | b |',
          '| c | d |',
          '',
          'a break  ',
          'stays',
          '',
        ),
      )
    }),
  )

  it.effect('changes nothing twice', () =>
    Effect.gen(function* () {
      const once = yield* formatted(lines(...frontmatter, '# Notes', '', 'Two', 'lines.', ''))
      expect(yield* formatted(once)).toBe(once)
    }),
  )
})

Formatting code

Without a formatter for its language, a code section keeps its spacing. With a formatter made for the default width of a hundred, the doubled spaces in each section's code close up, the code's own line breaks stay, and the prose around the code is untouched. A formatter made for another width changes nothing, which shows the width the formatter receives is the project's.

An anchor is a line of its section's code. A formatter that ends every line with a semicolon ends the anchor's placeholder with one too, and the anchor takes none, so it still stands alone on its line. A formatter that joins the lines would leave the anchor beside other code, which would change how it composes, so that section stays as written.

TypeScript
describe('LoomFormatter — code', () => {
  const program = lines(
    ...frontmatter,
    '# Program [program.ts]',
    '',
    'Some prose.',
    '',
    '=>',
    '',
    'const  answer  =  42',
    anchor('Greeting'),
    '',
    '# Greeting',
    '',
    '=>',
    '',
    "console.log('hi',  answer)",
  )

  it.effect('leaves code alone without a formatter for its language', () =>
    Effect.gen(function* () {
      expect(yield* formatted(program)).toBe(program)
    }),
  )

  it.effect('formats each section through its language, in place', () =>
    Effect.gen(function* () {
      expect(yield* formatted(program, [squeezeAt(100)])).toBe(
        program
          .replace('const  answer  =  42', 'const answer = 42')
          .replace("console.log('hi',  answer)", "console.log('hi', answer)"),
      )
    }),
  )

  it.effect('hands the formatter the project width', () =>
    Effect.gen(function* () {
      expect(yield* formatted(program, [squeezeAt(80)])).toBe(program)
    }),
  )

  it.effect('keeps an anchor on a line of its own when the formatter ends lines', () =>
    Effect.gen(function* () {
      const semicolons = formatterOf((code) => Option.some(code.replace(/(\S)$/gm, '$1;')))
      expect(yield* formatted(program, [semicolons])).toBe(
        program
          .replace('const  answer  =  42', 'const  answer  =  42;')
          .replace("console.log('hi',  answer)", "console.log('hi',  answer);"),
      )
    }),
  )

  it.effect('keeps a section as written when its anchor would leave its own line', () =>
    Effect.gen(function* () {
      const joined = formatterOf((code) => Option.some(code.replace(/\n/g, ' ')))
      expect(yield* formatted(program, [joined])).toBe(program)
    }),
  )
})

Anchors and the depth of the code they place

The anchor carries the depth, and the section it names keeps its own first column. Inside a function, an anchor written at the left margin moves in to the function's depth, and the section it names, written two spaces in, moves out to its own first column. Re-indentation then places that section's lines at the anchor's depth when the loom tangles, so the tangled function reads as a formatter would lay it out.

A section that two anchors use, at two depths, is formatted once, the same for both. Each anchor takes the depth its own place needs.

A section that stops for prose and goes on is one section's code, so its second block keeps the nesting its first block opened.

TypeScript
describe('LoomFormatter — anchors', () => {
  it.effect('gives an anchor its depth, and the section it names its own first column', () =>
    Effect.gen(function* () {
      const nested = (indent: string, body: ReadonlyArray<string>) =>
        lines(
          ...frontmatter,
          '# Main [main.ts]',
          '',
          '=>',
          '',
          'function main() {',
          indent + anchor('Body'),
          '}',
          '',
          '# Body',
          '',
          '=>',
          '',
          ...body,
        )
      expect(
        yield* formatted(nested('', ["  console.log('one')", "  console.log('two')"]), [tidy]),
      ).toBe(nested('  ', ["console.log('one')", "console.log('two')"]))
    }),
  )

  it.effect('formats a shared section once, the same wherever it is used', () =>
    Effect.gen(function* () {
      const shared = (a: string, b: string, body: string) =>
        lines(
          ...frontmatter,
          '# Main [main.ts]',
          '',
          '=>',
          '',
          'function first() {',
          a + anchor('Body'),
          '}',
          'function second() {',
          '  if (ready) {',
          b + anchor('Body'),
          '  }',
          '}',
          '',
          '# Body',
          '',
          '=>',
          '',
          body,
        )
      expect(yield* formatted(shared('', '', "  console.log('go',  now)"), [tidy])).toBe(
        shared('  ', '    ', "console.log('go', now)"),
      )
    }),
  )

  it.effect('keeps the nesting a section opened before its prose', () =>
    Effect.gen(function* () {
      const split = (rows: ReadonlyArray<string>) =>
        lines(
          ...frontmatter,
          '# Main [main.ts]',
          '',
          '=>',
          '',
          rows[0] ?? '',
          rows[1] ?? '',
          '',
          '~',
          '',
          'The work happens once the check passes.',
          '',
          '=>',
          '',
          rows[2] ?? '',
          rows[3] ?? '',
          rows[4] ?? '',
        )
      expect(
        yield* formatted(split(['function main() {', 'if (ready) {', 'run()', '}', '}']), [tidy]),
      ).toBe(split(['function main() {', '  if (ready) {', '    run()', '  }', '}']))
    }),
  )
})

Sections that do not parse alone

A section that lists an object's members cannot be read by itself. When one anchor uses it, it is formatted where that anchor places it, and its doubled spaces close up there. When two anchors use it, no one place speaks for both, so it stays as written, while the anchors around it still take their depth.

TypeScript
describe('LoomFormatter — sections that need their place', () => {
  const options = (anchors: ReadonlyArray<string>, fields: ReadonlyArray<string>) =>
    lines(
      ...frontmatter,
      '# Options [options.ts]',
      '',
      '=>',
      '',
      ...anchors,
      '',
      '# Fields',
      '',
      '=>',
      '',
      ...fields,
    )

  it.effect('formats a section where its one anchor places it', () =>
    Effect.gen(function* () {
      expect(
        yield* formatted(
          options(
            ['const options = {', anchor('Fields'), '}'],
            ['retries:  3,', 'verbose:  true,'],
          ),
          [tidy],
        ),
      ).toBe(
        options(
          ['const options = {', `  ${anchor('Fields')}`, '}'],
          ['retries: 3,', 'verbose: true,'],
        ),
      )
    }),
  )

  it.effect('leaves a section that two anchors use as written', () =>
    Effect.gen(function* () {
      const both = (indent: string) => [
        'const first = {',
        indent + anchor('Fields'),
        '}',
        'const second = {',
        indent + anchor('Fields'),
        '}',
      ]
      expect(yield* formatted(options(both(''), ['retries:  3,']), [tidy])).toBe(
        options(both('  '), ['retries:  3,']),
      )
    }),
  )
})
Loom