Skip to content

feat: add support for non-existent file parsing #9

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Nov 3, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/light-turkeys-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"typescript-eslint-parser-for-extra-files": minor
---

feat: add support for non-existent file parsing
138 changes: 109 additions & 29 deletions src/ts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ export class TSServiceManager {

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

let serviceList = this.tsServices.get(tsconfigPath);
Expand All @@ -37,35 +36,55 @@ export class TSServiceManager {
serviceList.unshift(service);
}

return service.getProgram(code, fileName);
return service.getProgram(code, options.filePath);
}
}

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

private readonly tsconfigPath: string;

public readonly extraFileExtensions: string[];

private currTarget = {
code: "",
filePath: "",
dirMap: new Map<string, { name: string; path: string }>(),
};

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

public constructor(tsconfigPath: string, extraFileExtensions: string[]) {
this.tsconfigPath = tsconfigPath;
this.extraFileExtensions = extraFileExtensions;
this.watch = this.createWatch(tsconfigPath, extraFileExtensions);
}

public getProgram(code: string, filePath: string): ts.Program {
const lastTargetFilePath = this.currTarget.filePath;
const normalized = normalizeFileName(
toRealFileName(filePath, this.extraFileExtensions)
);
const lastTarget = this.currTarget;

const dirMap = new Map<string, { name: string; path: string }>();
let childPath = normalized;
for (const dirName of iterateDirs(normalized)) {
dirMap.set(dirName, { path: childPath, name: path.basename(childPath) });
childPath = dirName;
}
this.currTarget = {
code,
filePath,
filePath: normalized,
dirMap,
};
const refreshTargetPaths = [filePath, lastTargetFilePath].filter((s) => s);
for (const targetPath of refreshTargetPaths) {
for (const { filePath: targetPath } of [this.currTarget, lastTarget]) {
if (!targetPath) continue;
if (!ts.sys.fileExists(targetPath)) {
// Signal a directory change to request a re-scan of the directory
// because it targets a file that does not actually exist.
this.fileWatchCallbacks.get(normalizeFileName(this.tsconfigPath))?.();
}
getFileNamesIncludingVirtualTSX(
targetPath,
this.extraFileExtensions
Expand All @@ -84,9 +103,7 @@ export class TSService {
tsconfigPath: string,
extraFileExtensions: string[]
): ts.WatchOfConfigFile<ts.BuilderProgram> {
const normalizedTsconfigPaths = new Set([
normalizeFileName(toAbsolutePath(tsconfigPath)),
]);
const normalizedTsconfigPaths = new Set([normalizeFileName(tsconfigPath)]);
const watchCompilerHost = ts.createWatchCompilerHost(
tsconfigPath,
{
Expand Down Expand Up @@ -120,17 +137,41 @@ export class TSService {
fileExists: watchCompilerHost.fileExists,
// eslint-disable-next-line @typescript-eslint/unbound-method -- Store original
readDirectory: watchCompilerHost.readDirectory,
// eslint-disable-next-line @typescript-eslint/unbound-method -- Store original
directoryExists: watchCompilerHost.directoryExists!,
// eslint-disable-next-line @typescript-eslint/unbound-method -- Store original
getDirectories: watchCompilerHost.getDirectories!,
};
watchCompilerHost.getDirectories = (dirName, ...args) => {
return distinctArray(
...original.getDirectories.call(watchCompilerHost, dirName, ...args),
// Include the path to the target file if the target file does not actually exist.
this.currTarget.dirMap.get(normalizeFileName(dirName))?.name
);
};
watchCompilerHost.readDirectory = (...args) => {
const results = original.readDirectory.call(watchCompilerHost, ...args);

return [
...new Set(
results.map((result) =>
toVirtualTSXFileName(result, extraFileExtensions)
)
),
];
watchCompilerHost.directoryExists = (dirName, ...args) => {
return (
original.directoryExists.call(watchCompilerHost, dirName, ...args) ||
// Include the path to the target file if the target file does not actually exist.
this.currTarget.dirMap.has(normalizeFileName(dirName))
);
};
watchCompilerHost.readDirectory = (dirName, ...args) => {
const results = original.readDirectory.call(
watchCompilerHost,
dirName,
...args
);

// Include the target file if the target file does not actually exist.
const file = this.currTarget.dirMap.get(normalizeFileName(dirName));
if (file && file.path === this.currTarget.filePath) {
results.push(file.path);
}

return distinctArray(...results).map((result) =>
toVirtualTSXFileName(result, extraFileExtensions)
);
};
watchCompilerHost.readFile = (fileName, ...args) => {
const realFileName = toRealFileName(fileName, extraFileExtensions);
Expand All @@ -151,12 +192,14 @@ export class TSService {
if (!code) {
return code;
}
// If it's tsconfig, it will take care of rewriting the `include`.
if (normalizedTsconfigPaths.has(normalized)) {
const configJson = ts.parseConfigFileTextToJson(realFileName, code);
if (!configJson.config) {
return code;
}
if (configJson.config.extends) {
// If it references another tsconfig, rewrite the `include` for that file as well.
for (const extendConfigPath of [configJson.config.extends].flat()) {
normalizedTsconfigPaths.add(
normalizeFileName(
Expand Down Expand Up @@ -184,12 +227,28 @@ export class TSService {
});
};
// Modify it so that it can be determined that the virtual file actually exists.
watchCompilerHost.fileExists = (fileName, ...args) =>
original.fileExists.call(
watchCompilerHost.fileExists = (fileName, ...args) => {
const normalizedFileName = normalizeFileName(fileName);

// Even if it is actually a file, if it is specified as a directory to the target file,
// it is assumed that it does not exist as a file.
if (this.currTarget.dirMap.has(normalizedFileName)) {
return false;
}
const normalizedRealFileName = toRealFileName(
normalizedFileName,
extraFileExtensions
);
if (this.currTarget.filePath === normalizedRealFileName) {
// It is the file currently being parsed.
return true;
}
return original.fileExists.call(
watchCompilerHost,
toRealFileName(fileName, extraFileExtensions),
...args
);
};

// It keeps a callback to mark the parsed file as changed so that it can be reparsed.
watchCompilerHost.watchFile = (fileName, callback) => {
Expand All @@ -205,11 +264,13 @@ export class TSService {
};
};
// Use watchCompilerHost but don't actually watch the files and directories.
watchCompilerHost.watchDirectory = () => ({
close() {
// noop
},
});
watchCompilerHost.watchDirectory = () => {
return {
close: () => {
// noop
},
};
};

/**
* It heavily references typescript-eslint.
Expand Down Expand Up @@ -278,13 +339,32 @@ function normalizeFileName(fileName: string) {
normalized = normalized.slice(0, -1);
}
if (ts.sys.useCaseSensitiveFileNames) {
return normalized;
return toAbsolutePath(normalized, null);
}
return normalized.toLowerCase();
return toAbsolutePath(normalized.toLowerCase(), null);
}

function toAbsolutePath(filePath: string, baseDir?: string) {
function toAbsolutePath(filePath: string, baseDir: string | null) {
return path.isAbsolute(filePath)
? filePath
: path.join(baseDir || process.cwd(), filePath);
}

function* iterateDirs(filePath: string) {
let target = filePath;
let parent: string;
while ((parent = path.dirname(target)) !== target) {
yield parent;
target = parent;
}
}

function distinctArray(...list: (string | null | undefined)[]) {
return [
...new Set(
ts.sys.useCaseSensitiveFileNames
? list.map((s) => s?.toLowerCase())
: list
),
].filter((s): s is string => s != null);
}
71 changes: 5 additions & 66 deletions tests/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,16 @@ import * as vueParser from "vue-eslint-parser";
import * as svelteParser from "svelte-eslint-parser";
import * as astroParser from "astro-eslint-parser";
import * as tsParser from "../../src";
import type * as tsEslintParser from "@typescript-eslint/parser";
import semver from "semver";
import assert from "assert";
import { iterateFixtures } from "./fixtures";
import { iterateFixtures } from "./utils/fixtures";
import { buildTypes } from "./utils/utils";

//------------------------------------------------------------------------------
// Helpers
//------------------------------------------------------------------------------

const ROOT = path.join(__dirname, "../fixtures/types");
const PROJECT_ROOT = path.join(__dirname, "../..");
const PARSER_OPTIONS = {
comment: true,
ecmaVersion: 2020,
Expand All @@ -32,68 +31,6 @@ const PARSER_OPTIONS = {
parser: tsParser,
};

function buildTypes(
input: string,
result: ReturnType<typeof tsEslintParser.parseForESLint>
) {
const tsNodeMap = result.services.esTreeNodeToTSNodeMap;
const checker =
result.services.program && result.services.program.getTypeChecker();

const checked = new Set();

const lines = input.split(/\r?\n/);
const types: string[][] = [];

function addType(node: any) {
const tsNode = tsNodeMap.get(node);
const type = checker.getTypeAtLocation(tsNode);
const typeText = checker.typeToString(type);
const lineTypes =
types[node.loc.start.line - 1] || (types[node.loc.start.line - 1] = []);
if (node.type === "Identifier") {
lineTypes.push(`${node.name}: ${typeText}`);
} else {
lineTypes.push(`${input.slice(...node.range)}: ${typeText}`);
}
}

vueParser.AST.traverseNodes(result.ast as any, {
visitorKeys: result.visitorKeys as any,
enterNode(node, parent) {
if (checked.has(parent)) {
checked.add(node);
return;
}

if (
node.type === "CallExpression" ||
node.type === "Identifier" ||
node.type === "MemberExpression"
) {
addType(node);
checked.add(node);
}
},
leaveNode() {
// noop
},
});
return lines
.map((l, i) => {
if (!types[i]) {
return l;
}
return `${l} // ${types[i].join(", ").replace(/\n\s*/g, " ")}`;
})
.join("\n")
.replace(new RegExp(escapeRegExp(PROJECT_ROOT), "gu"), "");
}

function escapeRegExp(string: string) {
return string.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&");
}

//------------------------------------------------------------------------------
// Main
//------------------------------------------------------------------------------
Expand Down Expand Up @@ -132,7 +69,7 @@ describe("Template Types", () => {
continue;
}

describe(`'test/fixtures/ast/${name}/${path.basename(sourcePath)}'`, () => {
describe(`'test/fixtures/${name}/${path.basename(sourcePath)}'`, () => {
it("should be parsed to valid Types.", () => {
const result =
path.extname(sourcePath) === ".vue"
Expand All @@ -141,6 +78,8 @@ describe("Template Types", () => {
? svelteParser.parseForESLint(source, options)
: path.extname(sourcePath) === ".astro"
? astroParser.parseForESLint(source, options)
: path.extname(sourcePath) === ".ts"
? tsParser.parseForESLint(source, options)
: vueParser.parseForESLint(source, options);
const actual = buildTypes(source, result as any);
const resultPath = sourcePath.replace(/source\.([a-z]+)$/u, "types.$1");
Expand Down
6 changes: 5 additions & 1 deletion tests/src/fixtures.ts → tests/src/utils/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ export function iterateFixtures(baseDir: string): Iterable<{
sourcePath: string;
tsconfigPath: string;
}> {
return iterateFixturesWithTsConfig(baseDir, "");
const tsconfigPathCandidate = path.join(baseDir, `tsconfig.json`);
const tsconfigPath = fs.existsSync(tsconfigPathCandidate)
? tsconfigPathCandidate
: "";
return iterateFixturesWithTsConfig(baseDir, tsconfigPath);
}

function* iterateFixturesWithTsConfig(
Expand Down
Loading