Skip to content

Commit cc2a577

Browse files
authored
feat: add support for non-existent file parsing (#9)
* feat: add support for non-existent file parsing * Create light-turkeys-mix.md * update
1 parent 2dc0a4e commit cc2a577

File tree

6 files changed

+329
-96
lines changed

6 files changed

+329
-96
lines changed

Diff for: .changeset/light-turkeys-mix.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"typescript-eslint-parser-for-extra-files": minor
3+
---
4+
5+
feat: add support for non-existent file parsing

Diff for: src/ts.ts

+109-29
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,35 +36,55 @@ 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

4443
export class TSService {
4544
private readonly watch: ts.WatchOfConfigFile<ts.BuilderProgram>;
4645

46+
private readonly tsconfigPath: string;
47+
4748
public readonly extraFileExtensions: string[];
4849

4950
private currTarget = {
5051
code: "",
5152
filePath: "",
53+
dirMap: new Map<string, { name: string; path: string }>(),
5254
};
5355

5456
private readonly fileWatchCallbacks = new Map<string, () => void>();
5557

5658
public constructor(tsconfigPath: string, extraFileExtensions: string[]) {
59+
this.tsconfigPath = tsconfigPath;
5760
this.extraFileExtensions = extraFileExtensions;
5861
this.watch = this.createWatch(tsconfigPath, extraFileExtensions);
5962
}
6063

6164
public getProgram(code: string, filePath: string): ts.Program {
62-
const lastTargetFilePath = this.currTarget.filePath;
65+
const normalized = normalizeFileName(
66+
toRealFileName(filePath, this.extraFileExtensions)
67+
);
68+
const lastTarget = this.currTarget;
69+
70+
const dirMap = new Map<string, { name: string; path: string }>();
71+
let childPath = normalized;
72+
for (const dirName of iterateDirs(normalized)) {
73+
dirMap.set(dirName, { path: childPath, name: path.basename(childPath) });
74+
childPath = dirName;
75+
}
6376
this.currTarget = {
6477
code,
65-
filePath,
78+
filePath: normalized,
79+
dirMap,
6680
};
67-
const refreshTargetPaths = [filePath, lastTargetFilePath].filter((s) => s);
68-
for (const targetPath of refreshTargetPaths) {
81+
for (const { filePath: targetPath } of [this.currTarget, lastTarget]) {
82+
if (!targetPath) continue;
83+
if (!ts.sys.fileExists(targetPath)) {
84+
// Signal a directory change to request a re-scan of the directory
85+
// because it targets a file that does not actually exist.
86+
this.fileWatchCallbacks.get(normalizeFileName(this.tsconfigPath))?.();
87+
}
6988
getFileNamesIncludingVirtualTSX(
7089
targetPath,
7190
this.extraFileExtensions
@@ -84,9 +103,7 @@ export class TSService {
84103
tsconfigPath: string,
85104
extraFileExtensions: string[]
86105
): ts.WatchOfConfigFile<ts.BuilderProgram> {
87-
const normalizedTsconfigPaths = new Set([
88-
normalizeFileName(toAbsolutePath(tsconfigPath)),
89-
]);
106+
const normalizedTsconfigPaths = new Set([normalizeFileName(tsconfigPath)]);
90107
const watchCompilerHost = ts.createWatchCompilerHost(
91108
tsconfigPath,
92109
{
@@ -120,17 +137,41 @@ export class TSService {
120137
fileExists: watchCompilerHost.fileExists,
121138
// eslint-disable-next-line @typescript-eslint/unbound-method -- Store original
122139
readDirectory: watchCompilerHost.readDirectory,
140+
// eslint-disable-next-line @typescript-eslint/unbound-method -- Store original
141+
directoryExists: watchCompilerHost.directoryExists!,
142+
// eslint-disable-next-line @typescript-eslint/unbound-method -- Store original
143+
getDirectories: watchCompilerHost.getDirectories!,
144+
};
145+
watchCompilerHost.getDirectories = (dirName, ...args) => {
146+
return distinctArray(
147+
...original.getDirectories.call(watchCompilerHost, dirName, ...args),
148+
// Include the path to the target file if the target file does not actually exist.
149+
this.currTarget.dirMap.get(normalizeFileName(dirName))?.name
150+
);
123151
};
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-
];
152+
watchCompilerHost.directoryExists = (dirName, ...args) => {
153+
return (
154+
original.directoryExists.call(watchCompilerHost, dirName, ...args) ||
155+
// Include the path to the target file if the target file does not actually exist.
156+
this.currTarget.dirMap.has(normalizeFileName(dirName))
157+
);
158+
};
159+
watchCompilerHost.readDirectory = (dirName, ...args) => {
160+
const results = original.readDirectory.call(
161+
watchCompilerHost,
162+
dirName,
163+
...args
164+
);
165+
166+
// Include the target file if the target file does not actually exist.
167+
const file = this.currTarget.dirMap.get(normalizeFileName(dirName));
168+
if (file && file.path === this.currTarget.filePath) {
169+
results.push(file.path);
170+
}
171+
172+
return distinctArray(...results).map((result) =>
173+
toVirtualTSXFileName(result, extraFileExtensions)
174+
);
134175
};
135176
watchCompilerHost.readFile = (fileName, ...args) => {
136177
const realFileName = toRealFileName(fileName, extraFileExtensions);
@@ -151,12 +192,14 @@ export class TSService {
151192
if (!code) {
152193
return code;
153194
}
195+
// If it's tsconfig, it will take care of rewriting the `include`.
154196
if (normalizedTsconfigPaths.has(normalized)) {
155197
const configJson = ts.parseConfigFileTextToJson(realFileName, code);
156198
if (!configJson.config) {
157199
return code;
158200
}
159201
if (configJson.config.extends) {
202+
// If it references another tsconfig, rewrite the `include` for that file as well.
160203
for (const extendConfigPath of [configJson.config.extends].flat()) {
161204
normalizedTsconfigPaths.add(
162205
normalizeFileName(
@@ -184,12 +227,28 @@ export class TSService {
184227
});
185228
};
186229
// Modify it so that it can be determined that the virtual file actually exists.
187-
watchCompilerHost.fileExists = (fileName, ...args) =>
188-
original.fileExists.call(
230+
watchCompilerHost.fileExists = (fileName, ...args) => {
231+
const normalizedFileName = normalizeFileName(fileName);
232+
233+
// Even if it is actually a file, if it is specified as a directory to the target file,
234+
// it is assumed that it does not exist as a file.
235+
if (this.currTarget.dirMap.has(normalizedFileName)) {
236+
return false;
237+
}
238+
const normalizedRealFileName = toRealFileName(
239+
normalizedFileName,
240+
extraFileExtensions
241+
);
242+
if (this.currTarget.filePath === normalizedRealFileName) {
243+
// It is the file currently being parsed.
244+
return true;
245+
}
246+
return original.fileExists.call(
189247
watchCompilerHost,
190248
toRealFileName(fileName, extraFileExtensions),
191249
...args
192250
);
251+
};
193252

194253
// It keeps a callback to mark the parsed file as changed so that it can be reparsed.
195254
watchCompilerHost.watchFile = (fileName, callback) => {
@@ -205,11 +264,13 @@ export class TSService {
205264
};
206265
};
207266
// Use watchCompilerHost but don't actually watch the files and directories.
208-
watchCompilerHost.watchDirectory = () => ({
209-
close() {
210-
// noop
211-
},
212-
});
267+
watchCompilerHost.watchDirectory = () => {
268+
return {
269+
close: () => {
270+
// noop
271+
},
272+
};
273+
};
213274

214275
/**
215276
* It heavily references typescript-eslint.
@@ -278,13 +339,32 @@ function normalizeFileName(fileName: string) {
278339
normalized = normalized.slice(0, -1);
279340
}
280341
if (ts.sys.useCaseSensitiveFileNames) {
281-
return normalized;
342+
return toAbsolutePath(normalized, null);
282343
}
283-
return normalized.toLowerCase();
344+
return toAbsolutePath(normalized.toLowerCase(), null);
284345
}
285346

286-
function toAbsolutePath(filePath: string, baseDir?: string) {
347+
function toAbsolutePath(filePath: string, baseDir: string | null) {
287348
return path.isAbsolute(filePath)
288349
? filePath
289350
: path.join(baseDir || process.cwd(), filePath);
290351
}
352+
353+
function* iterateDirs(filePath: string) {
354+
let target = filePath;
355+
let parent: string;
356+
while ((parent = path.dirname(target)) !== target) {
357+
yield parent;
358+
target = parent;
359+
}
360+
}
361+
362+
function distinctArray(...list: (string | null | undefined)[]) {
363+
return [
364+
...new Set(
365+
ts.sys.useCaseSensitiveFileNames
366+
? list.map((s) => s?.toLowerCase())
367+
: list
368+
),
369+
].filter((s): s is string => s != null);
370+
}

Diff for: tests/src/types.ts

+5-66
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");

Diff for: tests/src/fixtures.ts renamed to tests/src/utils/fixtures.ts

+5-1
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)