Skip to content

Commit

Permalink
fix(typescript-estree): better handle canonical paths (#1111)
Browse files Browse the repository at this point in the history
  • Loading branch information
bradzacher committed Oct 21, 2019
1 parent fd39bbd commit 8dcbf4c
Show file tree
Hide file tree
Showing 4 changed files with 101 additions and 32 deletions.
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

0 comments on commit 8dcbf4c

Please sign in to comment.