Skip to content

Commit da5e31e

Browse files
committed
feat: add support for non-existent file parsing
1 parent 2dc0a4e commit da5e31e

File tree

5 files changed

+342
-95
lines changed

5 files changed

+342
-95
lines changed

src/ts.ts

Lines changed: 127 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ export class TSServiceManager {
1818

1919
public getProgram(code: string, options: ProgramOptions): ts.Program {
2020
const tsconfigPath = options.project;
21-
const fileName = normalizeFileName(toAbsolutePath(options.filePath));
2221
const extraFileExtensions = [...new Set(options.extraFileExtensions)];
2322

2423
let serviceList = this.tsServices.get(tsconfigPath);
@@ -37,7 +36,7 @@ export class TSServiceManager {
3736
serviceList.unshift(service);
3837
}
3938

40-
return service.getProgram(code, fileName);
39+
return service.getProgram(code, options.filePath);
4140
}
4241
}
4342

@@ -49,22 +48,59 @@ export class TSService {
4948
private currTarget = {
5049
code: "",
5150
filePath: "",
51+
dirMap: new Map<string, { name: string; path: string }>(),
5252
};
5353

5454
private readonly fileWatchCallbacks = new Map<string, () => void>();
5555

56+
private readonly dirWatchCallbacks = new Map<string, () => void>();
57+
5658
public constructor(tsconfigPath: string, extraFileExtensions: string[]) {
5759
this.extraFileExtensions = extraFileExtensions;
5860
this.watch = this.createWatch(tsconfigPath, extraFileExtensions);
5961
}
6062

6163
public getProgram(code: string, filePath: string): ts.Program {
62-
const lastTargetFilePath = this.currTarget.filePath;
64+
const normalized = normalizeFileName(
65+
toRealFileName(filePath, this.extraFileExtensions)
66+
);
67+
const lastTarget = this.currTarget;
68+
69+
const dirMap = new Map<string, { name: string; path: string }>();
70+
let childPath = normalized;
71+
for (const dirName of iterateDirs(normalized)) {
72+
dirMap.set(dirName, { path: childPath, name: path.basename(childPath) });
73+
childPath = dirName;
74+
}
6375
this.currTarget = {
6476
code,
65-
filePath,
77+
filePath: normalized,
78+
dirMap,
6679
};
67-
const refreshTargetPaths = [filePath, lastTargetFilePath].filter((s) => s);
80+
for (const { filePath: targetPath, dirMap: map } of [
81+
this.currTarget,
82+
lastTarget,
83+
]) {
84+
if (!targetPath) continue;
85+
if (ts.sys.fileExists(targetPath)) {
86+
getFileNamesIncludingVirtualTSX(
87+
targetPath,
88+
this.extraFileExtensions
89+
).forEach((vFilePath) => {
90+
this.fileWatchCallbacks.get(vFilePath)?.();
91+
});
92+
} else {
93+
// Signal a directory change to request a re-scan of the directory
94+
// because it targets a file that does not actually exist.
95+
for (const dirName of map.keys()) {
96+
this.dirWatchCallbacks.get(dirName)?.();
97+
}
98+
}
99+
}
100+
101+
const refreshTargetPaths = [normalized, lastTarget.filePath].filter(
102+
(s) => s
103+
);
68104
for (const targetPath of refreshTargetPaths) {
69105
getFileNamesIncludingVirtualTSX(
70106
targetPath,
@@ -84,9 +120,7 @@ export class TSService {
84120
tsconfigPath: string,
85121
extraFileExtensions: string[]
86122
): ts.WatchOfConfigFile<ts.BuilderProgram> {
87-
const normalizedTsconfigPaths = new Set([
88-
normalizeFileName(toAbsolutePath(tsconfigPath)),
89-
]);
123+
const normalizedTsconfigPaths = new Set([normalizeFileName(tsconfigPath)]);
90124
const watchCompilerHost = ts.createWatchCompilerHost(
91125
tsconfigPath,
92126
{
@@ -120,17 +154,41 @@ export class TSService {
120154
fileExists: watchCompilerHost.fileExists,
121155
// eslint-disable-next-line @typescript-eslint/unbound-method -- Store original
122156
readDirectory: watchCompilerHost.readDirectory,
157+
// eslint-disable-next-line @typescript-eslint/unbound-method -- Store original
158+
directoryExists: watchCompilerHost.directoryExists!,
159+
// eslint-disable-next-line @typescript-eslint/unbound-method -- Store original
160+
getDirectories: watchCompilerHost.getDirectories!,
161+
};
162+
watchCompilerHost.getDirectories = (dirName, ...args) => {
163+
return distinctArray(
164+
...original.getDirectories.call(watchCompilerHost, dirName, ...args),
165+
// Include the path to the target file if the target file does not actually exist.
166+
this.currTarget.dirMap.get(normalizeFileName(dirName))?.name
167+
);
168+
};
169+
watchCompilerHost.directoryExists = (dirName, ...args) => {
170+
return (
171+
original.directoryExists.call(watchCompilerHost, dirName, ...args) ||
172+
// Include the path to the target file if the target file does not actually exist.
173+
this.currTarget.dirMap.has(normalizeFileName(dirName))
174+
);
123175
};
124-
watchCompilerHost.readDirectory = (...args) => {
125-
const results = original.readDirectory.call(watchCompilerHost, ...args);
126-
127-
return [
128-
...new Set(
129-
results.map((result) =>
130-
toVirtualTSXFileName(result, extraFileExtensions)
131-
)
132-
),
133-
];
176+
watchCompilerHost.readDirectory = (dirName, ...args) => {
177+
const results = original.readDirectory.call(
178+
watchCompilerHost,
179+
dirName,
180+
...args
181+
);
182+
183+
// Include the target file if the target file does not actually exist.
184+
const file = this.currTarget.dirMap.get(normalizeFileName(dirName));
185+
if (file && file.path === this.currTarget.filePath) {
186+
results.push(file.path);
187+
}
188+
189+
return distinctArray(...results).map((result) =>
190+
toVirtualTSXFileName(result, extraFileExtensions)
191+
);
134192
};
135193
watchCompilerHost.readFile = (fileName, ...args) => {
136194
const realFileName = toRealFileName(fileName, extraFileExtensions);
@@ -151,12 +209,14 @@ export class TSService {
151209
if (!code) {
152210
return code;
153211
}
212+
// If it's tsconfig, it will take care of rewriting the `include`.
154213
if (normalizedTsconfigPaths.has(normalized)) {
155214
const configJson = ts.parseConfigFileTextToJson(realFileName, code);
156215
if (!configJson.config) {
157216
return code;
158217
}
159218
if (configJson.config.extends) {
219+
// If it references another tsconfig, rewrite the `include` for that file as well.
160220
for (const extendConfigPath of [configJson.config.extends].flat()) {
161221
normalizedTsconfigPaths.add(
162222
normalizeFileName(
@@ -184,12 +244,28 @@ export class TSService {
184244
});
185245
};
186246
// Modify it so that it can be determined that the virtual file actually exists.
187-
watchCompilerHost.fileExists = (fileName, ...args) =>
188-
original.fileExists.call(
247+
watchCompilerHost.fileExists = (fileName, ...args) => {
248+
const normalizedFileName = normalizeFileName(fileName);
249+
250+
// Even if it is actually a file, if it is specified as a directory to the target file,
251+
// it is assumed that it does not exist as a file.
252+
if (this.currTarget.dirMap.has(normalizedFileName)) {
253+
return false;
254+
}
255+
const normalizedRealFileName = toRealFileName(
256+
normalizedFileName,
257+
extraFileExtensions
258+
);
259+
if (this.currTarget.filePath === normalizedRealFileName) {
260+
// It is the file currently being parsed.
261+
return true;
262+
}
263+
return original.fileExists.call(
189264
watchCompilerHost,
190265
toRealFileName(fileName, extraFileExtensions),
191266
...args
192267
);
268+
};
193269

194270
// It keeps a callback to mark the parsed file as changed so that it can be reparsed.
195271
watchCompilerHost.watchFile = (fileName, callback) => {
@@ -205,11 +281,15 @@ export class TSService {
205281
};
206282
};
207283
// Use watchCompilerHost but don't actually watch the files and directories.
208-
watchCompilerHost.watchDirectory = () => ({
209-
close() {
210-
// noop
211-
},
212-
});
284+
watchCompilerHost.watchDirectory = (dirName, callback) => {
285+
const normalized = normalizeFileName(dirName);
286+
this.dirWatchCallbacks.set(normalized, () => callback(dirName));
287+
return {
288+
close: () => {
289+
this.dirWatchCallbacks.delete(normalized);
290+
},
291+
};
292+
};
213293

214294
/**
215295
* It heavily references typescript-eslint.
@@ -278,13 +358,32 @@ function normalizeFileName(fileName: string) {
278358
normalized = normalized.slice(0, -1);
279359
}
280360
if (ts.sys.useCaseSensitiveFileNames) {
281-
return normalized;
361+
return toAbsolutePath(normalized, null);
282362
}
283-
return normalized.toLowerCase();
363+
return toAbsolutePath(normalized.toLowerCase(), null);
284364
}
285365

286-
function toAbsolutePath(filePath: string, baseDir?: string) {
366+
function toAbsolutePath(filePath: string, baseDir: string | null) {
287367
return path.isAbsolute(filePath)
288368
? filePath
289369
: path.join(baseDir || process.cwd(), filePath);
290370
}
371+
372+
function* iterateDirs(filePath: string) {
373+
let target = filePath;
374+
let parent: string;
375+
while ((parent = path.dirname(target)) !== target) {
376+
yield parent;
377+
target = parent;
378+
}
379+
}
380+
381+
function distinctArray(...list: (string | null | undefined)[]) {
382+
return [
383+
...new Set(
384+
ts.sys.useCaseSensitiveFileNames
385+
? list.map((s) => s?.toLowerCase())
386+
: list
387+
),
388+
].filter((s): s is string => s != null);
389+
}

tests/src/types.ts

Lines changed: 5 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,16 @@ import * as vueParser from "vue-eslint-parser";
1212
import * as svelteParser from "svelte-eslint-parser";
1313
import * as astroParser from "astro-eslint-parser";
1414
import * as tsParser from "../../src";
15-
import type * as tsEslintParser from "@typescript-eslint/parser";
1615
import semver from "semver";
1716
import assert from "assert";
18-
import { iterateFixtures } from "./fixtures";
17+
import { iterateFixtures } from "./utils/fixtures";
18+
import { buildTypes } from "./utils/utils";
1919

2020
//------------------------------------------------------------------------------
2121
// Helpers
2222
//------------------------------------------------------------------------------
2323

2424
const ROOT = path.join(__dirname, "../fixtures/types");
25-
const PROJECT_ROOT = path.join(__dirname, "../..");
2625
const PARSER_OPTIONS = {
2726
comment: true,
2827
ecmaVersion: 2020,
@@ -32,68 +31,6 @@ const PARSER_OPTIONS = {
3231
parser: tsParser,
3332
};
3433

35-
function buildTypes(
36-
input: string,
37-
result: ReturnType<typeof tsEslintParser.parseForESLint>
38-
) {
39-
const tsNodeMap = result.services.esTreeNodeToTSNodeMap;
40-
const checker =
41-
result.services.program && result.services.program.getTypeChecker();
42-
43-
const checked = new Set();
44-
45-
const lines = input.split(/\r?\n/);
46-
const types: string[][] = [];
47-
48-
function addType(node: any) {
49-
const tsNode = tsNodeMap.get(node);
50-
const type = checker.getTypeAtLocation(tsNode);
51-
const typeText = checker.typeToString(type);
52-
const lineTypes =
53-
types[node.loc.start.line - 1] || (types[node.loc.start.line - 1] = []);
54-
if (node.type === "Identifier") {
55-
lineTypes.push(`${node.name}: ${typeText}`);
56-
} else {
57-
lineTypes.push(`${input.slice(...node.range)}: ${typeText}`);
58-
}
59-
}
60-
61-
vueParser.AST.traverseNodes(result.ast as any, {
62-
visitorKeys: result.visitorKeys as any,
63-
enterNode(node, parent) {
64-
if (checked.has(parent)) {
65-
checked.add(node);
66-
return;
67-
}
68-
69-
if (
70-
node.type === "CallExpression" ||
71-
node.type === "Identifier" ||
72-
node.type === "MemberExpression"
73-
) {
74-
addType(node);
75-
checked.add(node);
76-
}
77-
},
78-
leaveNode() {
79-
// noop
80-
},
81-
});
82-
return lines
83-
.map((l, i) => {
84-
if (!types[i]) {
85-
return l;
86-
}
87-
return `${l} // ${types[i].join(", ").replace(/\n\s*/g, " ")}`;
88-
})
89-
.join("\n")
90-
.replace(new RegExp(escapeRegExp(PROJECT_ROOT), "gu"), "");
91-
}
92-
93-
function escapeRegExp(string: string) {
94-
return string.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
95-
}
96-
9734
//------------------------------------------------------------------------------
9835
// Main
9936
//------------------------------------------------------------------------------
@@ -132,7 +69,7 @@ describe("Template Types", () => {
13269
continue;
13370
}
13471

135-
describe(`'test/fixtures/ast/${name}/${path.basename(sourcePath)}'`, () => {
72+
describe(`'test/fixtures/${name}/${path.basename(sourcePath)}'`, () => {
13673
it("should be parsed to valid Types.", () => {
13774
const result =
13875
path.extname(sourcePath) === ".vue"
@@ -141,6 +78,8 @@ describe("Template Types", () => {
14178
? svelteParser.parseForESLint(source, options)
14279
: path.extname(sourcePath) === ".astro"
14380
? astroParser.parseForESLint(source, options)
81+
: path.extname(sourcePath) === ".ts"
82+
? tsParser.parseForESLint(source, options)
14483
: vueParser.parseForESLint(source, options);
14584
const actual = buildTypes(source, result as any);
14685
const resultPath = sourcePath.replace(/source\.([a-z]+)$/u, "types.$1");

tests/src/fixtures.ts renamed to tests/src/utils/fixtures.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ export function iterateFixtures(baseDir: string): Iterable<{
77
sourcePath: string;
88
tsconfigPath: string;
99
}> {
10-
return iterateFixturesWithTsConfig(baseDir, "");
10+
const tsconfigPathCandidate = path.join(baseDir, `tsconfig.json`);
11+
const tsconfigPath = fs.existsSync(tsconfigPathCandidate)
12+
? tsconfigPathCandidate
13+
: "";
14+
return iterateFixturesWithTsConfig(baseDir, tsconfigPath);
1115
}
1216

1317
function* iterateFixturesWithTsConfig(

0 commit comments

Comments
 (0)