@pieceful/ravel-language-typescript 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 James Taylor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # `@pieceful/ravel-language-typescript`
2
+
3
+ An in-process native TypeScript/JavaScript language-service bridge for [Ravel](https://github.com/jostylr/ravel)
4
+ virtual documents. It keeps TypeScript project state warm and normalizes native
5
+ results to generated-document URIs and zero-based UTF-16 offset ranges. Ravel's
6
+ routing layer is responsible for translating those ranges back to literate
7
+ source.
8
+
9
+ ## Runtime setup
10
+
11
+ TypeScript is an optional peer dependency so an editor or host may inject the
12
+ compiler API it already owns:
13
+
14
+ ```js
15
+ import { createTypeScriptLanguageBridgeWithApi } from "@pieceful/ravel-language-typescript";
16
+
17
+ const bridge = createTypeScriptLanguageBridgeWithApi(typescript, {
18
+ currentDirectory: workspaceRoot,
19
+ configSearchRoot: workspaceRoot,
20
+ });
21
+ ```
22
+
23
+ The asynchronous `createTypeScriptLanguageBridge()` factory loads the
24
+ `typescript` package when no API is injected. It reports
25
+ `TYPESCRIPT_NOT_AVAILABLE` with an actionable message when neither is
26
+ available.
27
+
28
+ ## Virtual file and project identity
29
+
30
+ Each document must keep a stable `uri` and monotonically increasing `version`.
31
+ For correct relative-import semantics it should also provide its declared
32
+ artifact location in `fileName`, `path`, `artifactPath`, or `outputPath`. A
33
+ host can instead provide `fileNameForDocument(document)`. Projection objects
34
+ need no extra field: a safe relative `artifactId` such as `dist/app.ts` is the
35
+ default declared artifact location. Absolute artifact IDs, URI-like IDs, and
36
+ paths that escape the workspace are rejected and receive an isolated in-memory
37
+ fallback path.
38
+
39
+ The bridge selects a project in this order:
40
+
41
+ 1. `configFileForDocument(document, fileName)`;
42
+ 2. `document.tsconfigPath` or `document.metadata.tsconfigPath`;
43
+ 3. the bridge-level `tsconfigPath`;
44
+ 4. the nearest `tsconfig.json` above the declared artifact path; or
45
+ 5. an inferred project with safe TypeScript/JavaScript analysis defaults.
46
+
47
+ Configuration lookup is confined to `configSearchRoot`, which defaults to
48
+ `currentDirectory`; a relative value is resolved from `currentDirectory`.
49
+ Upward discovery stops at that root. Explicit paths from a document, bridge
50
+ option, or host callback are ignored when their lexical or canonical filesystem
51
+ location is outside the root; lookup then falls back to a confined nearest
52
+ config or an inferred project. This also prevents a config symlink beneath the
53
+ root from selecting a file outside it. A host may widen the root deliberately
54
+ when one trusted TypeScript project spans several workspace folders.
55
+
56
+ `configSearchRoot` confines configuration selection; it is not a complete
57
+ filesystem sandbox. Once a trusted host enables the adapter, TypeScript's
58
+ configured-file processing, project references, standard library, module, and
59
+ declaration resolution use the native filesystem host and may read dependencies
60
+ outside that root. Hosts that need a stronger boundary must provide an explicit
61
+ file-access-root policy or a more isolated adapter; this adapter does not yet
62
+ implement such a filesystem sandbox. Ravel's VS Code host still confines
63
+ locations returned to the editor, but this native read boundary keeps the
64
+ strict M7 security gate open.
65
+
66
+ Configured compiler options, path aliases, JSX mode, project references,
67
+ standard libraries, and declaration packages are delegated to the native
68
+ TypeScript project service. Open virtual files overlay filesystem reads and
69
+ participate in ordinary module resolution without being written to disk.
70
+ Each Ravel target-and-stage pair owns an isolated TypeScript project and
71
+ document registry, even when two projections use the same `tsconfig.json` and
72
+ artifact path. This prevents browser/server targets and authoring/assembled
73
+ stages from contaminating one another while keeping each analysis context warm
74
+ across edits. Moving an open document to another stage also moves it to that
75
+ stage's project before later requests are served.
76
+
77
+ The supported language IDs are `typescript`, `typescriptreact`, `javascript`,
78
+ and `javascriptreact`. Semantic operations support authoring and assembled
79
+ projections. Emitted or destructive transformed projections are intentionally
80
+ not advertised for editable language features.
81
+
82
+ ## Lifecycle and cancellation
83
+
84
+ `change` rejects stale updates and version regressions. Requests can carry an
85
+ exact document version and are discarded when superseded. TypeScript's
86
+ cancellation token observes the request's `AbortSignal`; because the compiler
87
+ API is synchronous, cancellation is cooperative at native cancellation points
88
+ and is checked again before returning a response.
89
+
90
+ Open/change cancellation is commit-aware. Cancellation before project mutation
91
+ publishes no document. If cancellation arrives after a project mutation commits,
92
+ the bridge records the same committed entry before settling, so its retained
93
+ document map and TypeScript project cannot diverge. Script versions include a
94
+ per-project insertion sequence as well as the Ravel document version, ensuring
95
+ a reopened or moved virtual file receives a fresh native snapshot identity.
96
+
97
+ `restart()` rebuilds language services and reopens every current in-memory
98
+ document. `dispose()` releases services and forgets document text. This adapter
99
+ does not spawn a process and does not require a shadow workspace.
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@pieceful/ravel-language-typescript",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "description": "Native TypeScript and JavaScript language-service bridge for Ravel projections.",
6
+ "license": "MIT",
7
+ "author": "James Taylor",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/jostylr/ravel.git",
11
+ "directory": "packages/language-typescript"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/jostylr/ravel/issues"
15
+ },
16
+ "homepage": "https://ravel.jostylr.com/",
17
+ "engines": {
18
+ "node": ">=22"
19
+ },
20
+ "keywords": [
21
+ "ravel",
22
+ "literate-programming",
23
+ "typescript",
24
+ "language-service"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "exports": {
30
+ ".": "./src/index.js"
31
+ },
32
+ "types": "./src/index.d.ts",
33
+ "files": [
34
+ "src",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "dependencies": {
39
+ "@pieceful/ravel-language-bridge": "0.2.0"
40
+ },
41
+ "peerDependencies": {
42
+ "typescript": ">=5.0"
43
+ },
44
+ "peerDependenciesMeta": {
45
+ "typescript": {
46
+ "optional": true
47
+ }
48
+ }
49
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,85 @@
1
+ import type {
2
+ BridgeCapabilities,
3
+ BridgeRequestContext,
4
+ LanguageBridge,
5
+ LanguageRequest,
6
+ LanguageResponse,
7
+ TextChange,
8
+ VirtualDocumentLike
9
+ } from "@pieceful/ravel-language-bridge";
10
+
11
+ export interface TypeScriptBridgeOptions {
12
+ /** An already loaded `typescript` compiler API. */
13
+ typescript?: unknown;
14
+ /** Alternate module specifier used by the async factory. */
15
+ typescriptModule?: string;
16
+ loadTypeScript?: (specifier: string) => unknown | Promise<unknown>;
17
+ /** Base directory used for virtual artifact paths. */
18
+ currentDirectory?: string;
19
+ /**
20
+ * Allowlisted root for explicit and discovered tsconfig files. Defaults to
21
+ * currentDirectory; relative values are resolved from currentDirectory.
22
+ * Config paths outside its canonical filesystem location are ignored.
23
+ */
24
+ configSearchRoot?: string;
25
+ tsconfigPath?: string;
26
+ compilerOptions?: Record<string, unknown>;
27
+ fileNameForDocument?: (document: VirtualDocumentLike) => string | undefined;
28
+ configFileForDocument?: (document: VirtualDocumentLike, fileName: string) => string | undefined;
29
+ completionOptions?: Record<string, unknown>;
30
+ userPreferences?: Record<string, unknown>;
31
+ formatOptions?: Record<string, unknown>;
32
+ }
33
+
34
+ export interface GeneratedRange { start: number; end: number; }
35
+ export interface GeneratedLocation {
36
+ uri: string;
37
+ range: GeneratedRange;
38
+ contextRange?: GeneratedRange;
39
+ name?: string;
40
+ kind?: string;
41
+ containerName?: string;
42
+ isWriteAccess?: boolean;
43
+ isDefinition?: boolean;
44
+ }
45
+ export interface GeneratedTextChange { range: GeneratedRange; text: string; }
46
+ export interface GeneratedFileChanges {
47
+ uri: string;
48
+ version?: number;
49
+ textChanges: GeneratedTextChange[];
50
+ isNewFile?: boolean;
51
+ }
52
+ export interface TypeScriptDiagnostic {
53
+ uri: string;
54
+ range: GeneratedRange;
55
+ code: number;
56
+ severity: "error" | "warning" | "info" | "hint";
57
+ message: string;
58
+ source: "typescript";
59
+ related: Array<{ uri?: string; range?: GeneratedRange; message: string }>;
60
+ }
61
+ export interface TypeScriptCallItem {
62
+ name: string;
63
+ kind: string;
64
+ uri: string;
65
+ range: GeneratedRange;
66
+ selectionRange: GeneratedRange;
67
+ containerName?: string;
68
+ kindModifiers?: string;
69
+ }
70
+
71
+ export class TypeScriptLanguageBridge implements LanguageBridge {
72
+ readonly languageIds: readonly ["typescript", "typescriptreact", "javascript", "javascriptreact"];
73
+ readonly capabilities: BridgeCapabilities;
74
+ readonly state: string;
75
+ constructor(typescript: unknown, options?: TypeScriptBridgeOptions);
76
+ open(document: VirtualDocumentLike, signal?: AbortSignal): Promise<void>;
77
+ change(previous: VirtualDocumentLike, next: VirtualDocumentLike, changes?: readonly TextChange[], signal?: AbortSignal): Promise<void>;
78
+ close(document: VirtualDocumentLike): Promise<void>;
79
+ request<T extends LanguageRequest>(request: T, context?: BridgeRequestContext, signal?: AbortSignal): Promise<LanguageResponse<T>>;
80
+ restart(): Promise<void>;
81
+ dispose(): Promise<void>;
82
+ }
83
+
84
+ export function createTypeScriptLanguageBridge(options?: TypeScriptBridgeOptions): Promise<TypeScriptLanguageBridge>;
85
+ export function createTypeScriptLanguageBridgeWithApi(typescript: unknown, options?: TypeScriptBridgeOptions): TypeScriptLanguageBridge;
package/src/index.js ADDED
@@ -0,0 +1,46 @@
1
+ import { TypeScriptLanguageBridge } from "./typescript-bridge.js";
2
+
3
+ const isTypeScriptApi = (value) =>
4
+ value && typeof value.createLanguageService === "function" &&
5
+ value.ScriptSnapshot && value.sys;
6
+
7
+ const normalizeTypeScriptApi = (module) => {
8
+ if (isTypeScriptApi(module)) return module;
9
+ if (isTypeScriptApi(module?.default)) return module.default;
10
+ throw new TypeError("The supplied module is not a TypeScript compiler API.");
11
+ };
12
+
13
+ /**
14
+ * Create a bridge with an already loaded TypeScript compiler API.
15
+ *
16
+ * This entry point is useful to hosts that already own a TypeScript runtime,
17
+ * including editors and deterministic tests.
18
+ */
19
+ export const createTypeScriptLanguageBridgeWithApi = (typescript, options = {}) =>
20
+ new TypeScriptLanguageBridge(normalizeTypeScriptApi(typescript), options);
21
+
22
+ /**
23
+ * Load the optional TypeScript peer dependency and create a bridge.
24
+ */
25
+ export const createTypeScriptLanguageBridge = async (options = {}) => {
26
+ if (options.typescript !== undefined) {
27
+ return createTypeScriptLanguageBridgeWithApi(options.typescript, options);
28
+ }
29
+ const specifier = options.typescriptModule ?? "typescript";
30
+ let module;
31
+ try {
32
+ module = options.loadTypeScript
33
+ ? await options.loadTypeScript(specifier)
34
+ : await import(specifier);
35
+ } catch (error) {
36
+ const failure = new Error(
37
+ "TypeScript is not available. Install the optional `typescript` peer dependency or pass `options.typescript`.",
38
+ { cause: error }
39
+ );
40
+ failure.code = "TYPESCRIPT_NOT_AVAILABLE";
41
+ throw failure;
42
+ }
43
+ return createTypeScriptLanguageBridgeWithApi(module, options);
44
+ };
45
+
46
+ export { TypeScriptLanguageBridge } from "./typescript-bridge.js";
@@ -0,0 +1,227 @@
1
+ import { pathToFileURL } from "node:url";
2
+
3
+ export const rangeForSpan = (span) => span === undefined
4
+ ? undefined
5
+ : { start: span.start, end: span.start + span.length };
6
+
7
+ export const displayParts = (parts) =>
8
+ typeof parts === "string"
9
+ ? parts
10
+ : Array.isArray(parts) ? parts.map((part) => part.text).join("") : "";
11
+
12
+ const tagsFor = (tags) => tags?.map((tag) => ({
13
+ name: tag.name,
14
+ text: typeof tag.text === "string" ? tag.text : displayParts(tag.text)
15
+ })) ?? [];
16
+
17
+ export const diagnosticSeverity = (ts, category) => {
18
+ if (category === ts.DiagnosticCategory.Error) return "error";
19
+ if (category === ts.DiagnosticCategory.Warning) return "warning";
20
+ if (category === ts.DiagnosticCategory.Suggestion) return "hint";
21
+ return "info";
22
+ };
23
+
24
+ const diagnosticMessage = (ts, message) =>
25
+ ts.flattenDiagnosticMessageText(message, "\n");
26
+
27
+ export const createNormalizers = (ts, project) => {
28
+ const uriForFile = (fileName) => project.uriForFile(fileName) ?? pathToFileURL(fileName).href;
29
+
30
+ const location = (entry) => ({
31
+ uri: uriForFile(entry.fileName),
32
+ range: rangeForSpan(entry.textSpan),
33
+ name: entry.name,
34
+ kind: entry.kind,
35
+ containerName: entry.containerName,
36
+ ...(entry.contextSpan === undefined ? {} : { contextRange: rangeForSpan(entry.contextSpan) }),
37
+ ...(entry.isWriteAccess === undefined ? {} : { isWriteAccess: entry.isWriteAccess }),
38
+ ...(entry.isDefinition === undefined ? {} : { isDefinition: entry.isDefinition })
39
+ });
40
+
41
+ const relatedDiagnostic = (related) => ({
42
+ uri: related.file ? uriForFile(related.file.fileName) : undefined,
43
+ range: related.start === undefined
44
+ ? undefined
45
+ : { start: related.start, end: related.start + (related.length ?? 0) },
46
+ message: diagnosticMessage(ts, related.messageText)
47
+ });
48
+
49
+ const diagnostic = (entry, fallbackFileName) => ({
50
+ uri: entry.file ? uriForFile(entry.file.fileName) : uriForFile(fallbackFileName),
51
+ range: entry.start === undefined
52
+ ? { start: 0, end: 0 }
53
+ : { start: entry.start, end: entry.start + (entry.length ?? 0) },
54
+ code: entry.code,
55
+ severity: diagnosticSeverity(ts, entry.category),
56
+ message: diagnosticMessage(ts, entry.messageText),
57
+ source: "typescript",
58
+ related: entry.relatedInformation?.map(relatedDiagnostic) ?? []
59
+ });
60
+
61
+ const textChange = (entry) => ({ range: rangeForSpan(entry.span), text: entry.newText });
62
+ const fileTextChanges = (entry) => ({
63
+ uri: uriForFile(entry.fileName),
64
+ version: project.versionForFile(entry.fileName),
65
+ textChanges: entry.textChanges.map(textChange),
66
+ ...(entry.isNewFile === undefined ? {} : { isNewFile: entry.isNewFile })
67
+ });
68
+
69
+ const callItem = (entry) => ({
70
+ name: entry.name,
71
+ kind: entry.kind,
72
+ uri: uriForFile(entry.file),
73
+ range: rangeForSpan(entry.span),
74
+ selectionRange: rangeForSpan(entry.selectionSpan),
75
+ containerName: entry.containerName,
76
+ kindModifiers: entry.kindModifiers
77
+ });
78
+
79
+ return {
80
+ uriForFile,
81
+ location,
82
+ diagnostic,
83
+ fileTextChanges,
84
+ callItem,
85
+ completion(result) {
86
+ if (!result) return { items: [], isGlobal: false, isMember: false, isNewIdentifier: false };
87
+ return {
88
+ isGlobal: result.isGlobalCompletion === true,
89
+ isMember: result.isMemberCompletion === true,
90
+ isNewIdentifier: result.isNewIdentifierLocation === true,
91
+ optionalReplacementSpan: rangeForSpan(result.optionalReplacementSpan),
92
+ defaultCommitCharacters: result.defaultCommitCharacters,
93
+ items: result.entries.map((entry) => ({
94
+ name: entry.name,
95
+ kind: entry.kind,
96
+ kindModifiers: entry.kindModifiers,
97
+ sortText: entry.sortText,
98
+ insertText: entry.insertText,
99
+ filterText: entry.filterText,
100
+ replacementSpan: rangeForSpan(entry.replacementSpan),
101
+ source: entry.source,
102
+ hasAction: entry.hasAction === true,
103
+ isRecommended: entry.isRecommended === true,
104
+ isSnippet: entry.isSnippet === true,
105
+ commitCharacters: entry.commitCharacters,
106
+ data: entry.data
107
+ }))
108
+ };
109
+ },
110
+ completionDetails(result) {
111
+ if (!result) return undefined;
112
+ return {
113
+ name: result.name,
114
+ kind: result.kind,
115
+ kindModifiers: result.kindModifiers,
116
+ display: displayParts(result.displayParts),
117
+ documentation: displayParts(result.documentation),
118
+ tags: tagsFor(result.tags),
119
+ source: displayParts(result.source),
120
+ codeActions: result.codeActions?.map((action) => ({
121
+ description: action.description,
122
+ commands: action.commands,
123
+ changes: action.changes.map(fileTextChanges)
124
+ })) ?? []
125
+ };
126
+ },
127
+ hover(result) {
128
+ if (!result) return undefined;
129
+ return {
130
+ range: rangeForSpan(result.textSpan),
131
+ kind: result.kind,
132
+ kindModifiers: result.kindModifiers,
133
+ display: displayParts(result.displayParts),
134
+ documentation: displayParts(result.documentation),
135
+ tags: tagsFor(result.tags)
136
+ };
137
+ },
138
+ signature(result) {
139
+ if (!result) return undefined;
140
+ return {
141
+ applicableSpan: rangeForSpan(result.applicableSpan),
142
+ selectedItemIndex: result.selectedItemIndex,
143
+ argumentIndex: result.argumentIndex,
144
+ argumentCount: result.argumentCount,
145
+ items: result.items.map((item) => ({
146
+ isVariadic: item.isVariadic,
147
+ prefix: displayParts(item.prefixDisplayParts),
148
+ separator: displayParts(item.separatorDisplayParts),
149
+ suffix: displayParts(item.suffixDisplayParts),
150
+ documentation: displayParts(item.documentation),
151
+ tags: tagsFor(item.tags),
152
+ parameters: item.parameters.map((parameter) => ({
153
+ name: parameter.name,
154
+ display: displayParts(parameter.displayParts),
155
+ documentation: displayParts(parameter.documentation),
156
+ tags: tagsFor(parameter.tags),
157
+ isOptional: parameter.isOptional === true
158
+ }))
159
+ }))
160
+ };
161
+ },
162
+ locations(result) {
163
+ return result?.map(location) ?? [];
164
+ },
165
+ diagnostics(result, fallbackFileName) {
166
+ return result.map((entry) => diagnostic(entry, fallbackFileName));
167
+ },
168
+ callItems(result) {
169
+ if (!result) return [];
170
+ return (Array.isArray(result) ? result : [result]).map(callItem);
171
+ },
172
+ incomingCalls(result) {
173
+ return result?.map((entry) => ({
174
+ from: callItem(entry.from),
175
+ fromRanges: entry.fromSpans.map(rangeForSpan)
176
+ })) ?? [];
177
+ },
178
+ outgoingCalls(result) {
179
+ return result?.map((entry) => ({
180
+ to: callItem(entry.to),
181
+ fromRanges: entry.fromSpans.map(rangeForSpan)
182
+ })) ?? [];
183
+ },
184
+ renameLocations(result, newName) {
185
+ const grouped = new Map();
186
+ for (const entry of result ?? []) {
187
+ const uri = uriForFile(entry.fileName);
188
+ let group = grouped.get(uri);
189
+ if (!group) {
190
+ group = {
191
+ uri,
192
+ version: project.versionForFile(entry.fileName),
193
+ textChanges: []
194
+ };
195
+ grouped.set(uri, group);
196
+ }
197
+ const prefix = entry.prefixText ?? "";
198
+ const suffix = entry.suffixText ?? "";
199
+ group.textChanges.push({
200
+ range: rangeForSpan(entry.textSpan),
201
+ text: prefix + newName + suffix
202
+ });
203
+ }
204
+ return [...grouped.values()];
205
+ }
206
+ };
207
+ };
208
+
209
+ export const flattenNavigationTree = (tree, uri) => {
210
+ const symbols = [];
211
+ const visit = (item, containerName) => {
212
+ for (const span of item.spans ?? []) {
213
+ symbols.push({
214
+ name: item.text,
215
+ kind: item.kind,
216
+ kindModifiers: item.kindModifiers,
217
+ uri,
218
+ range: rangeForSpan(span),
219
+ selectionRange: rangeForSpan(item.nameSpan ?? span),
220
+ containerName
221
+ });
222
+ }
223
+ for (const child of item.childItems ?? []) visit(child, item.text);
224
+ };
225
+ if (tree) visit(tree, undefined);
226
+ return symbols;
227
+ };
@@ -0,0 +1,478 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import {
4
+ BRIDGE_ERROR_CODES,
5
+ LanguageBridgeError,
6
+ assertLanguageRequest,
7
+ assertVirtualDocument,
8
+ bridgeError,
9
+ createBridgeCapabilities,
10
+ requireLanguageRequestSupport,
11
+ throwIfAborted
12
+ } from "@pieceful/ravel-language-bridge";
13
+ import { createNormalizers, flattenNavigationTree, rangeForSpan } from "./normalize.js";
14
+ import {
15
+ TypeScriptProject,
16
+ resolveConfigPath,
17
+ resolveVirtualFileName
18
+ } from "./typescript-project.js";
19
+
20
+ const languageIds = Object.freeze([
21
+ "typescript",
22
+ "typescriptreact",
23
+ "javascript",
24
+ "javascriptreact"
25
+ ]);
26
+ const supportedStages = Object.freeze(["authoring", "assembled"]);
27
+
28
+ const capabilities = createBridgeCapabilities({
29
+ completion: { stages: supportedStages, triggerCharacters: [".", "\"", "'", "/", "@", "<", "#", " "], resolveProvider: true },
30
+ completionDetails: { stages: supportedStages },
31
+ hover: { stages: supportedStages },
32
+ signatureHelp: { stages: supportedStages, triggerCharacters: ["(", ",", "<"] },
33
+ definition: { stages: supportedStages },
34
+ typeDefinition: { stages: supportedStages },
35
+ references: { stages: supportedStages },
36
+ documentSymbols: { stages: supportedStages },
37
+ workspaceSymbols: { stages: supportedStages, workspaceProvider: true },
38
+ diagnostics: { stages: supportedStages },
39
+ prepareCallHierarchy: { stages: supportedStages },
40
+ incomingCalls: { stages: supportedStages },
41
+ outgoingCalls: { stages: supportedStages },
42
+ prepareRename: { stages: supportedStages },
43
+ rename: { stages: supportedStages }
44
+ });
45
+
46
+ const requestUri = (request, context) =>
47
+ request.documentUri ?? context.document?.uri ?? context.documentUri;
48
+
49
+ const compareDiagnostics = (left, right) =>
50
+ left.uri.localeCompare(right.uri) || left.range.start - right.range.start ||
51
+ left.range.end - right.range.end || String(left.code).localeCompare(String(right.code));
52
+
53
+ const dedupeDiagnostics = (diagnostics) => {
54
+ const seen = new Set();
55
+ return diagnostics.filter((entry) => {
56
+ const key = [entry.uri, entry.range.start, entry.range.end, entry.code, entry.message].join("\0");
57
+ if (seen.has(key)) return false;
58
+ seen.add(key);
59
+ return true;
60
+ }).sort(compareDiagnostics);
61
+ };
62
+
63
+ const positionFor = (request) => {
64
+ if (!Number.isInteger(request.position) || request.position < 0) {
65
+ throw new LanguageBridgeError(
66
+ BRIDGE_ERROR_CODES.INVALID_REQUEST,
67
+ request.kind + " requires a non-negative integer position."
68
+ );
69
+ }
70
+ return request.position;
71
+ };
72
+
73
+ const normalizeWorkspaceSymbol = (normalizers, item) => ({
74
+ name: item.name,
75
+ kind: item.kind,
76
+ kindModifiers: item.kindModifiers,
77
+ uri: normalizers.uriForFile(item.fileName),
78
+ range: rangeForSpan(item.textSpan),
79
+ selectionRange: rangeForSpan(item.matchKind === "exact" ? item.textSpan : item.textSpan),
80
+ containerName: item.containerName
81
+ });
82
+
83
+ export class TypeScriptLanguageBridge {
84
+ constructor(ts, options = {}) {
85
+ this.ts = ts;
86
+ const currentDirectory = path.resolve(options.currentDirectory ?? ts.sys.getCurrentDirectory());
87
+ this.options = {
88
+ currentDirectory,
89
+ configSearchRoot: path.resolve(currentDirectory, options.configSearchRoot ?? "."),
90
+ fileURLToPath,
91
+ compilerOptions: options.compilerOptions,
92
+ tsconfigPath: options.tsconfigPath,
93
+ configFileForDocument: options.configFileForDocument,
94
+ fileNameForDocument: options.fileNameForDocument,
95
+ completionOptions: options.completionOptions ?? {},
96
+ userPreferences: options.userPreferences ?? {},
97
+ formatOptions: options.formatOptions ?? {}
98
+ };
99
+ this.languageIds = languageIds;
100
+ this.capabilities = capabilities;
101
+ this.documents = new Map();
102
+ this.projects = new Map();
103
+ this.lifecycleState = "ready";
104
+ this.restartDocuments = undefined;
105
+ }
106
+
107
+ get state() { return this.lifecycleState; }
108
+
109
+ assertReady() {
110
+ if (this.lifecycleState === "disposed") {
111
+ throw new LanguageBridgeError(BRIDGE_ERROR_CODES.DISPOSED, "The TypeScript language bridge is disposed.");
112
+ }
113
+ if (this.lifecycleState === "failed") {
114
+ throw new LanguageBridgeError(BRIDGE_ERROR_CODES.CRASHED, "The TypeScript language bridge must be restarted.", { retryable: true });
115
+ }
116
+ }
117
+
118
+ projectKey(configPath, targetId, stage) {
119
+ const target = String(targetId ?? "default");
120
+ const projectionStage = String(stage ?? "assembled");
121
+ if (!configPath) {
122
+ return JSON.stringify([target, projectionStage, "inferred", this.options.currentDirectory]);
123
+ }
124
+ return JSON.stringify([target, projectionStage, "configured", path.normalize(configPath)]);
125
+ }
126
+
127
+ ensureProject(configPath, targetId, stage) {
128
+ const key = this.projectKey(configPath, targetId, stage);
129
+ let project = this.projects.get(key);
130
+ if (!project) {
131
+ project = new TypeScriptProject(
132
+ this.ts,
133
+ key,
134
+ configPath,
135
+ this.options,
136
+ this.ts.createDocumentRegistry(
137
+ this.ts.sys.useCaseSensitiveFileNames,
138
+ this.options.currentDirectory
139
+ )
140
+ );
141
+ this.projects.set(key, project);
142
+ }
143
+ return project;
144
+ }
145
+
146
+ createEntry(document) {
147
+ if (!this.languageIds.includes(document.languageId)) {
148
+ throw new LanguageBridgeError(
149
+ BRIDGE_ERROR_CODES.NOT_SUPPORTED,
150
+ "Unsupported TypeScript bridge language ID: " + document.languageId + ".",
151
+ { details: { languageId: document.languageId, supported: this.languageIds } }
152
+ );
153
+ }
154
+ const fileName = resolveVirtualFileName(document, this.options);
155
+ const configPath = resolveConfigPath(this.ts, document, fileName, this.options);
156
+ const project = this.ensureProject(configPath, document.targetId, document.stage);
157
+ return { uri: document.uri, document, fileName, project, configPath };
158
+ }
159
+
160
+ async open(document, signal) {
161
+ this.assertReady();
162
+ throwIfAborted(signal);
163
+ assertVirtualDocument(document);
164
+ const current = this.documents.get(document.uri);
165
+ if (current) {
166
+ throw new LanguageBridgeError(
167
+ BRIDGE_ERROR_CODES.DOCUMENT_COLLISION,
168
+ "The virtual document is already open: " + document.uri + ".",
169
+ { details: { uri: document.uri, version: current.document.version } }
170
+ );
171
+ }
172
+ try {
173
+ const entry = this.createEntry(document);
174
+ // Cancellation is accepted up to this commit boundary. Once the project
175
+ // and bridge indexes start changing, open must either finish or roll
176
+ // back; reporting ABORTED after a successful commit would leave callers
177
+ // believing the document is closed while it is actually open here.
178
+ throwIfAborted(signal);
179
+ entry.project.add(entry);
180
+ this.documents.set(document.uri, entry);
181
+ } catch (error) {
182
+ throw bridgeError(error, {
183
+ code: BRIDGE_ERROR_CODES.PROJECT_ERROR,
184
+ message: "TypeScript could not open " + document.uri + ": " + (error?.message ?? String(error)),
185
+ details: { uri: document.uri, version: document.version }
186
+ });
187
+ }
188
+ }
189
+
190
+ async change(previous, next, changes = [], signal) {
191
+ this.assertReady();
192
+ throwIfAborted(signal);
193
+ assertVirtualDocument(previous);
194
+ assertVirtualDocument(next);
195
+ const current = this.documents.get(previous.uri);
196
+ if (!current) {
197
+ throw new LanguageBridgeError(BRIDGE_ERROR_CODES.DOCUMENT_NOT_OPEN, "The virtual document is not open: " + previous.uri + ".");
198
+ }
199
+ if (current.document.version !== previous.version || next.uri !== previous.uri) {
200
+ throw new LanguageBridgeError(
201
+ BRIDGE_ERROR_CODES.STALE_DOCUMENT,
202
+ "The TypeScript change does not match the currently open document.",
203
+ { details: { currentVersion: current.document.version, previousVersion: previous.version, uri: previous.uri } }
204
+ );
205
+ }
206
+ if (next.version <= current.document.version) {
207
+ throw new LanguageBridgeError(
208
+ BRIDGE_ERROR_CODES.VERSION_REGRESSION,
209
+ "Virtual document versions must increase monotonically.",
210
+ { details: { currentVersion: current.document.version, nextVersion: next.version, uri: next.uri } }
211
+ );
212
+ }
213
+
214
+ let nextFileName;
215
+ let nextConfigPath;
216
+ let nextProject;
217
+ try {
218
+ nextFileName = resolveVirtualFileName(next, this.options);
219
+ nextConfigPath = resolveConfigPath(this.ts, next, nextFileName, this.options);
220
+ nextProject = this.ensureProject(nextConfigPath, next.targetId, next.stage);
221
+ } catch (error) {
222
+ throw bridgeError(error, {
223
+ code: BRIDGE_ERROR_CODES.PROJECT_ERROR,
224
+ message: "TypeScript could not update " + next.uri + ": " + (error?.message ?? String(error)),
225
+ details: { uri: next.uri, version: next.version }
226
+ });
227
+ }
228
+ // This is the change commit boundary. Do not observe cancellation again
229
+ // after removing the current entry: the rollback below only covers a
230
+ // failed commit, while a successful commit must be reported as success.
231
+ throwIfAborted(signal);
232
+ current.project.remove(current);
233
+ const entry = {
234
+ uri: next.uri,
235
+ document: next,
236
+ fileName: nextFileName,
237
+ project: nextProject,
238
+ configPath: nextConfigPath,
239
+ changes: [...changes]
240
+ };
241
+ try {
242
+ nextProject.add(entry);
243
+ this.documents.set(next.uri, entry);
244
+ } catch (error) {
245
+ current.project.add(current);
246
+ throw bridgeError(error, {
247
+ code: BRIDGE_ERROR_CODES.PROJECT_ERROR,
248
+ message: "TypeScript could not update " + next.uri + ": " + (error?.message ?? String(error)),
249
+ details: { uri: next.uri, version: next.version }
250
+ });
251
+ }
252
+ }
253
+
254
+ async close(document) {
255
+ this.assertReady();
256
+ assertVirtualDocument(document);
257
+ const current = this.documents.get(document.uri);
258
+ if (!current) {
259
+ throw new LanguageBridgeError(BRIDGE_ERROR_CODES.DOCUMENT_NOT_OPEN, "The virtual document is not open: " + document.uri + ".");
260
+ }
261
+ current.project.remove(current);
262
+ this.documents.delete(document.uri);
263
+ }
264
+
265
+ resolveRequest(request, context) {
266
+ const uri = requestUri(request, context);
267
+ if (typeof uri !== "string" || uri.length === 0) {
268
+ throw new LanguageBridgeError(BRIDGE_ERROR_CODES.INVALID_REQUEST, request.kind + " requires a document URI.");
269
+ }
270
+ const entry = this.documents.get(uri);
271
+ if (!entry) {
272
+ throw new LanguageBridgeError(BRIDGE_ERROR_CODES.DOCUMENT_NOT_OPEN, "The request document is not open: " + uri + ".");
273
+ }
274
+ const expectedVersion = context.version ?? context.document?.version;
275
+ if (expectedVersion !== undefined && expectedVersion !== entry.document.version) {
276
+ throw new LanguageBridgeError(
277
+ BRIDGE_ERROR_CODES.STALE_DOCUMENT,
278
+ "The request targets a stale virtual document version.",
279
+ { details: { expectedVersion, currentVersion: entry.document.version, uri } }
280
+ );
281
+ }
282
+ return entry;
283
+ }
284
+
285
+ async request(request, context = {}, signal) {
286
+ this.assertReady();
287
+ throwIfAborted(signal);
288
+ assertLanguageRequest(request);
289
+ requireLanguageRequestSupport(this, request, {
290
+ ...context,
291
+ stage: context.document?.stage ?? context.stage ?? "assembled"
292
+ });
293
+ const entry = this.resolveRequest(request, context);
294
+ const project = entry.project;
295
+ const service = project.service;
296
+ const normalizers = createNormalizers(this.ts, project);
297
+ project.activeSignal = signal;
298
+ try {
299
+ let result;
300
+ switch (request.kind) {
301
+ case "completion":
302
+ result = normalizers.completion(service.getCompletionsAtPosition(
303
+ entry.fileName,
304
+ positionFor(request),
305
+ { ...this.options.completionOptions, ...request.options },
306
+ request.formatOptions ?? this.options.formatOptions
307
+ ));
308
+ break;
309
+ case "completionDetails":
310
+ if (typeof request.name !== "string" || request.name.length === 0) {
311
+ throw new LanguageBridgeError(BRIDGE_ERROR_CODES.INVALID_REQUEST, "completionDetails requires an entry name.");
312
+ }
313
+ result = normalizers.completionDetails(service.getCompletionEntryDetails(
314
+ entry.fileName,
315
+ positionFor(request),
316
+ request.name,
317
+ request.formatOptions ?? this.options.formatOptions,
318
+ request.source,
319
+ { ...this.options.userPreferences, ...request.preferences },
320
+ request.data
321
+ ));
322
+ break;
323
+ case "hover":
324
+ result = normalizers.hover(service.getQuickInfoAtPosition(entry.fileName, positionFor(request)));
325
+ break;
326
+ case "signatureHelp":
327
+ result = normalizers.signature(service.getSignatureHelpItems(
328
+ entry.fileName,
329
+ positionFor(request),
330
+ {
331
+ triggerReason: { kind: "invoked" },
332
+ ...(request.options ?? {})
333
+ }
334
+ ));
335
+ break;
336
+ case "definition":
337
+ result = normalizers.locations(service.getDefinitionAtPosition(entry.fileName, positionFor(request)));
338
+ break;
339
+ case "typeDefinition":
340
+ result = normalizers.locations(service.getTypeDefinitionAtPosition(entry.fileName, positionFor(request)));
341
+ break;
342
+ case "references":
343
+ result = normalizers.locations(service.getReferencesAtPosition(entry.fileName, positionFor(request)));
344
+ break;
345
+ case "documentSymbols":
346
+ result = flattenNavigationTree(
347
+ service.getNavigationTree(entry.fileName),
348
+ normalizers.uriForFile(entry.fileName)
349
+ );
350
+ break;
351
+ case "workspaceSymbols":
352
+ result = service.getNavigateToItems(
353
+ typeof request.query === "string" ? request.query : "",
354
+ request.maximumResultCount,
355
+ request.excludeFileName,
356
+ request.excludeDtsFiles === true
357
+ ).map((item) => normalizeWorkspaceSymbol(normalizers, item));
358
+ break;
359
+ case "diagnostics": {
360
+ const categories = new Set(request.categories ?? ["configuration", "compilerOptions", "syntactic", "semantic", "suggestion"]);
361
+ const diagnostics = [];
362
+ if (categories.has("configuration")) {
363
+ diagnostics.push(...project.configuration.diagnostics.filter((diagnostic) =>
364
+ diagnostic.code !== 18003 || project.documents.size === 0));
365
+ }
366
+ if (categories.has("compilerOptions")) diagnostics.push(...service.getCompilerOptionsDiagnostics());
367
+ if (categories.has("syntactic")) diagnostics.push(...service.getSyntacticDiagnostics(entry.fileName));
368
+ if (categories.has("semantic")) diagnostics.push(...service.getSemanticDiagnostics(entry.fileName));
369
+ if (categories.has("suggestion")) diagnostics.push(...service.getSuggestionDiagnostics(entry.fileName));
370
+ result = dedupeDiagnostics(normalizers.diagnostics(diagnostics, entry.fileName));
371
+ break;
372
+ }
373
+ case "prepareCallHierarchy":
374
+ result = normalizers.callItems(service.prepareCallHierarchy(entry.fileName, positionFor(request)));
375
+ break;
376
+ case "incomingCalls":
377
+ result = normalizers.incomingCalls(service.provideCallHierarchyIncomingCalls(entry.fileName, positionFor(request)));
378
+ break;
379
+ case "outgoingCalls":
380
+ result = normalizers.outgoingCalls(service.provideCallHierarchyOutgoingCalls(entry.fileName, positionFor(request)));
381
+ break;
382
+ case "prepareRename": {
383
+ const info = service.getRenameInfo(
384
+ entry.fileName,
385
+ positionFor(request),
386
+ { allowRenameOfImportPath: request.allowRenameOfImportPath === true }
387
+ );
388
+ result = info.canRename
389
+ ? {
390
+ canRename: true,
391
+ range: rangeForSpan(info.triggerSpan),
392
+ placeholder: info.displayName,
393
+ fullDisplayName: info.fullDisplayName,
394
+ kind: info.kind,
395
+ kindModifiers: info.kindModifiers
396
+ }
397
+ : { canRename: false, reason: info.localizedErrorMessage };
398
+ break;
399
+ }
400
+ case "rename": {
401
+ if (typeof request.newName !== "string" || request.newName.length === 0) {
402
+ throw new LanguageBridgeError(BRIDGE_ERROR_CODES.INVALID_REQUEST, "rename requires a non-empty newName.");
403
+ }
404
+ const position = positionFor(request);
405
+ const info = service.getRenameInfo(
406
+ entry.fileName,
407
+ position,
408
+ { allowRenameOfImportPath: request.allowRenameOfImportPath === true }
409
+ );
410
+ result = info.canRename
411
+ ? {
412
+ canRename: true,
413
+ changes: normalizers.renameLocations(service.findRenameLocations(
414
+ entry.fileName,
415
+ position,
416
+ request.findInStrings === true,
417
+ request.findInComments === true,
418
+ request.providePrefixAndSuffixTextForRename === true
419
+ ), request.newName)
420
+ }
421
+ : { canRename: false, reason: info.localizedErrorMessage, changes: [] };
422
+ break;
423
+ }
424
+ }
425
+ throwIfAborted(signal);
426
+ if (entry.document.version !== (context.version ?? context.document?.version ?? entry.document.version)) {
427
+ throw new LanguageBridgeError(BRIDGE_ERROR_CODES.STALE_DOCUMENT, "The language response was superseded by a newer projection.", { retryable: true });
428
+ }
429
+ return result;
430
+ } catch (error) {
431
+ if (signal?.aborted) throwIfAborted(signal);
432
+ throw bridgeError(error, {
433
+ code: BRIDGE_ERROR_CODES.PROJECT_ERROR,
434
+ message: "TypeScript " + request.kind + " failed: " + (error?.message ?? String(error)),
435
+ retryable: true,
436
+ details: { kind: request.kind, uri: entry.uri, version: entry.document.version }
437
+ });
438
+ } finally {
439
+ project.activeSignal = undefined;
440
+ }
441
+ }
442
+
443
+ async restart() {
444
+ if (this.lifecycleState === "disposed") this.assertReady();
445
+ this.lifecycleState = "restarting";
446
+ const documents = this.restartDocuments ??
447
+ [...this.documents.values()].map((entry) => entry.document);
448
+ this.restartDocuments = documents;
449
+ for (const project of this.projects.values()) project.dispose();
450
+ this.projects.clear();
451
+ this.documents.clear();
452
+ try {
453
+ for (const document of documents) {
454
+ const entry = this.createEntry(document);
455
+ entry.project.add(entry);
456
+ this.documents.set(entry.uri, entry);
457
+ }
458
+ this.lifecycleState = "ready";
459
+ this.restartDocuments = undefined;
460
+ } catch (error) {
461
+ this.lifecycleState = "failed";
462
+ throw bridgeError(error, {
463
+ code: BRIDGE_ERROR_CODES.CRASHED,
464
+ message: "The TypeScript language bridge could not restart.",
465
+ retryable: true
466
+ });
467
+ }
468
+ }
469
+
470
+ async dispose() {
471
+ if (this.lifecycleState === "disposed") return;
472
+ for (const project of this.projects.values()) project.dispose();
473
+ this.projects.clear();
474
+ this.documents.clear();
475
+ this.restartDocuments = undefined;
476
+ this.lifecycleState = "disposed";
477
+ }
478
+ }
@@ -0,0 +1,343 @@
1
+ import path from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import { BRIDGE_ERROR_CODES, LanguageBridgeError } from "@pieceful/ravel-language-bridge";
4
+
5
+ const languageExtensions = Object.freeze({
6
+ typescript: ".ts",
7
+ typescriptreact: ".tsx",
8
+ javascript: ".js",
9
+ javascriptreact: ".jsx"
10
+ });
11
+
12
+ const languageScriptKind = (ts, languageId, fileName) => {
13
+ if (languageId === "typescript") return ts.ScriptKind.TS;
14
+ if (languageId === "typescriptreact") return ts.ScriptKind.TSX;
15
+ if (languageId === "javascript") return ts.ScriptKind.JS;
16
+ if (languageId === "javascriptreact") return ts.ScriptKind.JSX;
17
+ return ts.getScriptKindFromFileName?.(fileName) ?? ts.ScriptKind.Unknown;
18
+ };
19
+
20
+ const hash = (text) => {
21
+ let value = 2166136261;
22
+ for (let index = 0; index < text.length; index += 1) {
23
+ value ^= text.charCodeAt(index);
24
+ value = Math.imul(value, 16777619);
25
+ }
26
+ return (value >>> 0).toString(36);
27
+ };
28
+
29
+ const absolute = (currentDirectory, value) =>
30
+ path.isAbsolute(value) ? path.normalize(value) : path.resolve(currentDirectory, value);
31
+
32
+ const comparablePath = (ts, value) => {
33
+ const resolved = path.resolve(value);
34
+ return ts.sys.useCaseSensitiveFileNames ? resolved : resolved.toLowerCase();
35
+ };
36
+
37
+ const containedIn = (ts, root, candidate) => {
38
+ const relative = path.relative(
39
+ comparablePath(ts, root),
40
+ comparablePath(ts, candidate)
41
+ );
42
+ return relative === "" ||
43
+ relative !== ".." && !relative.startsWith(".." + path.sep) &&
44
+ !path.isAbsolute(relative);
45
+ };
46
+
47
+ // Resolve the nearest existing ancestor so a not-yet-created config beneath a
48
+ // symlinked directory is checked against the directory's canonical location.
49
+ const canonicalPotentialPath = (ts, value) => {
50
+ const original = path.resolve(value);
51
+ if (typeof ts.sys.realpath !== "function") return original;
52
+ const suffix = [];
53
+ let current = original;
54
+ while (true) {
55
+ const exists = ts.sys.fileExists?.(current) === true ||
56
+ ts.sys.directoryExists?.(current) === true;
57
+ if (exists) {
58
+ try {
59
+ const canonical = ts.sys.realpath(current);
60
+ if (typeof canonical === "string" && canonical.length > 0) {
61
+ return path.resolve(canonical, ...suffix);
62
+ }
63
+ } catch {
64
+ // Continue with the nearest existing parent. A failed realpath must not
65
+ // turn an outside candidate into an allowed path.
66
+ }
67
+ }
68
+ const parent = path.dirname(current);
69
+ if (parent === current) return original;
70
+ suffix.unshift(path.basename(current));
71
+ current = parent;
72
+ }
73
+ };
74
+
75
+ const confinedConfigPath = (ts, value, options) => {
76
+ const candidate = absolute(options.currentDirectory, value);
77
+ const root = path.resolve(options.configSearchRoot ?? options.currentDirectory);
78
+ if (!containedIn(ts, root, candidate)) return undefined;
79
+ const canonicalRoot = canonicalPotentialPath(ts, root);
80
+ const canonicalCandidate = canonicalPotentialPath(ts, candidate);
81
+ return containedIn(ts, canonicalRoot, canonicalCandidate)
82
+ ? canonicalCandidate
83
+ : undefined;
84
+ };
85
+
86
+ const discoverConfigPath = (ts, fileName, options) => {
87
+ const root = path.resolve(options.configSearchRoot ?? options.currentDirectory);
88
+ let directory = path.dirname(path.resolve(fileName));
89
+ if (!containedIn(ts, root, directory) ||
90
+ !containedIn(ts, canonicalPotentialPath(ts, root), canonicalPotentialPath(ts, directory))) {
91
+ return undefined;
92
+ }
93
+ while (containedIn(ts, root, directory)) {
94
+ const candidate = path.join(directory, "tsconfig.json");
95
+ if (ts.sys.fileExists(candidate)) {
96
+ const confined = confinedConfigPath(ts, candidate, options);
97
+ if (confined) return confined;
98
+ }
99
+ if (comparablePath(ts, directory) === comparablePath(ts, root)) break;
100
+ const parent = path.dirname(directory);
101
+ if (parent === directory) break;
102
+ directory = parent;
103
+ }
104
+ return undefined;
105
+ };
106
+
107
+ const safeRelativeArtifactPath = (value) => {
108
+ if (typeof value !== "string" || value.length === 0 || value.includes("\0") ||
109
+ path.isAbsolute(value) || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value)) return undefined;
110
+ const normalized = path.normalize(value);
111
+ if (normalized === "." || normalized === ".." || normalized.startsWith(".." + path.sep)) {
112
+ return undefined;
113
+ }
114
+ return normalized;
115
+ };
116
+
117
+ export const resolveVirtualFileName = (document, options) => {
118
+ const configured = options.fileNameForDocument?.(document) ??
119
+ document.fileName ?? document.path ?? document.artifactPath ?? document.outputPath ??
120
+ document.metadata?.fileName ?? document.metadata?.artifactPath ?? document.metadata?.outputPath;
121
+ if (typeof configured === "string" && configured.length > 0) {
122
+ return absolute(options.currentDirectory, configured);
123
+ }
124
+ if (document.uri.startsWith("file:")) {
125
+ return options.fileURLToPath(document.uri);
126
+ }
127
+ const artifactPath = safeRelativeArtifactPath(document.artifactId);
128
+ if (artifactPath !== undefined) return path.resolve(options.currentDirectory, artifactPath);
129
+ const extension = languageExtensions[document.languageId] ?? ".txt";
130
+ const base = String(document.artifactId ?? "projection")
131
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
132
+ .replace(/^[-.]+|[-.]+$/g, "") || "projection";
133
+ return path.join(options.currentDirectory, ".ravel-virtual", hash(document.uri) + "-" + base + extension);
134
+ };
135
+
136
+ export const resolveConfigPath = (ts, document, fileName, options) => {
137
+ const configured = options.configFileForDocument?.(document, fileName) ??
138
+ document.tsconfigPath ?? document.metadata?.tsconfigPath ?? options.tsconfigPath;
139
+ if (typeof configured === "string" && configured.length > 0) {
140
+ const confined = confinedConfigPath(ts, configured, options);
141
+ if (confined) return confined;
142
+ }
143
+ return discoverConfigPath(ts, fileName, options);
144
+ };
145
+
146
+ const inferredOptions = (ts, overrides = {}) => ({
147
+ target: ts.ScriptTarget.ES2022 ?? ts.ScriptTarget.ES2020,
148
+ module: ts.ModuleKind.ESNext,
149
+ moduleResolution: ts.ModuleResolutionKind.Bundler ?? ts.ModuleResolutionKind.Node10 ?? ts.ModuleResolutionKind.NodeJs,
150
+ allowJs: true,
151
+ checkJs: true,
152
+ jsx: ts.JsxEmit.Preserve,
153
+ allowNonTsExtensions: true,
154
+ ...(ts.ModuleDetectionKind?.Force === undefined
155
+ ? {}
156
+ : { moduleDetection: ts.ModuleDetectionKind.Force }),
157
+ ...overrides
158
+ });
159
+
160
+ const readConfiguredProject = (ts, configPath, overrides) => {
161
+ if (!configPath) return {
162
+ currentDirectory: undefined,
163
+ fileNames: [],
164
+ options: inferredOptions(ts, overrides),
165
+ diagnostics: []
166
+ };
167
+ const read = ts.readConfigFile(configPath, ts.sys.readFile);
168
+ if (read.error) {
169
+ return {
170
+ currentDirectory: path.dirname(configPath),
171
+ fileNames: [],
172
+ options: inferredOptions(ts, overrides),
173
+ diagnostics: [read.error]
174
+ };
175
+ }
176
+ const parsed = ts.parseJsonConfigFileContent(
177
+ read.config,
178
+ ts.sys,
179
+ path.dirname(configPath),
180
+ overrides,
181
+ configPath
182
+ );
183
+ return {
184
+ currentDirectory: path.dirname(configPath),
185
+ fileNames: parsed.fileNames,
186
+ options: { ...parsed.options, allowNonTsExtensions: true },
187
+ diagnostics: parsed.errors ?? [],
188
+ projectReferences: parsed.projectReferences
189
+ };
190
+ };
191
+
192
+ export class TypeScriptProject {
193
+ constructor(ts, key, configPath, options, documentRegistry) {
194
+ this.ts = ts;
195
+ this.key = key;
196
+ this.configPath = configPath;
197
+ this.options = options;
198
+ this.documents = new Map();
199
+ this.uriByCanonicalFile = new Map();
200
+ this.scriptVersionSequence = 0;
201
+ this.projectVersion = 0;
202
+ this.activeSignal = undefined;
203
+ this.configuration = readConfiguredProject(ts, configPath, options.compilerOptions);
204
+ this.currentDirectory = this.configuration.currentDirectory ?? options.currentDirectory;
205
+ this.configuredFiles = new Set(this.configuration.fileNames.map((file) => this.canonical(file)));
206
+ this.host = this.createHost();
207
+ this.service = ts.createLanguageService(this.host, documentRegistry);
208
+ }
209
+
210
+ canonical(fileName) {
211
+ const normalized = path.normalize(fileName);
212
+ return this.ts.sys.useCaseSensitiveFileNames ? normalized : normalized.toLowerCase();
213
+ }
214
+
215
+ uriForFile(fileName) {
216
+ return this.uriByCanonicalFile.get(this.canonical(fileName)) ?? pathToFileURL(fileName).href;
217
+ }
218
+
219
+ versionForFile(fileName) {
220
+ return this.documents.get(this.canonical(fileName))?.document.version;
221
+ }
222
+
223
+ add(entry) {
224
+ const key = this.canonical(entry.fileName);
225
+ const collision = this.documents.get(key);
226
+ if (collision && collision.uri !== entry.uri) {
227
+ throw new LanguageBridgeError(
228
+ BRIDGE_ERROR_CODES.DOCUMENT_COLLISION,
229
+ "Two virtual documents resolve to the same TypeScript file: " + entry.fileName + ".",
230
+ { details: { firstUri: collision.uri, secondUri: entry.uri, fileName: entry.fileName } }
231
+ );
232
+ }
233
+ entry.scriptKind = languageScriptKind(this.ts, entry.document.languageId, entry.fileName);
234
+ const documentVersion = String(entry.document.version);
235
+ // TypeScript caches snapshots by the host's script-version token. Ravel
236
+ // document versions may restart after a same-path projection is closed and
237
+ // reopened, so every insertion gets a fresh token, including later reopens.
238
+ entry.scriptVersion = documentVersion + ":r" + String(++this.scriptVersionSequence);
239
+ this.documents.set(key, entry);
240
+ this.uriByCanonicalFile.set(key, entry.uri);
241
+ this.projectVersion += 1;
242
+ }
243
+
244
+ remove(entry) {
245
+ const key = this.canonical(entry.fileName);
246
+ this.documents.delete(key);
247
+ this.uriByCanonicalFile.delete(key);
248
+ this.projectVersion += 1;
249
+ }
250
+
251
+ createHost() {
252
+ const ts = this.ts;
253
+ const project = this;
254
+ const sys = ts.sys;
255
+ const virtualEntry = (fileName) => project.documents.get(project.canonical(fileName));
256
+ const virtualDirectoryExists = (directory) => {
257
+ const prefix = project.canonical(directory).replace(/[\\/]+$/, "") + path.sep;
258
+ return [...project.documents.keys()].some((fileName) => fileName.startsWith(prefix));
259
+ };
260
+ const virtualDirectories = (directory) => {
261
+ const prefix = project.canonical(directory).replace(/[\\/]+$/, "") + path.sep;
262
+ const directories = new Set();
263
+ for (const fileName of project.documents.keys()) {
264
+ if (!fileName.startsWith(prefix)) continue;
265
+ const remainder = fileName.slice(prefix.length);
266
+ const separator = remainder.indexOf(path.sep);
267
+ if (separator >= 0) directories.add(path.join(directory, remainder.slice(0, separator)));
268
+ }
269
+ return [...directories];
270
+ };
271
+ const host = {
272
+ getCompilationSettings: () => project.configuration.options,
273
+ getCurrentDirectory: () => project.currentDirectory,
274
+ getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
275
+ getNewLine: () => sys.newLine,
276
+ getProjectReferences: () => project.configuration.projectReferences,
277
+ getProjectVersion: () => String(project.projectVersion),
278
+ getScriptFileNames: () => {
279
+ const configured = project.configuration.fileNames;
280
+ const virtual = [...project.documents.values()].map((entry) => entry.fileName);
281
+ return [...new Set([...configured, ...virtual])];
282
+ },
283
+ getScriptKind: (fileName) => virtualEntry(fileName)?.scriptKind ??
284
+ ts.getScriptKindFromFileName?.(fileName),
285
+ getScriptSnapshot: (fileName) => {
286
+ const entry = virtualEntry(fileName);
287
+ if (entry) return ts.ScriptSnapshot.fromString(entry.document.text);
288
+ const text = sys.readFile(fileName);
289
+ return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text);
290
+ },
291
+ getScriptVersion: (fileName) => {
292
+ const entry = virtualEntry(fileName);
293
+ if (entry) return entry.scriptVersion;
294
+ return String(sys.getModifiedTime?.(fileName)?.valueOf() ?? 0);
295
+ },
296
+ fileExists: (fileName) => virtualEntry(fileName) !== undefined || sys.fileExists(fileName),
297
+ readFile: (fileName, encoding) => virtualEntry(fileName)?.document.text ?? sys.readFile(fileName, encoding),
298
+ readDirectory: (...args) => sys.readDirectory(...args),
299
+ directoryExists: (directory) => virtualDirectoryExists(directory) || sys.directoryExists?.(directory) === true,
300
+ getDirectories: (directory) => [...new Set([
301
+ ...(sys.getDirectories?.(directory) ?? []),
302
+ ...virtualDirectories(directory)
303
+ ])],
304
+ realpath: (fileName) => virtualEntry(fileName)?.fileName ?? sys.realpath?.(fileName) ?? fileName,
305
+ useCaseSensitiveFileNames: () => sys.useCaseSensitiveFileNames,
306
+ getCancellationToken: () => ({
307
+ isCancellationRequested: () => project.activeSignal?.aborted === true,
308
+ throwIfCancellationRequested: () => {
309
+ if (!project.activeSignal?.aborted) return;
310
+ if (typeof ts.OperationCanceledException === "function") throw new ts.OperationCanceledException();
311
+ throw project.activeSignal.reason ?? new Error("TypeScript operation cancelled.");
312
+ }
313
+ })
314
+ };
315
+ if (typeof ts.resolveModuleName === "function") {
316
+ host.resolveModuleNames = (moduleNames, containingFile, _reusedNames, redirectedReference, compilerOptions) =>
317
+ moduleNames.map((moduleName) => ts.resolveModuleName(
318
+ moduleName,
319
+ containingFile,
320
+ compilerOptions ?? project.configuration.options,
321
+ host,
322
+ undefined,
323
+ redirectedReference
324
+ ).resolvedModule);
325
+ host.resolveModuleNameLiterals = (moduleLiterals, containingFile, redirectedReference, compilerOptions) =>
326
+ moduleLiterals.map((literal) => ({
327
+ resolvedModule: ts.resolveModuleName(
328
+ literal.text,
329
+ containingFile,
330
+ compilerOptions ?? project.configuration.options,
331
+ host,
332
+ undefined,
333
+ redirectedReference
334
+ ).resolvedModule
335
+ }));
336
+ }
337
+ return host;
338
+ }
339
+
340
+ dispose() {
341
+ this.service.dispose();
342
+ }
343
+ }