SourceLanguages as packagesThe Loom language-service contract

Part VII, Chapter 1

Part VII: Languages as packages

Chapter 1: The Loom language-service contract

Package: packages/loom-lang-services/src/LanguageService.ts

Language: TypeScript

Raw source

The Loom language-service contract

@athrio/loom-lang-services holds the contract every Loom language service satisfies — the LanguageService — together with the loader that finds them and the one service built into the server. A LanguageService gives one language its editor support: its diagnostics, hover, and completion.

Loom is the one language built in. LoomLanguage serves the frame, and every .loom has a frame, so its service is always present and needs no package. Every other language — TypeScript first among them — ships as a @athrio/loom-service-<id> package the project installs and the loader resolves by id. This module defines the contract both kinds satisfy; LanguageLoader turns an activated id into the service that serves it.

Architecture

Each service gives one embedded language its editor support — diagnostics, hover, completion — and every service, built in or loaded, is the same LanguageService. Loom is the one built-in: LoomLanguage lives here as a module, with no package. Every other language is loaded: each ships in its own @athrio/loom-service-<id> package, installed on activation and imported by id. TypeScript is the first such package, not a privileged built-in; it goes through the loader like any other language.

The @athrio/loom-lang language server reads the active ids from loom.config.json, and LanguageLoader resolves each one to its service — Loom from this module, the rest from their packages. The server indexes the resolved set in the LanguageRegistry, keyed by id for a section's declared language and by extension for a tangle path, so finding a language is a lookup, never a branch.

Editor features take a different route. The host runs each service's plugins Effect to collect its Volar plugins and registers them; Volar then dispatches every request to the right language by its languageId. Loom declares the languages; Volar routes them.

  loom activate ──►  loom.config.json   { languages: [typescript, css, go] }
                            │ read on initialize
                            ▼
  @athrio/loom-lang — the language server

    LanguageLoader resolves each active id to a LanguageService:

      built-in, in the server:   loom — the frame
      loaded, one package each:  everything else, by id —
        typescript → @athrio/loom-service-typescript → tsc
        go         → @athrio/loom-service-go         → gopls
        rust       → @athrio/loom-service-rust       → rust-analyzer
        (third-party packages too)

    for each active id: take loom, or import @athrio/loom-service-<id>;
    run service.plugins(ctx) and register its plugins under the id — a
    lookup, never a per-language branch.

                            │ both kinds satisfy one shape
                            ▼

  @athrio/loom-lang-services
    LanguageService { id, displayName, extensions, plugins(ctx) → Effect<Plugin[]>,
                      format?(request) → Effect<string> }
    isLanguageService · defineLanguageService
    LanguageLoader.loadActive(ids, root)

  Routing: a section tagged id="css" → the css service → hover / diagnostics /
  completion → mapped back to its span in the .loom.

The host hands a service its settings and runs plugins, providing the capabilities the Effect requires — today, the TypeScript SDK. An in-process service builds its plugins in memory; an external one spawns its binary and bridges it — both inside the Effect, so the collect-and-register pass is the same for every language.

Imports

The contract names Volar's LanguageServicePlugin, the unit of editor behavior; from Effect, Data for the tagged error and Effect itself, since a service builds its plugins in an Effect and a capability is an Effect service; and from @athrio/loom-ast, the Diagnostic the frame query returns and the SemanticToken colour it tags each of a file's tokens with.

TypeScript
import type { LanguageServicePlugin } from '@volar/language-service'
import { Context, Data, Effect, Layer } from 'effect'
import { type Diagnostic } from '@athrio/loom-ast/LoomNode'
import type { SemanticToken } from '@athrio/loom-ast/LoomSymbol'

A service

A LanguageService is one language's entry: the section id it serves, a displayName for the loom activate menu, its file extensions, and plugins — the Effect that builds its Volar plugins. That Effect may fail with a ServiceError — an external binary that will not start — which the host reports without bringing the other languages down. A service may draw on the host's capabilities (HostCapabilities): the Loom and TypeScript services take the TypeScript SDK, a CSS service takes nothing. A tangle path's extension names its language — a section that tangles to main.ts turns on the TypeScript service.

TypeScript
export class ServiceError extends Data.TaggedError('ServiceError')<{
  readonly service: string
  readonly reason: string
}> {}

export interface LanguageService {
  readonly id: string
  readonly displayName: string
  readonly extensions: ReadonlyArray<string>
  readonly plugins: (
    context: LanguageServiceContext,
  ) => Effect.Effect<ReadonlyArray<LanguageServicePlugin>, ServiceError, HostCapabilities>
  readonly format?: (request: FormatRequest) => Effect.Effect<string, ServiceError>
}

Formatting a language

A service may also format its language. Its format takes a FormatRequest and answers with the code laid out in the language's own style. The request carries the composed code of one composition root, the path that root tangles to, so a formatter can read the project's own settings for that file, and the width the loom's lines keep to, which a formatter uses unless the project's settings name their own. A formatter that cannot read the code, because it does not parse, fails with a ServiceError, and the formatter that called it leaves that root as it was.

The field is optional. A language whose service offers no format keeps its sections exactly as the author wrote them.

TypeScript
export interface FormatRequest {
  readonly code: string
  readonly path: string
  readonly width: number
}

Host capabilities

A service may draw on capabilities the host provides, taken through the Effect environment so each service asks only for what it needs. There are three.

TypescriptSdk is the TypeScript compiler itself. It carries no default — the host supplies the editor's own instance (via Volar's loadTsdkByPath), so analysis matches the user's project. The Loom and TypeScript services both take it; create needs the compiler to check the frame and the TypeScript product. A CSS or Go service never asks.

FrameQuery is the host's read into the frame — the questions Loom can answer about a file from the composition it built. Its first reader, diagnostics, returns that file's Loom diagnostics, the grammatical and semantic health in Loom's own terms, so the frame service can surface them. Four more readers answer navigation and rename. definition gives the heading an anchor at an offset resolves to, and references gives every anchor that names the section under an offset. rename gives the title and anchor-name spans a rename rewrites together, and renameRange gives the one span the rename box covers. These answer in FrameLocations — a path and a range in the editor's own line-and-character coordinates. Another reader, semanticTokens, gives each of a file's tokens its colour: a FrameToken pairing a range with the SemanticToken kind LoomSymbol assigns, so the editor highlights the source. The last, format, answers with a file's text formatted, its prose paragraphs each on one line and its code laid out by each language's formatter. It takes the text itself, the open buffer the editor holds, rather than reading the warm corpus, so a format never works from a stale copy, and it answers with a promise, because a language's formatter may take its time. The host builds the query over the warm corpus, and like TypescriptSdk it carries no default. Only the Loom service takes it.

Composition is the host's read of the de re as a filesystem: every composition root in a file's corpus, each as the resolved code it tangles to, named by the path it tangles to. A product service builds its analyzer's program over these, so a cross-file import resolves to another section's live composition rather than its last-tangled output on disk. Each root also carries its heading, the FrameLocation of the section that tangles it, so go-to on that import lands on the section in the .loom rather than the transcluded code the composed file opens with. Like the others it carries no default; the host builds it over the warm corpus. The product service takes it.

A product service still asks nothing about its own file's sections: the editor hands it each Product directly — a resolved root, its members folded in — so the file's own roots travel with the data, never a query. Composition adds only the rest of the corpus, the roots a file imports across.

HostCapabilities is the union of these capabilities — the environment a service's plugins Effect may require.

TypeScript
export class TypescriptSdk extends Context.Service<TypescriptSdk>()('TypescriptSdk', {
  make: Effect.die('TypescriptSdk must be provided by the host') as Effect.Effect<
    typeof import('typescript')
  >,
}) {
  static readonly layer = Layer.effect(this, this.make)
}

export interface FrameLocation {
  readonly path: string
  readonly range: {
    readonly start: { readonly line: number; readonly character: number }
    readonly end: { readonly line: number; readonly character: number }
  }
}

export interface FrameToken {
  readonly range: FrameLocation['range']
  readonly type: SemanticToken
}

export interface FrameQueryApi {
  readonly diagnostics: (path: string) => ReadonlyArray<Diagnostic>
  readonly definition: (path: string, offset: number) => FrameLocation | undefined
  readonly references: (path: string, offset: number) => ReadonlyArray<FrameLocation>
  readonly rename: (path: string, offset: number) => ReadonlyArray<FrameLocation>
  readonly renameRange: (path: string, offset: number) => FrameLocation | undefined
  readonly navigationRange: (path: string, offset: number) => FrameLocation | undefined
  readonly semanticTokens: (path: string) => ReadonlyArray<FrameToken>
  readonly format: (path: string, text: string) => Promise<string>
}

export class FrameQuery extends Context.Service<FrameQuery>()('FrameQuery', {
  make: Effect.die('FrameQuery must be provided by the host') as Effect.Effect<FrameQueryApi>,
}) {
  static readonly layer = Layer.effect(this, this.make)
}

export interface ComposedFile {
  readonly path: string
  readonly content: string
  readonly loomPath: string
  readonly rootId: string
  readonly heading: FrameLocation
}

export interface CompositionApi {
  readonly rootsFor: (path: string) => ReadonlyArray<ComposedFile>
}

export class Composition extends Context.Service<Composition>()('Composition', {
  make: Effect.die('Composition must be provided by the host') as Effect.Effect<CompositionApi>,
}) {
  static readonly layer = Layer.effect(this, this.make)
}

export type HostCapabilities = TypescriptSdk | FrameQuery | Composition

The service settings

The LanguageServiceContext is the one input the host hands every service: settings, this language's own entry in loom.config.json. The service decodes it — format options for an in-process service, the binary command for an external one. Capabilities like the TypeScript SDK do not come through the context; they come through the environment, so the context keeps the same shape for every language.

TypeScript
export interface LanguageServiceContext {
  readonly settings: Record<string, unknown>
}

Declaring a service

Each service declares itself with defineLanguageService, which checks the value against the contract and returns it unchanged. A built-in module and a package's default export pass through the same check, so the two are the same kind of value however they reach the server. isLanguageService is that check, and the loader reuses it on a package it imports — there the value is unknown until proven. The guard holds when the value carries a string id and displayName, an array of extensions, and a plugins function.

TypeScript
export const isLanguageService = (value: unknown): value is LanguageService =>
  typeof value === 'object' &&
  value !== null &&
  typeof (value as LanguageService).id === 'string' &&
  typeof (value as LanguageService).displayName === 'string' &&
  Array.isArray((value as LanguageService).extensions) &&
  typeof (value as LanguageService).plugins === 'function'

export const defineLanguageService = (service: LanguageService): LanguageService => {
  if (!isLanguageService(service)) {
    throw new Error(
      'defineLanguageService: the value does not satisfy the LanguageService contract',
    )
  }
  return service
}
Loom