6. phaze-language-tools
1. phaze-tsplugin ← editor (TS Language Service)2. phaze-compile ← build-time AST rewriting3. phaze-vite ← island HMR + chunking helpers4. phaze-astro ← Astro integration (island model)5. phaze-cloudflare ← native Cloudflare Workers adapter (whole-page)
─── Editor stack ────────────────────────────────────────────────────6. phaze-language-tools ← Volar LSP backend (.phaze → virtual .tsx) ← you are here7. phaze-vscode ← VSCode extension (grammar + LSP client)8. phaze-glow ← VSCode theme + halo runtime@madenowhere/phaze-language-tools is the language services engine for .phaze files — the shared backend every editor integration consumes when it wants hover types, completion, go-to-definition, find-references, and diagnostics inside .phaze source. It does not ship a UI: it’s an npm package with a Volar LanguagePlugin (programmatic API) plus a stdio language server (LSP-over-stdio binary). phaze-vscode (#7) consumes it as an LSP backend; future Cursor / JetBrains / Sublime adapters consume the same npm package; the planned phaze check CLI (Phase 4) consumes it headlessly.
The same pattern Astro’s editor stack uses (@astrojs/language-server + @astrojs/ts-plugin), the same Vue uses (@vue/language-core + @vue/language-server), the same Svelte uses (svelte2tsx + svelte-language-server). All of them stand on Volar — a framework for building language tools that bridge a source file to a virtual TS file.
What “language tools” means here
Section titled “What “language tools” means here”A .phaze file isn’t TypeScript — it has ---page / ---data / ---state fences (page mode) or fenceless component-body code (component mode), and phaze-compile’s format transform produces the actual .tsx the bundler sees. So an off-the-shelf TS language server has nothing to bind against: it doesn’t recognise the extension, can’t parse the fences, doesn’t know how phaze-compile synthesizes the module.
phaze-language-tools fills that gap by inserting a translation layer between the IDE’s TS service and the .phaze source:
What the editor sees .phaze file open in tab │ ▼ ┌──────────────────────────────────────────────┐ │ Volar's LanguagePlugin dispatch │ │ (LSP / TS-plugin / CLI — same hook) │ └──────────────────────────────────────────────┘ │ ▼ PhazeVirtualCode ┌──────────────────────────────────────────────┐ │ phaze-compile's phazeFormatTransform │ │ → synthetic .tsx body │ │ → v3 sourcemap (.tsx ↔ .phaze positions) │ │ + VLQ-decode → Volar CodeMapping[] │ └──────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────┐ │ TypeScript Language Service │ │ (drives against the synthetic .tsx file) │ │ → hover types, completion, diagnostics, │ │ go-to-def, find-refs │ └──────────────────────────────────────────────┘ │ ▼ responses translated ┌──────────────────────────────────────────────┐ │ Volar routes responses through │ │ CodeMapping[] back to .phaze positions │ └──────────────────────────────────────────────┘ │ ▼ Editor displays the result at the user's actual .phaze cursorEverything that makes a .phaze file behave like a .tsx file in the editor sits at one of those two boxes: the virtual-code synth, or the response-mapping pass.
Two exports
Section titled “Two exports”The package exposes two surfaces — one for in-process integrations, one for stdio launchers.
import { getPhazeLanguagePlugin, // the Volar LanguagePlugin factory PhazeVirtualCode, // the VirtualCode class (re-exported for embedders)} from '@madenowhere/phaze-language-tools'// package.json bin entry"bin": { "phaze-language-server": "./bin/phaze-language-server.js"}- Programmatic —
getPhazeLanguagePlugin()returns a VolarLanguagePlugin<URI, PhazeVirtualCode>. You hand it tocreateTypeScriptProject(Volar’s project factory) or to any other Volar host (vue-tsc / astro check / a JetBrains plugin’s embedder). This is whatphaze check(Phase 4) and future non-LSP integrations will consume. - Stdio binary —
phaze-language-serveris an executable Node entry that wires the same plugin into@volar/language-server/node’screateConnection+createServerpipeline, ready to be spawned by any LSP client (VSCode’svscode-languageclient, neovim’snvim-lspconfig, JetBrains’s LSP support, …). phaze-vscode launches it viarequire.resolve('@madenowhere/phaze-language-tools/bin/phaze-language-server.js')— the resolver walks workspace symlinks, hoisting, and pnpm isolation uniformly.
Both surfaces share the same LanguagePlugin — every editor consumer gets the same hover types, the same completion, the same diagnostics. There’s no “VSCode flavor” vs “JetBrains flavor” of the language services.
The LanguagePlugin
Section titled “The LanguagePlugin”The LanguagePlugin object Volar consumes is small — three hooks and a typescript: sub-object that tells the TS service how to treat .phaze files.
{ getLanguageId(uri) { /* '.phaze' → 'phaze', else undefined */ }, createVirtualCode(uri, …) { /* new PhazeVirtualCode(uri.fsPath, snapshot) */ }, updateVirtualCode(uri, …) { /* re-synth on edit */ }, typescript: { extraFileExtensions: [{ extension: 'phaze', isMixedContent: false, scriptKind: ts.ScriptKind.Deferred }], getServiceScript(root) { return { code: root, extension: '.tsx', scriptKind: ts.ScriptKind.TSX } }, },}The interesting hook is createVirtualCode — Volar calls it the first time it sees a .phaze URI; we instantiate PhazeVirtualCode, which:
- Reads the
.phazesource from thets.IScriptSnapshotVolar passes. - Runs
phazeFormatTransform(source, { filename })— the same parser + emit pipeline phaze-compile uses at build time. The output is{ code: <synthetic .tsx>, map: <v3 sourcemap> }. - Wraps the emitted
.tsxstring in a freshIScriptSnapshotso the TS service treats it like a normal file. - Decodes the v3
map.mappings(VLQ) via@jridgewell/sourcemap-codecand walks each line’s segments into Volar’s offset-basedCodeMapping[]format — pre-computing source/generated line-start offsets so each segment’s(line, col)pair converts to a flat byte offset. Every mapping getsdata: ALL_FEATURESfor now (verification + completion + semantic + navigation + structure + format all enabled).
getServiceScript declares the virtual code’s “physical” file extension and ScriptKind to TypeScript — the TS service sees a .tsx snapshot when it asks “what’s this file?”, which means hover / completion / definition all run their TSX-shaped code paths.
ScriptKind.Deferred in extraFileExtensions tells TS to defer its language-kind selection to our virtual code’s languageId field ('typescriptreact') — that’s how the TS service knows to parse the virtual buffer as TSX rather than picking a default off the extension.
The import type {} from '@volar/typescript' line in language-plugin.ts exists to trigger the module augmentation that adds the typescript?: field to LanguagePlugin — without that import TS doesn’t see the field and rejects the plugin object.
The v3 sourcemap → Volar mapping conversion
Section titled “The v3 sourcemap → Volar mapping conversion”phaze-compile emits a standard v3 sourcemap from synthetic .tsx to .phaze positions (see phaze-compile → format transform for how the parser + emit track source lines and the Mapper builds the map). v3’s mappings field is a VLQ-encoded string, line-grouped by ; with each line a comma-separated list of segments — [genCol, srcIdx, srcLine, srcCol, nameIdx?]. Volar’s mapping format is offset-based:
interface CodeMapping { sourceOffsets: number[] generatedOffsets: number[] lengths: number[] data: CodeInformation}v3ToVolarMappings is the bridge:
decodeVlq(map.mappings)→number[][][](per-line, per-segment, per-field).- Pre-compute
sourceLineStartsandgeneratedLineStartsonce via a one-pass scan over each text for\npositions. - For each
[genCol, _srcIdx, srcLine, srcCol]segment, computesrcOffset = sourceLineStarts[srcLine] + srcColandgenOffset = generatedLineStarts[genLineIdx] + genCol. - Emit one
CodeMappingper segment, withlengths: [<remaining line length>]so Volar’s range queries work line-by-line.
The conversion runs once per .phaze open and per edit (Volar re-invokes updateVirtualCode on every snapshot change, and PhazeVirtualCode’s constructor re-runs the transform + decode). For typical components (50–200 lines) this is sub-millisecond.
The language server
Section titled “The language server”The stdio server is ~30 lines:
const connection = createConnection()const server = createServer(connection)
connection.onInitialize(async (params) => { const tsdkPath = params.initializationOptions?.typescript?.tsdk const tsdk = loadTsdkByPath(tsdkPath, params.locale)
return server.initialize( params, createTypeScriptProject( tsdk.typescript, tsdk.diagnosticMessages, () => ({ languagePlugins: [getPhazeLanguagePlugin()] }), ), [ ...createTypeScriptService(tsdk.typescript), createTypeScriptTwoslashService(tsdk.typescript), ], )})connection.onInitialized(server.initialized)connection.onShutdown(server.shutdown)connection.listen()The pattern mirrors @astrojs/language-server’s server.ts and @vue/language-server’s bin entry. Three pieces matter:
tsdkis the path to the workspace’s TypeScript SDK (<workspaceRoot>/node_modules/typescript/lib). The LSP loadstscfrom there instead of bundling its own copy, so type-checking matches whatever tsc version your project depends on — same as Vue and Astro do.createTypeScriptProjectwires Volar’s project model with ourLanguagePlugin. Volar maintains the virtual-code tree across edits; we just register the.phazeplugin.- Two services plug into the project:
volar-service-typescript(hover, completion, go-to-def, find-refs, semantic diagnostics — routed through ourCodeMapping[]back to.phazecoordinates) andvolar-service-typescript-twoslash-queries(twoslash query support — used by some MDX-style docs tooling; cheap to include).
@global auto-import in the LSP (Phase E)
Section titled “@global auto-import in the LSP (Phase E)”The LSP shares the same GlobalRegistry class that phaze-compile uses at build time — one class, separate instances per process. The flow is symmetric to the build path:
- Init-time workspace scan —
getPhazeLanguagePlugin({ workspaceRoot, appPhazePath })walks the workspace’s.phazefiles synchronously (skipsnode_modules/dist/.gitetc.), runsphazeFormatTransform’s parse-only path on each, and registers every@globaldeclaration. The scan blocks the LSP’sonInitializecallback for a few tens of milliseconds — a deliberate trade for order-independence (consumers can be opened first; the registry already knows about them). - Per-file registration — every
PhazeVirtualCodeconstruction also callsregistry.registerDeclarations(fileName, globalDecls). This handles new files created mid-session AND keeps the registry in lockstep with the latest edit. - Auto-import prefix injection — for any
.phazefile that ISN’Tapp.phaze,buildAutoImportPrefixscans the synthesized.tsxwith a fast identifier regex, intersects with the registry, and groups by source file. The result is prepended to the virtual code asimport { X, Y } from '<rel>.phaze';lines. - CodeMapping offset shift —
shiftGeneratedOffsetsadjusts every existing mapping’sgeneratedOffsetsby the prepended prefix’s byte length. Hover, go-to-def, and find-refs continue to resolve correctly through the prepended imports.
The server derives workspaceRoot from the LSP’s params.workspaceFolders[0]?.uri.fsPath (via vscode-uri’s URI.parse). Projects with a non-default shell location pass initializationOptions.phaze.appPhazePath to override the conventional src/app.phaze.
Build-vs-LSP scope-analysis trade-off
Section titled “Build-vs-LSP scope-analysis trade-off”The build path (phaze-compile’s babel-plugin) uses babel’s ip.scope.hasBinding(name) for proper JS scope analysis — a local declaration shadowing a @global name correctly skips auto-import. The LSP path uses a fast string-level regex scan instead (no babel boot per file edit) — so a shadowing local would still get the import injected. TypeScript’s scope rules handle the shadowing correctly downstream: the auto-import becomes an unused import in that edge case, not incorrect behavior.
Why a separate package
Section titled “Why a separate package”phaze-language-tools sits at a different layer from every preceding tool in the pipeline:
- Different host.
#1–#5plug into Babel, esbuild, Rollup, Vite, Astro, Cloudflare’s Vite plugin. phaze-language-tools plugs into Volar — a Volar host (LSP server, TS-plugin embed, headless CLI) instantiates theLanguagePluginand lets Volar drive the TS service. - Different runtime.
#1–#5run inside the build process (per-file transforms, per-build chunking, per-deploy compilation). phaze-language-tools runs inside the editor’s language-server process — continuously, against live in-memory buffers, every keystroke. - Different deps. It pulls in
@volar/language-core,@volar/language-server,@volar/language-service,@volar/typescript,volar-service-typescript,volar-service-typescript-twoslash-queries,@jridgewell/sourcemap-codec,vscode-uri. None of those belong in a build-time package; none of#1–#5’s deps belong in a language-server package. - Different consumers. phaze-vscode launches it as an LSP server. Future non-VSCode editor integrations consume the npm package directly.
phaze check(Phase 4) embeds theLanguagePluginfor headless type-checking. Bundling these into any of#1–#5would force build-only consumers to install@volar/*they never use.
The package depends on @madenowhere/phaze-compile (workspace) for phazeFormatTransform — the parsing logic is shared. The language services just take the same parsed output the build uses and feed it to a TS service through Volar.
What you don’t get (yet)
Section titled “What you don’t get (yet)”The v1 plugin treats the synthesized .tsx as one root virtual code with line-level mappings. Practical implications:
- Range precision is line-level. Hover / go-to-def land on the right
.phazeline; the exact column inside multi-token lines follows the v3 segments emitted by phaze-compile. No sub-token range narrowing. - Diagnostics fire at the synthesized JSX, then map back. Errors from
useStatetypos or wrong-typedbind:valueshow up in the.phazefile at the right line. Error messages reference TS-compiler terms (not phaze-compile diagnostic codes — those come from phaze-compile at build time). - No semantic highlighting beyond TS. Token colorization on the fence text comes from phaze-vscode’s TextMate grammar, not the LSP. The LSP only contributes semantic highlight inside the TSX body.
- No embedded styles /
---datavalidation yet. v1 treats---datablocks as TS-bound type-data syntax; future versions can split it into a separate embedded code (Volar supports nested virtual codes via theembeddedCodesfield onVirtualCode) for stricter validation.
These are surface-tuning, not architectural — the Volar pipeline already supports finer mappings + embedded codes; v1 just doesn’t use them yet. The plumbing is in place to add them without changing the package shape.
Integration recipes
Section titled “Integration recipes”Use it in VSCode
Section titled “Use it in VSCode”Install phaze-vscode (#7) from the marketplace — it bundles this package and spawns the language server automatically. No config required.
Use it in another LSP-capable editor (Cursor / Sublime / neovim)
Section titled “Use it in another LSP-capable editor (Cursor / Sublime / neovim)”Install the npm package as a workspace dependency, then point your editor’s LSP client at the phaze-language-server bin:
pnpm add -D @madenowhere/phaze-language-toolsLSP client config (the exact shape varies per editor — what matters: stdio transport, point at the bin, pass initializationOptions.typescript.tsdk as the absolute path to <workspaceRoot>/node_modules/typescript/lib):
{ "command": ["node", "./node_modules/@madenowhere/phaze-language-tools/bin/phaze-language-server.js", "--stdio"], "fileTypes": ["phaze"], "initializationOptions": { "typescript": { "tsdk": "./node_modules/typescript/lib" } }}Embed the LanguagePlugin in a custom Volar host
Section titled “Embed the LanguagePlugin in a custom Volar host”import { createTypeScriptProject } from '@volar/language-service'import { getPhazeLanguagePlugin } from '@madenowhere/phaze-language-tools'
const project = createTypeScriptProject(ts, undefined, () => ({ languagePlugins: [getPhazeLanguagePlugin()],}))This is the shape phaze check (Phase 4) will use — drive the project headlessly, walk all .phaze files, collect diagnostics, exit non-zero on errors.
What’s next
Section titled “What’s next”Phase 4 — phaze check — wraps this package as a CLI: walks the project, instantiates the same LanguagePlugin, asks the TS service for diagnostics on every .phaze file, exits non-zero if any are errors. Same model as vue-tsc / astro check / svelte-check. Same single source of truth for what “type-correct phaze code” means across editor + CI.