@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 +21 -0
- package/README.md +99 -0
- package/package.json +49 -0
- package/src/index.d.ts +85 -0
- package/src/index.js +46 -0
- package/src/normalize.js +227 -0
- package/src/typescript-bridge.js +478 -0
- package/src/typescript-project.js +343 -0
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";
|
package/src/normalize.js
ADDED
|
@@ -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
|
+
}
|