The language server
The Loom language server is an Effect program. startLanguageServer runs it: it builds the pipeline layers once and captures a warm runtime, because Volar's hooks are synchronous and the language plugin runs the projection on that runtime. On initialize the server finds a TypeScript compiler to check against and collects the plugins of the workspace the editor opened. From then on it serves every loom with the plugins of the loom's own workspace. It collects them the first time it meets a loom there, and again whenever that workspace's configuration changes.
Any editor that speaks the Language Server Protocol can start the server. VS Code starts it through the Loom extension, which loads a thin entry module. Neovim, Helix, Zed, and the JetBrains IDEs start it with the loom lsp command and talk to it over standard input and output. So the server lives in the CLI, the one tool every Loom user already has.
Imports
The server is built from Volar's language-server toolkit over a ManagedRuntime. From the toolkit it takes the connection, the TypeScript project factory, loadTsdkByPath, and the types of a server, a project, a service plugin, and a file the editor reports changed. It imports LoomCompiler, the projector and cache the runtime warms, and LoomFormatter, which formats a file on it, with the DocumentSource, PackageConfig, and LoomConfig they require, and the two halves this module registers, loomLanguagePlugin and loomServicePlugins. From the language store come workspaceOf and workspaceRoot, which find the workspace a path belongs to, and what the server needs to keep a TypeScript in a workspace's store: storeDir, which locates the store, storeTypeScript, which finds a TypeScript there, installTypeScript and its typeScriptRange, which put one there, and standalone, which says whether Loom runs as the standalone executable.
Effect's Schema reads the editor's initialization options, Option carries a compiler or a workspace that may not be there, and Data types the failure when no compiler is. Cache holds what the server builds for each workspace, and Array gathers across them. Node's createRequire finds an installed typescript package the way Node itself would. join builds the path a search starts from, and dirname trims a file's path to the directory that holds it. URI types the address Volar names a document by.
import { Array, Cache, Data, Effect, Layer, ManagedRuntime, Option, Schema, pipe } from 'effect'
import {
createConnection,
createServer,
createTypeScriptProject,
type FileEvent,
type LanguageServer,
type LanguageServerProject,
type LanguageServicePlugin,
loadTsdkByPath,
} from '@volar/language-server/node'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { URI } from 'vscode-uri'
import { LoomCompiler, DocumentSource } from './LoomCompiler'
import { LoomFormatter } from './LoomFormatter'
import { PackageConfig } from './PackageConfig'
import { LoomConfig } from '@athrio/loom-config/LoomConfig'
import {
installTypeScript,
standalone,
storeDir,
storeTypeScript,
typeScriptRange,
workspaceOf,
workspaceRoot,
} from '@athrio/loom-lang-services/LoomStore'
import { loomLanguagePlugin, loomServicePlugins } from './LoomLanguagePlugin'A TypeScript to check against
The server type-checks every TypeScript section against a real compiler, so it needs one before it can start. Volar's loadTsdkByPath loads the compiler from a TypeScript SDK directory, which Volar calls the tsdk: the lib directory of a typescript package, where typescript.js sits beside the standard library's declaration files. The server's first task is to find that directory.
VS Code supplies it. The Loom extension sends the path of the TypeScript that VS Code ships, in the typescript.tsdk field of its initialization options. EditorTsdkSchema is the shape of that field, and editorTsdk decodes it from whatever the client sent, yielding nothing when the field is absent or malformed.
const EditorTsdkSchema = Schema.Struct({
typescript: Schema.Struct({ tsdk: Schema.String }),
})
const editorTsdk = (options: unknown): Option.Option<string> =>
Option.map(
Schema.decodeUnknownOption(EditorTsdkSchema)(options),
(decoded) => decoded.typescript.tsdk,
)Every other editor starts loom lsp with no such option, so the server looks for the compiler itself. What it looks for is TypeScript's JavaScript API, the lib/typescript.js file that TypeScript 6 and every earlier release ship. TypeScript 7 is a native program, and its package holds no such file for Volar to load.
installedTsdk resolves typescript/lib/typescript.js the way Node's require would from a given file, walking up its node_modules directories, and returns the directory that holds it. It yields nothing when the walk finds no TypeScript, and nothing when the TypeScript it finds is version 7, so the search moves on to the next place.
const installedTsdk = (from: string): Option.Option<string> =>
Option.map(
Option.liftThrowable(() => createRequire(from).resolve('typescript/lib/typescript.js'))(),
dirname,
)foundTsdk tries four places in order and takes the first that holds a compiler. The editor's own path comes first, so VS Code keeps the TypeScript it ships. The project being edited comes second, so a section is checked by the project's own TypeScript whenever it is one the server can load.
Loom's own install comes third: the directories above the server's module, which hold typescript when Loom is installed beside it. Only the ES-module bundle that loom lsp runs knows its own location. The CommonJS bundles, the VS Code extension's server.js and the loom-language-server bin, compile import.meta to an empty object, so the search finds nothing there, and VS Code never needs it. The standalone executable skips this place, since nothing is installed beside it. Its modules live inside the executable, and a search that starts there would walk up from the working directory instead.
The workspace's store comes last. It holds a TypeScript when Loom has already installed one there, for this server or for loom format.
const besideLoom = (): Option.Option<string> =>
standalone ? Option.none() : installedTsdk(import.meta.url)
const foundTsdk = (options: unknown, root: string): Option.Option<string> =>
pipe(
editorTsdk(options),
Option.orElse(() => installedTsdk(join(root, 'package.json'))),
Option.orElse(besideLoom),
Option.orElse(() => Option.map(storeTypeScript(root), dirname)),
)A project written in another language holds no TypeScript, and nothing sits beside the standalone executable, so outside VS Code all four places can be empty. The server then puts TypeScript 6 into the workspace's store itself, through the store's installTypeScript, and loads it from there. The standalone executable unpacks the copy it carries, with no network at all. The npm package downloads one, which needs the network that once. Either way it happens once per workspace, and every later start finds the copy in the store.
The install needs a workspace to hold the store. A folder that lies in no workspace has no store, and installing one would turn the folder into a workspace behind the person's back, so the server installs nothing there.
When the server finds no compiler and can install none, it refuses to start and says why. MissingTypeScript names the folder it searched and says which TypeScript it can load. It says why no install happened, either because the folder lies in no workspace or because the install failed, and then it gives the ways to supply a compiler. The error travels back to the editor as the answer to initialize, so the person reads the reason instead of watching a server die.
export class MissingTypeScript extends Data.TaggedError('MissingTypeScript')<{
readonly root: string
readonly failedInstall: Option.Option<string>
}> {
get message(): string {
const why = Option.match(this.failedInstall, {
onNone: () =>
`${this.root} lies in no Loom workspace, so there is no store to install one into; run \`loom init\` there`,
onSome: (cause) => `installing one into the workspace's store failed: ${cause}`,
})
return `Loom's language server needs TypeScript 6 or earlier, and found none in ${this.root} or beside Loom. TypeScript 7 has no JavaScript API for it to load. ${why}. You can also install one in the project with \`bun add -d typescript@6\`, or have the editor send its path as \`initializationOptions.typescript.tsdk\`.`
}
}
const fetchedTsdk = (root: string): Effect.Effect<string, MissingTypeScript> =>
Option.match(workspaceOf(root), {
onNone: () => Effect.fail(new MissingTypeScript({ root, failedInstall: Option.none() })),
onSome: (workspace) =>
Effect.logInfo(`installing TypeScript ${typeScriptRange} into ${storeDir(workspace)}`).pipe(
Effect.andThen(installTypeScript(workspace)),
Effect.map(dirname),
Effect.mapError(
(error) =>
new MissingTypeScript({ root, failedInstall: Option.some(String(error.cause)) }),
),
),
})
const tsdkFor = (options: unknown, root: string): Effect.Effect<string, MissingTypeScript> =>
Option.match(foundTsdk(options, root), {
onNone: () => fetchedTsdk(root),
onSome: Effect.succeed,
})Every loom answers to its own workspace
A workspace decides which languages its looms use and where their services come from. Its config.yaml names the active languages, and its store under .loom/services/ holds the packages that serve them. Workspaces can nest. A Git worktree checked out inside a repository carries a .loom/config.yaml of its own. An editor opens one folder, but the looms it shows may belong to several workspaces.
So the server serves each loom with the services of the workspace the loom belongs to, not those of the folder the editor opened. A loom in a nested workspace is checked and formatted with that workspace's languages, at the versions its own store holds. loom format loads the same services for the same loom. Whether the editor opened the outer folder or the nested one makes no difference to the loom.
workspaceFor names the workspace a document belongs to. For a file on disk it is the nearest directory above the file that holds a .loom/config.yaml, the one workspaceOf finds. A document outside every workspace, or one that is not a file on disk, belongs to home. That is the workspace of the folder the editor opened, or the folder itself when it lies in no workspace.
const workspaceFor = (home: string, uri: URI): string =>
uri.scheme === 'file' ? Option.getOrElse(workspaceOf(dirname(uri.fsPath)), () => home) : homeVolar already has a seam for this. For every request, Volar asks a project which language service answers the document. Every language service carries its own list of service plugins, and Volar's TypeScript project builds its language services from the plugins of the server it is set up with. So projectOver builds a TypeScript project for one workspace and sets it up with a copy of the server whose languageServicePlugins are that workspace's. Every language service the project builds then carries the workspace's plugins. The project's looms still project through the Loom language plugin, on the one warm runtime every workspace shares.
type Tsdk = ReturnType<typeof loadTsdkByPath>
const projectOver = (
server: LanguageServer,
tsdk: Tsdk,
plugins: ReadonlyArray<LanguageServicePlugin>,
): LanguageServerProject => {
const project = createTypeScriptProject(tsdk.typescript, tsdk.diagnosticMessages, () => ({
languagePlugins: [loomLanguagePlugin(runtime)],
}))
project.setup({ ...server, languageServicePlugins: [...plugins] })
return project
}workspaceProject is the project the server hands to Volar. Its projects cache holds one TypeScript project per workspace. Its services cache holds the plugins each project is built over. getLanguageService answers a document from the project of the document's workspace. getExistingLanguageServices and reload reach every project the projects cache holds. Volar sets the project up with the server once the editor has initialized. The projects cache already builds every workspace's project with that same server, so setup has one task left. It starts watchConfigs, which keeps both caches in step with each workspace's configuration.
const workspaceProject = (
home: string,
services: Cache.Cache<string, ReadonlyArray<LanguageServicePlugin>>,
projects: Cache.Cache<string, LanguageServerProject>,
): LanguageServerProject => ({
setup: (server) => watchConfigs(server, services, projects),
getLanguageService: (uri) =>
runtime.runPromise(
Cache.get(projects, workspaceFor(home, uri)).pipe(
Effect.flatMap((project) => Effect.promise(async () => project.getLanguageService(uri))),
),
),
getExistingLanguageServices: () =>
runtime.runPromise(
Cache.values(projects).pipe(
Effect.flatMap((all) =>
Effect.forEach(all, (project) =>
Effect.promise(async () => (await project.getExistingLanguageServices()) ?? []),
),
),
Effect.map(Array.flatten),
),
),
reload: () =>
runtime.runSync(
Cache.values(projects).pipe(
Effect.flatMap((all) =>
Effect.forEach(all, (project) => Effect.sync(() => project.reload()), { discard: true }),
),
),
),
})A workspace's configuration can change while the server runs. loom add installs a language's service in the store and then rewrites config.yaml. loom remove takes the service out of the store and rewrites config.yaml too. So a change to config.yaml is the one sign that a workspace's services changed. watchConfigs asks the editor to watch every .loom/config.yaml and listens for the changes the editor reports. reconfiguredWorkspaces reads a batch of changed files and returns the workspaces whose configuration changed, each the folder that holds a changed .loom/ folder.
forgetWorkspace drops what the server built for one workspace. It reloads the workspace's TypeScript project, which disposes the project's language services and the plugins inside them, then removes the workspace from both caches. The next request for one of the workspace's looms collects its plugins afresh, from the configuration and the store as they now stand, and builds a new project over them. Under Node, a service loom add replaced comes back at its new version, because the loader imports each version at an address of its own. reconfigure forgets every workspace a batch of changes names. It then asks Volar to refresh, clearing the diagnostics on screen, so every open loom is checked again with the services it now has. A batch that names no config.yaml, such as a change to a tsconfig.json, forgets nothing and leaves the refresh to Volar's own projects.
const reconfiguredWorkspaces = (changes: ReadonlyArray<FileEvent>): ReadonlyArray<string> =>
pipe(
changes,
Array.filter((change) => change.uri.endsWith('/.loom/config.yaml')),
Array.map((change) => dirname(dirname(fileURLToPath(change.uri)))),
Array.dedupe,
)
const forgetWorkspace = (
services: Cache.Cache<string, ReadonlyArray<LanguageServicePlugin>>,
projects: Cache.Cache<string, LanguageServerProject>,
workspace: string,
): Effect.Effect<void> =>
Cache.getSuccess(projects, workspace).pipe(
Effect.flatMap(
Option.match({
onNone: () => Effect.void,
onSome: (project) => Effect.sync(() => project.reload()),
}),
),
Effect.andThen(Cache.invalidate(projects, workspace)),
Effect.andThen(Cache.invalidate(services, workspace)),
)
const reconfigure = (
server: LanguageServer,
services: Cache.Cache<string, ReadonlyArray<LanguageServicePlugin>>,
projects: Cache.Cache<string, LanguageServerProject>,
workspaces: ReadonlyArray<string>,
): Effect.Effect<void> =>
workspaces.length === 0
? Effect.void
: Effect.forEach(workspaces, (workspace) => forgetWorkspace(services, projects, workspace), {
discard: true,
}).pipe(Effect.andThen(Effect.sync(() => void server.languageFeatures.requestRefresh(true))))
const watchConfigs = (
server: LanguageServer,
services: Cache.Cache<string, ReadonlyArray<LanguageServicePlugin>>,
projects: Cache.Cache<string, LanguageServerProject>,
): void => {
void server.fileWatcher.watchFiles(['**/.loom/config.yaml'])
server.fileWatcher.onDidChangeWatchedFiles(({ changes }) =>
runtime.runSync(reconfigure(server, services, projects, reconfiguredWorkspaces(changes))),
)
}One limit remains, and it concerns capabilities. The editor learns the server's capabilities once, from the answer to initialize, and Volar derives that answer from the plugins of home. A nested workspace's plugins receive only the requests those capabilities let the editor send. So do the plugins of a language activated after the server started. The Loom language is active in every workspace, and its TypeScript plugins already claim the common features: diagnostics, hover, completion, navigation, rename, and formatting. So the limit reaches only a feature that no plugin of home claimed at initialize.
The program
A ManagedRuntime, built once from the pipeline layers, warms the services the server runs. The language plugin runs the per-keystroke projection on it, and resolves each file's activated languages from LoomConfig on it too. A format request runs LoomFormatter on it.
The onInitialize handler runs one Effect on that runtime. The program reads the workspace folder the editor opened, falling back to the working directory when the editor names none. It takes home to be the workspace that folder belongs to. It finds the tsdk with tsdkFor and loads the compiler from it, so a TypeScript section checks against a real standard library.
The program then makes two caches, each keyed by a workspace's root. services holds the plugins loomServicePlugins collects for a workspace. projects holds the TypeScript project projectOver builds over those plugins. A cache fills a workspace's entry the first time a request needs it, and fills it once, however many requests ask at the same moment. The program collects the plugins of home right away, because Volar announces the server's capabilities from them. The handler registers workspaceProject, built over both caches, and those plugins with Volar. A MissingTypeScript failure rejects the handler, and the client receives its message as the error of initialize.
On initialized, the server watches every tsconfig.json and jsconfig.json. Editing a package's compiler options disposes its project, and the next request rebuilds it, so the consumer's options take effect without an editor reload.
const runtime = ManagedRuntime.make(
Layer.mergeAll(LoomCompiler.layer, LoomFormatter.layer, LoomConfig.layer).pipe(
Layer.provide(DocumentSource.layer),
Layer.provide(PackageConfig.layer),
Layer.provide(LoomConfig.layer),
),
)
export const startLanguageServer = (): void => {
const connection = createConnection()
const server = createServer(connection)
connection.onInitialize(async (params) => {
const folder = params.workspaceFolders?.[0]?.uri
const root = folder ? fileURLToPath(folder) : process.cwd()
const home = workspaceRoot(root)
const { services, projects, plugins } = await runtime.runPromise(
Effect.gen(function* () {
const path = yield* tsdkFor(params.initializationOptions, root)
const tsdk = yield* Effect.sync(() => loadTsdkByPath(path, params.locale))
const services = yield* Cache.make({
capacity: Infinity,
lookup: (workspace: string) => loomServicePlugins(runtime, tsdk.typescript, workspace),
})
const projects = yield* Cache.make({
capacity: Infinity,
lookup: (workspace: string) =>
Cache.get(services, workspace).pipe(
Effect.flatMap((plugins) => Effect.sync(() => projectOver(server, tsdk, plugins))),
),
})
const plugins = yield* Cache.get(services, home)
return { services, projects, plugins }
}),
)
return server.initialize(params, workspaceProject(home, services, projects), [...plugins])
})
connection.onInitialized(() => {
server.initialized()
void server.fileWatcher.watchFiles(['**/tsconfig.json', '**/jsconfig.json'])
})
connection.onShutdown(server.shutdown)
connection.listen()
}The server module
The server module assembles the imports, the search for a compiler, the projects kept per workspace, and the program, and exports startLanguageServer. It runs nothing on import — a caller decides when to start the server, so the same module serves both an editor that spawns it and the loom lsp command that calls it in-process.
The run-on-import entry
This thin entry starts the server the moment it loads. The VS Code extension bundles it as server.js, and the loom-language-server bin runs it — both expect loading the module to start the server.
import { startLanguageServer } from './LoomServer'
startLanguageServer()Testing a nested workspace
The cases build two workspaces, one nested in the other, the way a Git worktree sits inside the repository it was checked out from. A workspace's store holds a stand-in for the TypeScript service rather than the real one, so the cases need no build and no network. The outer stand-in offers no format. The nested one formats by squeezing every double space to one. Both workspaces hold the same loom, whose code is spaced wide.
A stand-in reads Effect from the runtime the host lends, under runtimeKey, as a published service does. It declares the version serviceVersion, so the loader loads it without a warning. install puts a stand-in in a workspace's store, configure writes the workspace's config.yaml, and loomAt writes its loom. workspaceAt does all three, and it fills the store before it writes the configuration, in the order loom add follows.
const lines = (...rows: ReadonlyArray<string>): string => `${rows.join('\n')}\n`
const loomOf = (code: string): string =>
lines('---', 'Language: TypeScript', '---', '', '# Answer', '', '=>', '', code)
const standIn = (formats: boolean): string =>
lines(
`const { Effect } = globalThis.${runtimeKey}.modules.effect`,
'export default {',
" id: 'typescript',",
" displayName: 'TypeScript',",
" extensions: ['.ts'],",
' plugins: () => Effect.succeed([]),',
...(formats
? [" format: (request) => Effect.succeed(request.code.replaceAll(' ', ' ')),"]
: []),
'}',
)
const activated = lines('languages:', ' typescript: {}')
const inactive = lines('languages: {}')
const install = (root: string, service: string): void => {
const store = join(root, '.loom', 'services', 'node_modules', servicePackage('typescript'))
mkdirSync(store, { recursive: true })
writeFileSync(
join(store, 'package.json'),
JSON.stringify({
name: servicePackage('typescript'),
version: serviceVersion,
type: 'module',
main: 'index.js',
}),
)
writeFileSync(join(store, 'index.js'), service)
}
const configure = (root: string, config: string): void => {
mkdirSync(join(root, '.loom'), { recursive: true })
writeFileSync(join(root, '.loom', 'config.yaml'), config)
}
const loomAt = (root: string): string => {
mkdirSync(join(root, 'corpus'), { recursive: true })
const loom = join(root, 'corpus', 'answer.loom')
writeFileSync(loom, loomOf('const answer = 42'))
return loom
}
const workspaceAt = (root: string, service: string): string => {
install(root, service)
configure(root, activated)
return loomAt(root)
}The cases start the server from source the way an editor does. The server runs as a process of its own and speaks the Language Server Protocol over its standard input and output. The client hands it the TypeScript this repository installs, as VS Code hands over its own. It leaves LOOM_HOME out of the server's environment, since that variable would point every workspace at one store.
The client declares that it watches files for the server, as an editor does. It does not watch any itself. A case reports a changed file through changed, the way an editor's watcher would. The client answers every request the server makes of it with nothing, whether the server asks for the editor's settings or asks it to watch a file. open shows the server a loom, format asks for the edits that format it, and texts reads the text out of those edits.
const serverEntry = fileURLToPath(new URL('../src/index.ts', import.meta.url))
const tsdk = dirname(createRequire(import.meta.url).resolve('typescript/lib/typescript.js'))
const editorAt = async (folder: string) => {
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== 'LOOM_HOME'))
const child = spawn(process.execPath, [serverEntry, '--stdio'], {
env,
stdio: ['pipe', 'pipe', 'ignore'],
})
const connection = createMessageConnection(
new StreamMessageReader(child.stdout),
new StreamMessageWriter(child.stdin),
)
connection.onRequest(() => null)
connection.listen()
await connection.sendRequest('initialize', {
processId: process.pid,
rootUri: pathToFileURL(folder).href,
workspaceFolders: [{ uri: pathToFileURL(folder).href, name: 'outer' }],
capabilities: { workspace: { didChangeWatchedFiles: { dynamicRegistration: true } } },
initializationOptions: { typescript: { tsdk } },
})
await connection.sendNotification('initialized', {})
const open = (path: string): Promise<void> =>
connection.sendNotification('textDocument/didOpen', {
textDocument: {
uri: pathToFileURL(path).href,
languageId: 'loom',
version: 1,
text: readFileSync(path, 'utf8'),
},
})
const format = async (path: string): Promise<ReadonlyArray<TextEdit>> =>
(await connection.sendRequest<TextEdit[] | null>('textDocument/formatting', {
textDocument: { uri: pathToFileURL(path).href },
options: { tabSize: 2, insertSpaces: true },
})) ?? []
const changed = (path: string): Promise<void> =>
connection.sendNotification('workspace/didChangeWatchedFiles', {
changes: [{ uri: pathToFileURL(path).href, type: FileChangeType.Changed }],
})
const close = (): void => {
connection.dispose()
child.kill()
}
return { open, format, changed, close }
}
const texts = (edits: ReadonlyArray<TextEdit>): ReadonlyArray<string> =>
Array.map(edits, (edit) => edit.newText)In the first case the editor opens the outer folder, as it opens a repository, and asks the server to format each loom. The outer loom must come back unchanged, since its workspace offers no formatter for TypeScript. The nested loom must come back with its code squeezed, which only the nested workspace's service can do.
The second case activates a language while the server runs. The nested workspace starts with no language active and nothing in its store, so its loom formats to itself. The case then installs the formatting stand-in and activates TypeScript, as loom add would, and reports the changed config.yaml. The next format must squeeze the loom's code, with no restart in between.
describe('LoomServer — a loom in a nested workspace', () => {
it('is formatted by the services of its own workspace', async () => {
const outer = realpathSync(mkdtempSync(join(tmpdir(), 'loom-server-')))
const outerLoom = workspaceAt(outer, standIn(false))
const nestedLoom = workspaceAt(join(outer, 'nested'), standIn(true))
const editor = await editorAt(outer)
try {
await editor.open(outerLoom)
await editor.open(nestedLoom)
expect(await editor.format(outerLoom)).toEqual([])
expect(texts(await editor.format(nestedLoom))).toEqual([loomOf('const answer = 42')])
} finally {
editor.close()
}
}, 30_000)
it('is served by a language its workspace activates while the server runs', async () => {
const outer = realpathSync(mkdtempSync(join(tmpdir(), 'loom-server-')))
workspaceAt(outer, standIn(false))
const nested = join(outer, 'nested')
configure(nested, inactive)
const nestedLoom = loomAt(nested)
const editor = await editorAt(outer)
try {
await editor.open(nestedLoom)
expect(await editor.format(nestedLoom)).toEqual([])
install(nested, standIn(true))
configure(nested, activated)
await editor.changed(join(nested, '.loom', 'config.yaml'))
expect(texts(await editor.format(nestedLoom))).toEqual([loomOf('const answer = 42')])
} finally {
editor.close()
}
}, 30_000)
})What the cases draw on
The cases draw on Bun's test runner, and on Node to make the workspaces, start the server, and turn paths into the file: addresses the protocol names documents by. Volar's language-server package carries the client half of the protocol too: createMessageConnection and the stream reader and writer it speaks through, the TextEdit a format answers with, and the FileChangeType a reported change carries. The language store gives servicePackage and serviceVersion, which name and version the stand-in. The host runtime gives runtimeKey, where the stand-in finds Effect. Effect's Array reads the text out of the edits a format returns.
import { describe, expect, it } from 'bun:test'
import {
createMessageConnection,
FileChangeType,
StreamMessageReader,
StreamMessageWriter,
type TextEdit,
} from '@volar/language-server/node'
import { Array } from 'effect'
import { spawn } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { servicePackage, serviceVersion } from '@athrio/loom-lang-services/LoomStore'
import { runtimeKey } from '@athrio/loom-lang-services/Runtime'