diff --git a/snapshots/input/overlapping-projects/package.json b/snapshots/input/overlapping-projects/package.json new file mode 100644 index 00000000..a7d08913 --- /dev/null +++ b/snapshots/input/overlapping-projects/package.json @@ -0,0 +1,5 @@ +{ + "name": "overlapping-projects", + "version": "1.0.0", + "private": true +} diff --git a/snapshots/input/overlapping-projects/parent/nested/shared.ts b/snapshots/input/overlapping-projects/parent/nested/shared.ts new file mode 100644 index 00000000..3e006a24 --- /dev/null +++ b/snapshots/input/overlapping-projects/parent/nested/shared.ts @@ -0,0 +1,3 @@ +export function shared(): string { + return 'shared' +} diff --git a/snapshots/input/overlapping-projects/parent/nested/tsconfig.json b/snapshots/input/overlapping-projects/parent/nested/tsconfig.json new file mode 100644 index 00000000..42138766 --- /dev/null +++ b/snapshots/input/overlapping-projects/parent/nested/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "composite": true + }, + "include": ["shared.ts"] +} diff --git a/snapshots/input/overlapping-projects/parent/tsconfig.json b/snapshots/input/overlapping-projects/parent/tsconfig.json new file mode 100644 index 00000000..73b9d86e --- /dev/null +++ b/snapshots/input/overlapping-projects/parent/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "composite": true + }, + "include": ["**/*.ts"] +} diff --git a/snapshots/input/overlapping-projects/tsconfig.json b/snapshots/input/overlapping-projects/tsconfig.json new file mode 100644 index 00000000..e6d4d7bd --- /dev/null +++ b/snapshots/input/overlapping-projects/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./parent" }, { "path": "./parent/nested" }] +} diff --git a/snapshots/output/overlapping-projects/parent/nested/shared.ts b/snapshots/output/overlapping-projects/parent/nested/shared.ts new file mode 100644 index 00000000..fe46fc80 --- /dev/null +++ b/snapshots/output/overlapping-projects/parent/nested/shared.ts @@ -0,0 +1,8 @@ +// language TypeScript +// < definition . . nested/`shared.ts`/ + +export function shared(): string { +// ^^^^^^ definition . . nested/`shared.ts`/shared(). + return 'shared' +} + diff --git a/src/CommandLineOptions.ts b/src/CommandLineOptions.ts index f60a706c..cecfbeee 100644 --- a/src/CommandLineOptions.ts +++ b/src/CommandLineOptions.ts @@ -35,6 +35,7 @@ export interface GlobalCache { [ts.SourceFile | undefined, ts.ScriptTarget | ts.CreateSourceFileOptions] > parsedCommandLines: Map + indexedFiles: Set } export function mainCommand( diff --git a/src/ProjectIndexer.test.ts b/src/ProjectIndexer.test.ts index 93848ba3..bccf9bdb 100644 --- a/src/ProjectIndexer.test.ts +++ b/src/ProjectIndexer.test.ts @@ -1,7 +1,17 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' + +import * as ts from 'typescript' import { test } from 'uvu' import * as assert from 'uvu/assert' -import { languageForFileName, prettyMilliseconds } from './ProjectIndexer' +import { GlobalCache, ProjectOptions } from './CommandLineOptions' +import { + languageForFileName, + prettyMilliseconds, + ProjectIndexer, +} from './ProjectIndexer' function minute(x: number): number { return x * 60 * 1000 @@ -37,4 +47,48 @@ test('languageForFileName', () => { assert.is(languageForFileName('Component.svelte'), '') }) +test('only deduplicates documents after successful emission', () => { + const projectRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'scip-typescript-project-') + ) + try { + const fileName = path.join(projectRoot, 'index.ts') + fs.writeFileSync(fileName, 'export const value = 1\n') + const cache: GlobalCache = { + sources: new Map(), + parsedCommandLines: new Map(), + indexedFiles: new Set(), + } + const options: ProjectOptions = { + cwd: projectRoot, + projectRoot, + projectDisplayName: projectRoot, + output: path.join(projectRoot, 'index.scip'), + inferTsconfig: false, + progressBar: false, + yarnWorkspaces: false, + yarnBerryWorkspaces: false, + pnpmWorkspaces: false, + globalCaches: false, + indexedProjects: new Set(), + writeIndex: () => { + throw new Error('emission failed') + }, + } + const config: ts.ParsedCommandLine = { + options: {}, + fileNames: [fileName], + errors: [], + } + + assert.throws( + () => new ProjectIndexer(config, options, cache).index(), + /emission failed/ + ) + assert.not.ok(cache.indexedFiles.has(fileName)) + } finally { + fs.rmSync(projectRoot, { recursive: true }) + } +}) + test.run() diff --git a/src/ProjectIndexer.ts b/src/ProjectIndexer.ts index d73ebb2f..92935c6f 100644 --- a/src/ProjectIndexer.ts +++ b/src/ProjectIndexer.ts @@ -73,6 +73,7 @@ export class ProjectIndexer { private symbolCache: Map = new Map() private hasConstructor: Map = new Map() private packages: Packages + private indexedFiles: Set constructor( public readonly config: ts.ParsedCommandLine, public readonly options: ProjectOptions, @@ -82,22 +83,33 @@ export class ProjectIndexer { this.program = ts.createProgram(config.fileNames, config.options, host) this.checker = this.program.getTypeChecker() this.packages = new Packages(options.projectRoot) + this.indexedFiles = cache.indexedFiles } public index(): void { const startTimestamp = Date.now() const sourceFiles = this.program.getSourceFiles() const filesToIndex: ts.SourceFile[] = [] + let projectFileCount = 0 // Visit every sourceFile in the program for (const sourceFile of sourceFiles) { const includes = this.config.fileNames.includes(sourceFile.fileName) if (!includes) { continue } + projectFileCount++ + if (this.indexedFiles.has(sourceFile.fileName)) { + continue + } filesToIndex.push(sourceFile) } if (filesToIndex.length === 0) { + if (projectFileCount > 0) { + // Every source belongs to a project that was indexed earlier. SCIP + // requires document paths to be unique across a complete index. + return + } throw new Error( `no indexable files in project '${this.options.projectDisplayName}'` ) @@ -159,6 +171,10 @@ export class ProjectIndexer { documents: [visitor.document], }) ) + // Only suppress the file in later overlapping projects after its + // document has actually been emitted. If indexing or emission fails, + // another project that includes the file can still retry it. + this.indexedFiles.add(sourceFile.fileName) } } jobs?.terminate() diff --git a/src/main.test.ts b/src/main.test.ts index 6114e291..4a563e60 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -74,6 +74,15 @@ for (const snapshotDirectory of snapshotDirectories) { if (index.documents.length === 0) { throw new Error('empty LSIF index') } + const documentPaths = new Set() + const duplicateDocuments: string[] = [] + for (const document of index.documents) { + if (documentPaths.has(document.relative_path)) { + duplicateDocuments.push(document.relative_path) + } + documentPaths.add(document.relative_path) + } + assert.equal(duplicateDocuments, [], 'SCIP document paths should be unique') for (const document of index.documents) { const symbols = new Set() const duplicateSymbols: string[] = [] diff --git a/src/main.ts b/src/main.ts index 07fb1302..e6b5b40c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -54,6 +54,7 @@ export function indexCommand( const cache: GlobalCache = { sources: new Map(), parsedCommandLines: new Map(), + indexedFiles: new Set(), } try { writeIndex(