Skip to content

fix(typescript-estree): better handle canonical paths #1111

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 2 commits into from
Oct 21, 2019
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
63 changes: 38 additions & 25 deletions packages/typescript-estree/src/create-program/createWatchProgram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,21 @@ import path from 'path';
import ts from 'typescript';
import { Extra } from '../parser-options';
import { WatchCompilerHostOfConfigFile } from '../WatchCompilerHostOfConfigFile';
import { getTsconfigPath, DEFAULT_COMPILER_OPTIONS } from './shared';
import {
canonicalDirname,
CanonicalPath,
getTsconfigPath,
DEFAULT_COMPILER_OPTIONS,
getCanonicalFileName,
} from './shared';

const log = debug('typescript-eslint:typescript-estree:createWatchProgram');

/**
* Maps tsconfig paths to their corresponding file contents and resulting watches
*/
const knownWatchProgramMap = new Map<
string,
CanonicalPath,
ts.WatchOfConfigFile<ts.SemanticDiagnosticsBuilderProgram>
>();

Expand All @@ -21,25 +27,25 @@ const knownWatchProgramMap = new Map<
* There may be more than one per file/folder if a file/folder is shared between projects
*/
const fileWatchCallbackTrackingMap = new Map<
string,
CanonicalPath,
Set<ts.FileWatcherCallback>
>();
const folderWatchCallbackTrackingMap = new Map<
string,
CanonicalPath,
Set<ts.FileWatcherCallback>
>();

/**
* Stores the list of known files for each program
*/
const programFileListCache = new Map<string, Set<string>>();
const programFileListCache = new Map<CanonicalPath, Set<CanonicalPath>>();

/**
* Caches the last modified time of the tsconfig files
*/
const tsconfigLsatModifiedTimestampCache = new Map<string, number>();
const tsconfigLastModifiedTimestampCache = new Map<CanonicalPath, number>();

const parsedFilesSeen = new Set<string>();
const parsedFilesSeen = new Set<CanonicalPath>();

/**
* Clear all of the parser caches.
Expand All @@ -51,7 +57,7 @@ function clearCaches(): void {
folderWatchCallbackTrackingMap.clear();
parsedFilesSeen.clear();
programFileListCache.clear();
tsconfigLsatModifiedTimestampCache.clear();
tsconfigLastModifiedTimestampCache.clear();
}

function saveWatchCallback(
Expand All @@ -61,7 +67,7 @@ function saveWatchCallback(
fileName: string,
callback: ts.FileWatcherCallback,
): ts.FileWatcher => {
const normalizedFileName = path.normalize(fileName);
const normalizedFileName = getCanonicalFileName(path.normalize(fileName));
const watchers = ((): Set<ts.FileWatcherCallback> => {
let watchers = trackingMap.get(normalizedFileName);
if (!watchers) {
Expand All @@ -83,9 +89,9 @@ function saveWatchCallback(
/**
* Holds information about the file currently being linted
*/
const currentLintOperationState = {
const currentLintOperationState: { code: string; filePath: CanonicalPath } = {
code: '',
filePath: '',
filePath: '' as CanonicalPath,
};

/**
Expand All @@ -101,16 +107,17 @@ function diagnosticReporter(diagnostic: ts.Diagnostic): void {
/**
* Calculate project environments using options provided by consumer and paths from config
* @param code The code being linted
* @param filePath The path of the file being parsed
* @param filePathIn The path of the file being parsed
* @param extra.tsconfigRootDir The root directory for relative tsconfig paths
* @param extra.projects Provided tsconfig paths
* @returns The programs corresponding to the supplied tsconfig paths
*/
function getProgramsForProjects(
code: string,
filePath: string,
filePathIn: string,
extra: Extra,
): ts.Program[] {
const filePath = getCanonicalFileName(filePathIn);
const results = [];

// preserve reference to code and file being linted
Expand Down Expand Up @@ -145,7 +152,9 @@ function getProgramsForProjects(
let updatedProgram: ts.Program | null = null;
if (!fileList) {
updatedProgram = existingWatch.getProgram().getProgram();
fileList = new Set(updatedProgram.getRootFileNames());
fileList = new Set(
updatedProgram.getRootFileNames().map(f => getCanonicalFileName(f)),
);
programFileListCache.set(tsconfigPath, fileList);
}

Expand Down Expand Up @@ -215,7 +224,8 @@ function createWatchProgram(

// ensure readFile reads the code being linted instead of the copy on disk
const oldReadFile = watchCompilerHost.readFile;
watchCompilerHost.readFile = (filePath, encoding): string | undefined => {
watchCompilerHost.readFile = (filePathIn, encoding): string | undefined => {
const filePath = getCanonicalFileName(filePathIn);
parsedFilesSeen.add(filePath);
return path.normalize(filePath) ===
path.normalize(currentLintOperationState.filePath)
Expand Down Expand Up @@ -297,14 +307,14 @@ function createWatchProgram(
return ts.createWatchProgram(watchCompilerHost);
}

function hasTSConfigChanged(tsconfigPath: string): boolean {
function hasTSConfigChanged(tsconfigPath: CanonicalPath): boolean {
const stat = fs.statSync(tsconfigPath);
const lastModifiedAt = stat.mtimeMs;
const cachedLastModifiedAt = tsconfigLsatModifiedTimestampCache.get(
const cachedLastModifiedAt = tsconfigLastModifiedTimestampCache.get(
tsconfigPath,
);

tsconfigLsatModifiedTimestampCache.set(tsconfigPath, lastModifiedAt);
tsconfigLastModifiedTimestampCache.set(tsconfigPath, lastModifiedAt);

if (cachedLastModifiedAt === undefined) {
return false;
Expand All @@ -315,8 +325,8 @@ function hasTSConfigChanged(tsconfigPath: string): boolean {

function maybeInvalidateProgram(
existingWatch: ts.WatchOfConfigFile<ts.SemanticDiagnosticsBuilderProgram>,
filePath: string,
tsconfigPath: string,
filePath: CanonicalPath,
tsconfigPath: CanonicalPath,
): ts.Program | null {
/*
* By calling watchProgram.getProgram(), it will trigger a resync of the program based on
Expand Down Expand Up @@ -355,21 +365,22 @@ function maybeInvalidateProgram(
log('File was not found in program - triggering folder update. %s', filePath);

// Find the correct directory callback by climbing the folder tree
let current: string | null = null;
let next: string | null = path.dirname(filePath);
const currentDir = canonicalDirname(filePath);
let current: CanonicalPath | null = null;
let next = currentDir;
let hasCallback = false;
while (current !== next) {
current = next;
const folderWatchCallbacks = folderWatchCallbackTrackingMap.get(current);
if (folderWatchCallbacks) {
folderWatchCallbacks.forEach(cb =>
cb(current!, ts.FileWatcherEventKind.Changed),
cb(currentDir, ts.FileWatcherEventKind.Changed),
);
hasCallback = true;
break;
}

next = path.dirname(current);
next = canonicalDirname(current);
}
if (!hasCallback) {
/*
Expand Down Expand Up @@ -410,7 +421,9 @@ function maybeInvalidateProgram(
return null;
}

const fileWatchCallbacks = fileWatchCallbackTrackingMap.get(deletedFile);
const fileWatchCallbacks = fileWatchCallbackTrackingMap.get(
getCanonicalFileName(deletedFile),
);
if (!fileWatchCallbacks) {
// shouldn't happen, but just in case
log('Could not find watch callbacks for root file. %s', deletedFile);
Expand Down
29 changes: 24 additions & 5 deletions packages/typescript-estree/src/create-program/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,29 @@ const DEFAULT_COMPILER_OPTIONS: ts.CompilerOptions = {
// extendedDiagnostics: true,
};

function getTsconfigPath(tsconfigPath: string, extra: Extra): string {
return path.isAbsolute(tsconfigPath)
? tsconfigPath
: path.join(extra.tsconfigRootDir || process.cwd(), tsconfigPath);
// This narrows the type so we can be sure we're passing canonical names in the correct places
type CanonicalPath = string & { __brand: unknown };
const getCanonicalFileName = ts.sys.useCaseSensitiveFileNames
? (path: string): CanonicalPath => path as CanonicalPath
: (path: string): CanonicalPath => path.toLowerCase() as CanonicalPath;

function getTsconfigPath(tsconfigPath: string, extra: Extra): CanonicalPath {
return getCanonicalFileName(
path.isAbsolute(tsconfigPath)
? tsconfigPath
: path.join(extra.tsconfigRootDir || process.cwd(), tsconfigPath),
);
}

function canonicalDirname(p: CanonicalPath): CanonicalPath {
return path.dirname(p) as CanonicalPath;
}

export { ASTAndProgram, DEFAULT_COMPILER_OPTIONS, getTsconfigPath };
export {
ASTAndProgram,
canonicalDirname,
CanonicalPath,
DEFAULT_COMPILER_OPTIONS,
getCanonicalFileName,
getTsconfigPath,
};
36 changes: 35 additions & 1 deletion packages/typescript-estree/tests/lib/persistentParse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,16 @@ function renameFile(dirName: string, src: 'bar', dest: 'baz/bar'): void {
);
}

function setup(tsconfig: Record<string, unknown>, writeBar = true): string {
function createTmpDir(): tmp.DirResult {
const tmpDir = tmp.dirSync({
keep: false,
unsafeCleanup: true,
});
tmpDirs.add(tmpDir);
return tmpDir;
}
function setup(tsconfig: Record<string, unknown>, writeBar = true): string {
const tmpDir = createTmpDir();

writeTSConfig(tmpDir.name, tsconfig);

Expand Down Expand Up @@ -141,4 +145,34 @@ describe('persistent lint session', () => {
expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow();
expect(() => parseFile('bar', PROJECT_DIR)).not.toThrow();
});

it('handles tsconfigs with no includes/excludes (single level)', () => {
const PROJECT_DIR = setup({}, false);

// parse once to: assert the config as correct, and to make sure the program is setup
expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow();
expect(() => parseFile('bar', PROJECT_DIR)).toThrow();

// write a new file and attempt to parse it
writeFile(PROJECT_DIR, 'bar');

expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow();
expect(() => parseFile('bar', PROJECT_DIR)).not.toThrow();
});

it('handles tsconfigs with no includes/excludes (nested)', () => {
const PROJECT_DIR = setup({}, false);

// parse once to: assert the config as correct, and to make sure the program is setup
expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow();
expect(() => parseFile('baz/bar', PROJECT_DIR)).toThrow();

// write a new file and attempt to parse it
writeFile(PROJECT_DIR, 'baz/bar');

expect(() => parseFile('foo', PROJECT_DIR)).not.toThrow();
expect(() => parseFile('baz/bar', PROJECT_DIR)).not.toThrow();
});

// TODO - support the complex monorepo case with a tsconfig with no include/exclude
});
5 changes: 4 additions & 1 deletion packages/typescript-estree/tests/lib/semanticInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,10 @@ describe('semanticInfo', () => {
badConfig.project = '.';
expect(() =>
parseCodeAndGenerateServices(readFileSync(fileName, 'utf8'), badConfig),
).toThrow(/File .+semanticInfo' not found/);
).toThrow(
// case insensitive because unix based systems are case insensitive
/File .+semanticInfo' not found/i,
);
});

it('malformed project file', () => {
Expand Down