SourceLanguages as packagesThe product's own TypeScript program

Part VII, Chapter 7

Part VII, Chapter 7: The product's own TypeScript program

Package: packages/loom-service-typescript/

Language: TypeScript

Raw source

The product's own TypeScript program

The frame keeps Volar's built-in TypeScript program. The product needs one of its own. createProductProgram builds it: a standalone TypeScript service, governed by the consumer's tsconfig.json rather than the frame's baked baseline, so each section is analyzed the way the project ships it.

The program never opens a .loom. The host composes the file's whole corpus, every composition root resolved to TypeScript, and hands the program each root under its own path. One root's import of another then resolves against that other's composition, not its last output on disk. The program holds every root, but it names to TypeScript only the roots of its own project, so TypeScript parses those roots and what they import rather than the whole corpus. The same volar-service-typescript plugins the frame uses answer diagnostics, hover, completion, and definition over this program. The host maps each answer back to its place in the .loom.

What the program offers

A ProductProgram holds the product's roots and answers the editor's questions about them. sync sets those roots in one call. It takes the roots the program lists, each a ProductFile with its path and text, and the other roots of the composition, which the program reads only when a listed root imports one. A program called with no other roots lists every root it holds. sync adds the roots new since the last call, replaces the ones whose text changed, and drops the ones now gone. The feature methods answer over a root by its path: diagnostics for the whole file; hover, completions, definition, and references at a position; rename for a new name; and codeActions for the quick-fixes and refactors in a range, with resolveCodeAction filling in an action's edit. Each returns what volar-service-typescript finds, named by that path. The service rewrites each such path to the embedded document it stands for, and Volar maps the coordinates back to the .loom. dispose releases the service.

TypeScript
export interface ProductFile {
  readonly path: string
  readonly text: string
}

export interface ProductProgram {
  readonly sync: (
    listed: ReadonlyArray<ProductFile>,
    others?: ReadonlyArray<ProductFile>,
  ) => void
  readonly diagnostics: (fileName: string) => Promise<Diagnostic[]>
  readonly hover: (fileName: string, position: Position) => Promise<Hover | undefined>
  readonly completions: (fileName: string, position: Position) => Promise<CompletionList>
  readonly definition: (fileName: string, position: Position) => Promise<LocationLink[] | undefined>
  readonly references: (fileName: string, position: Position) => Promise<Location[] | undefined>
  readonly rename: (
    fileName: string,
    position: Position,
    newName: string,
  ) => Promise<WorkspaceEdit | undefined>
  readonly codeActions: (
    fileName: string,
    range: Range,
    context: CodeActionContext,
  ) => Promise<CodeAction[] | undefined>
  readonly resolveCodeAction: (action: CodeAction) => Promise<CodeAction>
  readonly dispose: () => void
}

The files served from memory

Volar reads files through a language: a registry that holds a snapshot of each file, the file's text as Volar last read it. productLanguage builds one that serves the product roots from memory and every other file from disk. It answers a root by file name, from the roots map the program owns. It reads every other file Volar asks for, such as the TypeScript standard library or a package under node_modules, from disk. A file that is neither a root nor present on disk leaves the registry.

TypeScript
export const productLanguage = (
  ts: TypeScript,
  roots: ReadonlyMap<string, IScriptSnapshot>,
): Language<URI> => {
  const fromDisk = diskSnapshots(ts)
  const language = createLanguage<URI>(
    [{ getLanguageId: (uri: URI) => resolveFileLanguageId(uri.path) }],
    createUriMap(ts.sys.useCaseSensitiveFileNames),
    (uri, includeFsFiles) => {
      const fileName = uri.fsPath
      const root = roots.get(fileName)
      if (root) {
        language.scripts.set(uri, root)
        return
      }
      if (!includeFsFiles) return
      Option.match(fromDisk(fileName), {
        onSome: (snapshot) => language.scripts.set(uri, snapshot),
        onNone: () => language.scripts.delete(uri),
      })
    },
  )
  return language
}

A file read from disk keeps its snapshot until the file changes on disk. Volar versions a file by its snapshot, so every new snapshot is a new version, even one that holds the same text. TypeScript parses a file again whenever its version moves. Volar looks a file up each time TypeScript asks for the file's version, so a file read afresh on every lookup would never keep a version. Each edit to a root would then rebuild the program from nothing. TypeScript would parse the standard library and every declaration file under node_modules again. That takes seconds, and every other request from the editor waits behind it, a format among them.

diskSnapshots builds the reader that productLanguage calls fromDisk. The reader reads each file through ts.sys, the TypeScript system interface, and keeps it in a DiskFile, which records the file's snapshot and the time the file was last modified. fromDisk reads a file again only when that time moves. A file it found missing is kept too, so the next lookup asks the disk only for the time. Volar's own language server keeps the files it reads the same way.

TypeScript
interface DiskFile {
  readonly modified: Option.Option<number>
  readonly snapshot: Option.Option<IScriptSnapshot>
}

const sameTime = Option.makeEquivalence(Equivalence.Number)

const diskSnapshots = (ts: TypeScript): ((fileName: string) => Option.Option<IScriptSnapshot>) => {
  const disk = new Map<string, DiskFile>()
  const read = (fileName: string, modified: Option.Option<number>): Option.Option<IScriptSnapshot> => {
    const snapshot = Option.map(
      Option.fromNullishOr(ts.sys.fileExists(fileName) ? ts.sys.readFile(fileName) : undefined),
      (text) => ts.ScriptSnapshot.fromString(text),
    )
    disk.set(fileName, { modified, snapshot })
    return snapshot
  }
  return (fileName) => {
    const modified = Option.map(Option.fromNullishOr(ts.sys.getModifiedTime?.(fileName)), (time) =>
      time.valueOf(),
    )
    return pipe(
      Option.fromNullishOr(disk.get(fileName)),
      Option.filter((file) => sameTime(file.modified, modified)),
      Option.match({
        onSome: (file) => file.snapshot,
        onNone: () => read(fileName, modified),
      }),
    )
  }
}

Assembling the program

createProductProgram ties the layers together. It owns the roots map of every root it holds, the fileNames set of the roots it names to TypeScript, and a project version, and sync keeps all three current. It drops the roots the new set omits, writes the ones whose text changed, takes the new listing, and bumps the version once when any of them changed. The program then rechecks after a real edit and rests otherwise. unchanged is the guard sync uses, comparing a root's incoming text against the snapshot already held. The project host reports the compiler options, that version, and the listed roots as the program's file names. createLanguageServiceHost turns it into the host TypeScript needs. createLanguageService runs the feature plugins over that host. The returned ProductProgram forwards each feature to it, naming the file with URI.file and passing the position through unchanged.

TypeScript
export const createProductProgram = (
  ts: TypeScript,
  compilerOptions: CompilerOptions,
): ProductProgram => {
  const roots = new Map<string, IScriptSnapshot>()
  let fileNames: ReadonlySet<string> = new Set()
  let projectVersion = 0
  const language = productLanguage(ts, roots)
  const asUri = (fileName: string): URI => URI.file(fileName)

  const projectHost = {
    getCurrentDirectory: () => process.cwd(),
    getCompilationSettings: () => compilerOptions,
    getProjectVersion: () => projectVersion.toString(),
    getScriptFileNames: () => [...fileNames],
  }

  const env: LanguageServiceEnvironment = {
    workspaceFolders: [URI.file(process.cwd())],
  }

  const service = createLanguageService(language, createTypescriptServices(ts), env, {
    typescript: {
      configFileName: undefined,
      sys: ts.sys,
      uriConverter: { asFileName: (uri: URI) => uri.fsPath, asUri },
      ...createLanguageServiceHost(ts, ts.sys, language, asUri, projectHost),
    },
  })

  const unchanged = (path: string, text: string): boolean =>
    pipe(
      Option.fromNullishOr(roots.get(path)),
      Option.match({
        onNone: () => false,
        onSome: (snapshot) => snapshot.getText(0, snapshot.getLength()) === text,
      }),
    )

  const sync = (
    listed: ReadonlyArray<ProductFile>,
    others: ReadonlyArray<ProductFile> = [],
  ): void => {
    const files = [...listed, ...others]
    const present = new Set(Array.map(files, (file) => file.path))
    const stale = Array.filter(Array.fromIterable(roots.keys()), (path) => !present.has(path))
    const fresh = Array.filter(files, (file) => !unchanged(file.path, file.text))
    const listing = new Set(Array.map(listed, (file) => file.path))
    const relisted =
      listing.size !== fileNames.size ||
      Array.some(Array.fromIterable(listing), (path) => !fileNames.has(path))
    if (stale.length === 0 && fresh.length === 0 && !relisted) return
    stale.forEach((path) => {
      roots.delete(path)
      language.scripts.delete(asUri(path))
    })
    fresh.forEach((file) => {
      const snapshot = ts.ScriptSnapshot.fromString(file.text)
      roots.set(file.path, snapshot)
      language.scripts.set(asUri(file.path), snapshot)
    })
    fileNames = listing
    projectVersion++
  }

  return {
    sync,
    diagnostics: (fileName) => service.getDiagnostics(asUri(fileName)),
    hover: (fileName, position) => service.getHover(asUri(fileName), position),
    completions: (fileName, position) => service.getCompletionItems(asUri(fileName), position),
    definition: (fileName, position) => service.getDefinition(asUri(fileName), position),
    references: (fileName, position) =>
      service.getReferences(asUri(fileName), position, {
        includeDeclaration: true,
      }),
    rename: (fileName, position, newName) =>
      service.getRenameEdits(asUri(fileName), position, newName),
    codeActions: (fileName, range, context) =>
      service.getCodeActions(asUri(fileName), range, context),
    resolveCodeAction: (action) => service.resolveCodeAction(action),
    dispose: () => service.dispose(),
  }
}

What the program draws on

The product program is a Volar language service assembled by hand. createLanguage registers the files, createLanguageServiceHost turns them into a TypeScript program, and createLanguageService runs the feature plugins over it. resolveFileLanguageId reads a file's language from its extension, so a .ts file held in memory counts as TypeScript. The URI type names every file, and volar-service-typescript brings the editor features. From Effect come Array, Option, and pipe, which sync uses to reconcile the roots and diskSnapshots to keep the files it reads. Equivalence gives sameTime its comparison of two modified times.

TypeScript
import { Array, Equivalence, Option, pipe } from 'effect'
import {
  createLanguage,
  createLanguageService,
  createUriMap,
  type LanguageServiceEnvironment,
} from '@volar/language-service'
import type {
  CodeAction,
  CodeActionContext,
  CompletionList,
  Diagnostic,
  Hover,
  Location,
  LocationLink,
  Position,
  Range,
  WorkspaceEdit,
} from '@volar/language-service'
import type { Language } from '@volar/language-core'
import { createLanguageServiceHost, resolveFileLanguageId } from '@volar/typescript'
import { create as createTypescriptServices } from 'volar-service-typescript'
import { URI } from 'vscode-uri'
import type { CompilerOptions, IScriptSnapshot } from 'typescript'

type TypeScript = typeof import('typescript')

Testing the files the program reads

A file read from disk must keep one snapshot until the file changes. The case writes a declaration file into a folder of its own and looks it up twice through productLanguage, and both lookups return the same snapshot. It then rewrites the file and moves the file's modified time ten seconds on, since a rewrite within the same millisecond would leave the time where it was. The next lookup returns a new snapshot that holds the new text. Once the folder is deleted, the registry holds nothing for the file.

TypeScript
import * as ts from 'typescript'
import { describe, expect, it } from 'bun:test'
import { mkdtempSync, realpathSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { URI } from 'vscode-uri'
import { productLanguage } from '../src/ProductProgram'

const textOf = (snapshot: ts.IScriptSnapshot | undefined): string =>
  snapshot === undefined ? '' : snapshot.getText(0, snapshot.getLength())

describe('productLanguage — a file read from disk', () => {
  it('keeps its snapshot until the file changes', () => {
    const folder = realpathSync(mkdtempSync(join(tmpdir(), 'loom-product-')))
    const file = join(folder, 'library.d.ts')
    const uri = URI.file(file)
    writeFileSync(file, 'export declare const answer: number\n')
    const language = productLanguage(ts, new Map())
    const first = language.scripts.get(uri)?.snapshot
    const again = language.scripts.get(uri)?.snapshot
    const later = new Date(Date.now() + 10_000)
    writeFileSync(file, 'export declare const answer: string\n')
    utimesSync(file, later, later)
    const changed = language.scripts.get(uri)?.snapshot
    rmSync(folder, { recursive: true })
    const gone = language.scripts.get(uri)
    expect(again).toBe(first)
    expect(changed).not.toBe(first)
    expect(textOf(changed)).toBe('export declare const answer: string\n')
    expect(gone).toBeUndefined()
  })
})

Testing the roots a program lists

A program reads a root it does not list only when a listed root imports it. The case lists a caller and hands over the library it imports as one of the other roots. Neither file is on disk, so the import can resolve only against the library the program holds. The caller assigns the library's number to a string, and the error that follows shows the library's type reached the caller. No error says the module is missing.

TypeScript
import { Array } from 'effect'
import { createProductProgram } from '../src/ProductProgram'

describe('createProductProgram — a root it holds but does not list', () => {
  it('answers an import from a root it lists', async () => {
    const program = createProductProgram(ts, {
      target: ts.ScriptTarget.ES2022,
      module: ts.ModuleKind.ESNext,
      moduleResolution: ts.ModuleResolutionKind.Bundler,
      strict: true,
      skipLibCheck: true,
      noEmit: true,
      allowImportingTsExtensions: true,
    })
    program.sync(
      [
        {
          path: '/held/caller.ts',
          text: "import { answer } from './library.ts'\nexport const wrong: string = answer\n",
        },
      ],
      [{ path: '/held/library.ts', text: 'export const answer = 42\n' }],
    )
    const messages = Array.map(await program.diagnostics('/held/caller.ts'), (d) => d.message)
    program.dispose()
    expect(messages.some((message) => /not assignable to type 'string'/.test(message))).toBe(true)
    expect(messages.some((message) => /Cannot find module/.test(message))).toBe(false)
  })
})
Loom