Skip to content

Commit 74c07a3

Browse files
committed
Introduce typed path invariants throughout tsc
Replace ambiguous string path contracts with a typed lattice for rooted files, rooted directories, normalized relative paths, and canonical path keys. Keep canonical identity as a one-way sink while retaining presentation spelling wherever diagnostics, watches, symlinks, or protocol responses need it. Carry those invariants through compiler inputs and outputs, module resolution, project snapshots, language-service hosts, VFS operations, source maps, LSP conversion, and the JavaScript API. Separate raw compiler option wire values from finalized rooted options, and centralize explicit normalization, rooting, and case-sensitivity boundaries. This commit consolidates the exploratory migration into one reviewable rewrite after the independently portable fixes. It also adapts those fixes to the typed representation and retains the two newer main changes, including auto-import completion retries and tuple completion filtering.
1 parent 39444da commit 74c07a3

451 files changed

Lines changed: 16837 additions & 9010 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Herebyfile.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,7 @@ const enumDefs = [
419419
{ name: "NewLineKind", goPrefix: "NewLineKind", goFile: "tsc/internal/core/compileroptions.go", outDir: "packages/typescript/src/enums" },
420420
{ name: "JsxEmit", goPrefix: "JsxEmit", goFile: "tsc/internal/core/compileroptions.go", outDir: "packages/typescript/src/enums" },
421421
{ name: "ScriptKind", goPrefix: "ScriptKind", goFile: "tsc/internal/core/scriptkind.go", outDir: "packages/typescript/src/enums" },
422+
{ name: "CaseSensitivity", goPrefix: "Case", goFile: "tsc/internal/tspath/path.go", outDir: "packages/typescript/src/enums" },
422423
{ name: "TokenFlags", goPrefix: "TokenFlags", goFile: "tsc/internal/ast/tokenflags.go", outDir: "packages/typescript/src/enums" },
423424
{ name: "DiagnosticDirectivePolicy", goPrefix: "MappedDiagnosticDirectivePolicy", goFile: "tsc/internal/ast/ast.go", outDir: "packages/typescript/src/enums" },
424425
{ name: "SpanMapKind", goPrefix: "Kind", goFile: "tsc/internal/spanmap/spanmap.go", outDir: "packages/typescript/src/enums" },

packages/typescript/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@
5050
"@typescript/source": "./src/api/fs.ts",
5151
"default": "./dist/api/fs.js"
5252
},
53+
"./unstable/path": {
54+
"@typescript/source": "./src/api/typedPaths.ts",
55+
"default": "./dist/api/typedPaths.js"
56+
},
5357
"./unstable/proto": {
5458
"@typescript/source": "./src/api/proto.ts",
5559
"default": "./dist/api/proto.js"

packages/typescript/src/api/async/api.ts

Lines changed: 97 additions & 58 deletions
Large diffs are not rendered by default.

packages/typescript/src/api/async/client.ts

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import {
99
} from "#vscode-jsonrpc/node";
1010
import type { ChildProcess } from "node:child_process";
1111
import type { Socket } from "node:net";
12+
import type {
13+
RootedDirectoryPath,
14+
RootedFilePath,
15+
RootedPath,
16+
} from "../../ast/index.ts";
1217
import {
1318
type FileSystem,
1419
fsCallbackNames,
@@ -141,32 +146,45 @@ export class Client {
141146
private registerFSCallbacks(connection: MessageConnection, fs: FileSystem | undefined): void {
142147
if (!fs) return;
143148
for (const name of fsCallbackNames) {
144-
if (name === "writeFile") {
145-
if (!fs.writeFile) continue;
146-
const callback = fs.writeFile;
147-
148-
const requestType = new RequestType<{ path: string; data: string; }, unknown, void>(name);
149-
connection.onRequest(requestType, (arg: { path: string; data: string; }) => {
150-
callback(arg.path, arg.data);
151-
return null;
152-
});
153-
154-
continue;
155-
}
156-
157-
const callback = fs[name];
158-
if (callback) {
159-
const requestType = new RequestType<unknown, unknown, void>(name);
160-
connection.onRequest(requestType, (arg: unknown) => {
161-
const result = callback(arg as any);
162-
if (name === "readFile") {
163-
// readFile has 3 returns: string (content), null (not found), undefined (fall back).
164-
// JSON-RPC can't distinguish null from undefined, so wrap in object.
165-
if (result === undefined) return null;
166-
return { content: result };
149+
switch (name) {
150+
case "readFile":
151+
if (fs.readFile) {
152+
connection.onRequest(new RequestType<RootedFilePath, unknown, void>(name), fileName => {
153+
const result = fs.readFile!(fileName);
154+
// readFile has 3 returns: string (content), null (not found), undefined (fall back).
155+
// JSON-RPC can't distinguish null from undefined, so wrap in object.
156+
return result === undefined ? null : { content: result };
157+
});
158+
}
159+
break;
160+
case "fileExists":
161+
if (fs.fileExists) {
162+
connection.onRequest(new RequestType<RootedFilePath, unknown, void>(name), fileName => fs.fileExists!(fileName) ?? null);
163+
}
164+
break;
165+
case "directoryExists":
166+
if (fs.directoryExists) {
167+
connection.onRequest(new RequestType<RootedDirectoryPath, unknown, void>(name), directoryName => fs.directoryExists!(directoryName) ?? null);
168+
}
169+
break;
170+
case "getAccessibleEntries":
171+
if (fs.getAccessibleEntries) {
172+
connection.onRequest(new RequestType<RootedDirectoryPath, unknown, void>(name), directoryName => fs.getAccessibleEntries!(directoryName) ?? null);
173+
}
174+
break;
175+
case "realpath":
176+
if (fs.realpath) {
177+
connection.onRequest(new RequestType<RootedPath, unknown, void>(name), path => fs.realpath!(path) ?? null);
178+
}
179+
break;
180+
case "writeFile":
181+
if (fs.writeFile) {
182+
connection.onRequest(new RequestType<{ path: RootedFilePath; data: string; }, unknown, void>(name), arg => {
183+
fs.writeFile!(arg.path, arg.data);
184+
return null;
185+
});
167186
}
168-
return result ?? null;
169-
});
187+
break;
170188
}
171189
}
172190
}

packages/typescript/src/api/async/types.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import type {
88
NamedTupleMember,
99
ParameterDeclaration,
1010
} from "../../ast/ast.ts";
11+
import type {
12+
RootedDirectoryPath,
13+
RootedFilePath,
14+
} from "../../ast/index.ts";
1115
import type { Diagnostic } from "../proto.ts";
1216
import type {
1317
NodeHandle,
@@ -387,26 +391,26 @@ export interface CompletionInfo {
387391
}
388392

389393
export interface FormatDiagnosticsHost {
390-
getCurrentDirectory(): string;
394+
getCurrentDirectory(): RootedDirectoryPath;
391395
getCanonicalFileName(fileName: string): string;
392396
getNewLine(): string;
393397
}
394398

395399
export interface EmitOutputFile {
396400
readonly text: string;
397-
readonly sourceFileName?: string | undefined;
401+
readonly sourceFileName?: RootedFilePath | undefined;
398402
}
399403

400404
export interface EmitResult {
401405
readonly emitSkipped: boolean;
402406
readonly diagnostics: readonly Diagnostic[];
403-
readonly emittedFiles: readonly string[];
407+
readonly emittedFiles: readonly RootedFilePath[];
404408
}
405409

406410
export interface EmitOutput {
407411
readonly emitSkipped: boolean;
408412
readonly diagnostics: readonly Diagnostic[];
409-
readonly outputFiles: ReadonlyMap<string, EmitOutputFile>;
413+
readonly outputFiles: ReadonlyMap<RootedFilePath, EmitOutputFile>;
410414
}
411415

412416
export interface ImportSymbolAction {

packages/typescript/src/api/diagnosticFormatter.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
import type {
2+
RootedDirectoryPath,
3+
RootedFilePath,
4+
} from "../ast/index.ts";
15
import { convertToRelativePath } from "./path.ts";
26
import type { DiagnosticResponse as Diagnostic } from "./proto.generated.ts";
37

48
export interface FormatDiagnosticsHost {
5-
getCurrentDirectory(): string;
9+
getCurrentDirectory(): RootedDirectoryPath;
610
getCanonicalFileName(fileName: string): string;
711
getNewLine(): string;
812
}
@@ -70,7 +74,7 @@ function flattenDiagnosticMessage(diagnostic: Diagnostic, newLine: string, inden
7074
return result;
7175
}
7276

73-
function relativeFileName(fileName: string, host: FormatDiagnosticsHost): string {
77+
function relativeFileName(fileName: RootedFilePath, host: FormatDiagnosticsHost): string {
7478
return convertToRelativePath(
7579
fileName,
7680
host.getCurrentDirectory(),

packages/typescript/src/api/fs.ts

Lines changed: 48 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,42 @@
1-
import { getPathComponents } from "./path.ts";
1+
import type {
2+
RootedDirectoryPath,
3+
RootedFilePath,
4+
RootedPath,
5+
} from "../ast/index.ts";
6+
import {
7+
getPathComponents,
8+
toRootedFilePath,
9+
} from "./path.ts";
210

311
export interface FileSystemEntries {
412
files: string[];
513
directories: string[];
614
}
715

816
export interface FileSystem {
9-
directoryExists?: (directoryName: string) => boolean | undefined;
10-
fileExists?: (fileName: string) => boolean | undefined;
11-
getAccessibleEntries?: (directoryName: string) => FileSystemEntries | undefined;
17+
directoryExists?: (directoryName: RootedDirectoryPath) => boolean | undefined;
18+
fileExists?: (fileName: RootedFilePath) => boolean | undefined;
19+
getAccessibleEntries?: (directoryName: RootedDirectoryPath) => FileSystemEntries | undefined;
1220
/**
1321
* Read a file's content.
1422
* - Return the file content as a `string` (including `""` for empty files).
1523
* - Return `null` to indicate the file does not exist (without falling back to the real FS).
1624
* - Return `undefined` to fall back to the real filesystem.
1725
*/
18-
readFile?: (fileName: string) => string | null | undefined;
19-
realpath?: (path: string) => string | undefined;
20-
writeFile?: (path: string, content: string) => void;
21-
removeFile?: (path: string) => void;
26+
readFile?: (fileName: RootedFilePath) => string | null | undefined;
27+
realpath?: (path: RootedPath) => RootedPath | undefined;
28+
writeFile?: (path: RootedFilePath, content: string) => void;
29+
removeFile?: (path: RootedFilePath) => void;
30+
}
31+
32+
export interface VirtualFileSystem extends FileSystem {
33+
directoryExists(directoryName: RootedDirectoryPath): boolean;
34+
fileExists(fileName: RootedFilePath): boolean;
35+
getAccessibleEntries(directoryName: RootedDirectoryPath): FileSystemEntries | undefined;
36+
readFile(fileName: RootedFilePath): string | undefined;
37+
realpath(path: RootedPath): RootedPath;
38+
writeFile(path: RootedFilePath, content: string): void;
39+
removeFile(path: RootedFilePath): void;
2240
}
2341

2442
/** The callback names supported by the Go server for virtual FS delegation. */
@@ -35,15 +53,16 @@ interface VFile {
3553

3654
type VNode = VDirectory | VFile;
3755

38-
export function createVirtualFileSystem(files: Record<string, string>): FileSystem {
56+
export function createVirtualFileSystem(files: Record<string, string>): VirtualFileSystem {
3957
const root: VDirectory = {
4058
type: "directory",
4159
children: {},
4260
};
43-
const content: Record<string, string> = {};
61+
const content = new Map<RootedFilePath, string>();
4462

45-
for (const filePath of Object.keys(files)) {
46-
content[filePath] = files[filePath];
63+
for (const [rawFilePath, data] of Object.entries(files)) {
64+
const filePath = toRootedFilePath(rawFilePath, undefined);
65+
content.set(filePath, data);
4766
addToTree(filePath);
4867
}
4968

@@ -57,11 +76,14 @@ export function createVirtualFileSystem(files: Record<string, string>): FileSyst
5776
removeFile,
5877
};
5978

60-
function getNodeFromPath(path: string): VNode | undefined {
79+
function getNodeFromPath(path: RootedPath): VNode | undefined {
6180
if (!path || path === "/") {
6281
return root;
6382
}
64-
const segments = getPathComponents(path).slice(1);
83+
return getNodeFromSegments(getPathComponents(path).slice(1));
84+
}
85+
86+
function getNodeFromSegments(segments: readonly string[]): VNode | undefined {
6587
let current: VNode = root;
6688
for (const segment of segments) {
6789
if (current.type !== "directory") {
@@ -90,7 +112,7 @@ export function createVirtualFileSystem(files: Record<string, string>): FileSyst
90112
return current;
91113
}
92114

93-
function addToTree(path: string): void {
115+
function addToTree(path: RootedFilePath): void {
94116
const segments = getPathComponents(path).slice(1);
95117
if (segments.length === 0) {
96118
throw new Error(`Invalid file path: "${path}"`);
@@ -100,32 +122,32 @@ export function createVirtualFileSystem(files: Record<string, string>): FileSyst
100122
dirNode.children[filename] = { type: "file" };
101123
}
102124

103-
function writeFile(path: string, data: string): void {
104-
content[path] = data;
125+
function writeFile(path: RootedFilePath, data: string): void {
126+
content.set(path, data);
105127
addToTree(path);
106128
}
107129

108-
function removeFile(path: string): void {
109-
delete content[path];
130+
function removeFile(path: RootedFilePath): void {
131+
content.delete(path);
110132
const segments = getPathComponents(path).slice(1);
111133
if (segments.length === 0) return;
112134
const filename = segments.pop()!;
113-
const dirNode = getNodeFromPath("/" + segments.join("/"));
135+
const dirNode = getNodeFromSegments(segments);
114136
if (dirNode && dirNode.type === "directory") {
115137
delete dirNode.children[filename];
116138
}
117139
}
118140

119-
function directoryExists(directoryName: string): boolean {
141+
function directoryExists(directoryName: RootedDirectoryPath): boolean {
120142
const node = getNodeFromPath(directoryName);
121143
return !!node && node.type === "directory";
122144
}
123145

124-
function fileExists(fileName: string): boolean {
125-
return fileName in content;
146+
function fileExists(fileName: RootedFilePath): boolean {
147+
return content.has(fileName);
126148
}
127149

128-
function getAccessibleEntries(directoryName: string): FileSystemEntries | undefined {
150+
function getAccessibleEntries(directoryName: RootedDirectoryPath): FileSystemEntries | undefined {
129151
const node = getNodeFromPath(directoryName);
130152
if (!node || node.type !== "directory") {
131153
return undefined;
@@ -143,10 +165,7 @@ export function createVirtualFileSystem(files: Record<string, string>): FileSyst
143165
return { files: fileEntries, directories };
144166
}
145167

146-
function readFile(fileName: string): string | undefined {
147-
if (fileName in content) {
148-
return content[fileName];
149-
}
150-
return undefined;
168+
function readFile(fileName: RootedFilePath): string | undefined {
169+
return content.get(fileName);
151170
}
152171
}

packages/typescript/src/api/node/node.infrastructure.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
type FileReference,
33
ModifierFlags,
44
type Node,
5+
type PathKey,
56
SyntaxKind,
67
} from "../../ast/index.ts";
78
import type { TimingCollector } from "../timing.ts";
@@ -52,7 +53,7 @@ export interface SourceFileInfo {
5253
readonly _offsetStructuredData: number;
5354
readonly _decoder: TextDecoder;
5455
nodes: any[];
55-
readonly path?: string;
56+
readonly path?: PathKey;
5657
/**
5758
* The timing collector that per-node materialization is reported into, and
5859
* that this source file registered itself with when fetched. Present only

0 commit comments

Comments
 (0)