diff --git a/README.md b/README.md index 2c54bb2a..c5cbe42a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # scip-typescript -[SCIP](https://github.com/sourcegraph/scip) indexer for TypeScript and JavaScript. +[SCIP](https://github.com/sourcegraph/scip) indexer for TypeScript, JavaScript, +and Svelte. ## Quick start @@ -33,6 +34,18 @@ scip-typescript index --infer-tsconfig To improve the quality of indexing results for JavaScript, consider adding `@types/*` packages as `devDependencies` in `package.json`. +### Indexing a Svelte project + +Install the project dependencies and run the indexer from the directory that +contains the project's `tsconfig.json` or `jsconfig.json`. SvelteKit projects +should run `svelte-kit sync` first so their generated configuration is current. + +```sh +npm install # or yarn/pnpm install +npx svelte-kit sync # SvelteKit projects only +scip-typescript index +``` + ### Index a TypeScript project using Yarn workspaces Navigate to the project root, containing `package.json`. diff --git a/package.json b/package.json index ab388109..817aa29b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@sourcegraph/scip-typescript", "version": "0.4.0", - "description": "SCIP indexer for TypeScript and JavaScript", + "description": "SCIP indexer for TypeScript, JavaScript, and Svelte", "publisher": "sourcegraph", "bin": "dist/src/main.js", "main": "./dist/src/main.js", @@ -36,9 +36,12 @@ }, "homepage": "https://github.com/sourcegraph/scip-typescript#readme", "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", "commander": "^14.0.3", "google-protobuf": "^4.0.2", "progress": "^2.0.3", + "svelte": "^5.56.10", + "svelte2tsx": "^0.7.61", "typescript": "^6.0.3" }, "devDependencies": { diff --git a/snapshots/input/multi-project/packages/a/src/Component.svelte b/snapshots/input/multi-project/packages/a/src/Component.svelte new file mode 100644 index 00000000..eba5169f --- /dev/null +++ b/snapshots/input/multi-project/packages/a/src/Component.svelte @@ -0,0 +1,6 @@ + + +

{label}

diff --git a/snapshots/input/multi-project/packages/b/src/App.svelte b/snapshots/input/multi-project/packages/b/src/App.svelte new file mode 100644 index 00000000..0ce0f783 --- /dev/null +++ b/snapshots/input/multi-project/packages/b/src/App.svelte @@ -0,0 +1,5 @@ + + + diff --git a/snapshots/input/svelte/Child.svelte b/snapshots/input/svelte/Child.svelte new file mode 100644 index 00000000..5a7f36b9 --- /dev/null +++ b/snapshots/input/svelte/Child.svelte @@ -0,0 +1,6 @@ + + + diff --git a/snapshots/input/svelte/Generic.svelte b/snapshots/input/svelte/Generic.svelte new file mode 100644 index 00000000..072160c9 --- /dev/null +++ b/snapshots/input/svelte/Generic.svelte @@ -0,0 +1,5 @@ + + +{value} diff --git a/snapshots/input/svelte/Legacy.svelte b/snapshots/input/svelte/Legacy.svelte new file mode 100644 index 00000000..d13125f8 --- /dev/null +++ b/snapshots/input/svelte/Legacy.svelte @@ -0,0 +1,5 @@ + + +{value} diff --git a/snapshots/input/svelte/Parent.svelte b/snapshots/input/svelte/Parent.svelte new file mode 100644 index 00000000..fc97095c --- /dev/null +++ b/snapshots/input/svelte/Parent.svelte @@ -0,0 +1,31 @@ + + +{#snippet greeting(name: string)} + Hello {name} +{/snippet} + + + + + + +

{$count}

+

{$importedCount}

+{@render greeting(user.name)} diff --git a/snapshots/input/svelte/Plain.svelte b/snapshots/input/svelte/Plain.svelte new file mode 100644 index 00000000..ba5c5bbb --- /dev/null +++ b/snapshots/input/svelte/Plain.svelte @@ -0,0 +1,7 @@ + + +{#if enabled} +

enabled

+{/if} diff --git a/snapshots/input/svelte/helper.ts b/snapshots/input/svelte/helper.ts new file mode 100644 index 00000000..dd64523f --- /dev/null +++ b/snapshots/input/svelte/helper.ts @@ -0,0 +1,7 @@ +export interface User { + name: string +} + +export function format(name: string): string { + return name.toUpperCase() +} diff --git a/snapshots/input/svelte/index.ts b/snapshots/input/svelte/index.ts new file mode 100644 index 00000000..a8a34948 --- /dev/null +++ b/snapshots/input/svelte/index.ts @@ -0,0 +1,5 @@ +import Legacy from './Legacy.svelte' +import Parent from './Parent.svelte' + +export const component = Parent +export const components = { Legacy, Parent } diff --git a/snapshots/input/svelte/package.json b/snapshots/input/svelte/package.json new file mode 100644 index 00000000..4157fece --- /dev/null +++ b/snapshots/input/svelte/package.json @@ -0,0 +1,5 @@ +{ + "name": "svelte-example", + "version": "1.0.0", + "private": true +} diff --git a/snapshots/input/svelte/stores.ts b/snapshots/input/svelte/stores.ts new file mode 100644 index 00000000..8002c07c --- /dev/null +++ b/snapshots/input/svelte/stores.ts @@ -0,0 +1,3 @@ +import { writable } from 'svelte/store' + +export const importedCount = writable(3) diff --git a/snapshots/input/svelte/tsconfig.json b/snapshots/input/svelte/tsconfig.json new file mode 100644 index 00000000..86639c7b --- /dev/null +++ b/snapshots/input/svelte/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "esnext", + "moduleResolution": "bundler", + "target": "es2022", + "strict": true, + "skipLibCheck": true + }, + "include": ["*.ts", "*.svelte"] +} diff --git a/snapshots/output/multi-project/packages/a/src/Component.svelte b/snapshots/output/multi-project/packages/a/src/Component.svelte new file mode 100644 index 00000000..f6f038d3 --- /dev/null +++ b/snapshots/output/multi-project/packages/a/src/Component.svelte @@ -0,0 +1,18 @@ +// language Svelte +// < definition @example/a 1.0.0 src/`Component.svelte`/ + + + +

{label}

+// ^^^^^ reference local 4 + diff --git a/snapshots/output/multi-project/packages/b/src/App.svelte b/snapshots/output/multi-project/packages/b/src/App.svelte new file mode 100644 index 00000000..a4ff0ec8 --- /dev/null +++ b/snapshots/output/multi-project/packages/b/src/App.svelte @@ -0,0 +1,13 @@ +// language Svelte +// < definition @example/b 1.0.0 src/`App.svelte`/ + + + + +//^^^^^^^^^ reference @example/a 1.0.0 src/`Component.svelte`/ +// ^^^^^ reference @example/a 1.0.0 src/`Component.svelte`/Props#label. + diff --git a/snapshots/output/svelte/Child.svelte b/snapshots/output/svelte/Child.svelte new file mode 100644 index 00000000..a8407be4 --- /dev/null +++ b/snapshots/output/svelte/Child.svelte @@ -0,0 +1,24 @@ +// language Svelte +// < definition svelte-example 1.0.0 `Child.svelte`/ + + + + +// ^^^^^ reference local 4 +// ^^^^^^^ reference local 8 + diff --git a/snapshots/output/svelte/Generic.svelte b/snapshots/output/svelte/Generic.svelte new file mode 100644 index 00000000..3d4de288 --- /dev/null +++ b/snapshots/output/svelte/Generic.svelte @@ -0,0 +1,17 @@ +// language Svelte +// < definition svelte-example 1.0.0 `Generic.svelte`/ + + + +{value} +// ^^^^^ reference local 4 + diff --git a/snapshots/output/svelte/Legacy.svelte b/snapshots/output/svelte/Legacy.svelte new file mode 100644 index 00000000..7f9d0ea4 --- /dev/null +++ b/snapshots/output/svelte/Legacy.svelte @@ -0,0 +1,12 @@ +// language Svelte +// < definition svelte-example 1.0.0 `Legacy.svelte`/ + + + +{value} +// ^^^^^ reference local 2 + diff --git a/snapshots/output/svelte/Parent.svelte b/snapshots/output/svelte/Parent.svelte new file mode 100644 index 00000000..37823fa4 --- /dev/null +++ b/snapshots/output/svelte/Parent.svelte @@ -0,0 +1,90 @@ +// language Svelte +// < definition svelte-example 1.0.0 `Parent.svelte`/ + + + +{#snippet greeting(name: string)} +// ^^^^^^^^ definition local 2 +// ^^^^ definition local 4 + Hello {name} +// ^^^^ reference local 4 +{/snippet} + + +// ^^^^^^^ definition svelte-example 1.0.0 `Parent.svelte`/`"onclick"0`: +// ^^^^^^^^^ reference local 17 + +//^^^^^ reference svelte-example 1.0.0 `Child.svelte`/ +// ^^^^^ reference svelte-example 1.0.0 `Child.svelte`/Props#label. +// ^^^^^^ reference svelte-example 1.0.0 `helper.ts`/format(). +// ^^^^ reference local 7 +// ^^^^ reference svelte-example 1.0.0 `helper.ts`/User#name. +// ^^^^^ reference svelte-example 1.0.0 `Child.svelte`/Props#count. +// ^^^^^^ reference local 10 + +//^^^^^^^ reference svelte-example 1.0.0 `Generic.svelte`/ +// ^^^^^^^ reference local 16 +// ^^^^^ reference svelte-example 1.0.0 `Generic.svelte`/Props#value. +// ^^^^ reference local 7 + +//^^^^^^ reference svelte-example 1.0.0 `Legacy.svelte`/ +// ^^^^^ reference svelte-example 1.0.0 `Legacy.svelte`/Props#value. + +//^^^^^ reference svelte-example 1.0.0 `Plain.svelte`/ +// ^^^^^^^ reference svelte-example 1.0.0 `Plain.svelte`/Props#enabled. +

{$count}

+// ^^^^^^ reference local 13 +

{$importedCount}

+// ^^^^^^^^^^^^^^ reference svelte-example 1.0.0 `stores.ts`/importedCount. +{@render greeting(user.name)} +// ^^^^^^^^ reference local 2 +// ^^^^ reference local 7 +// ^^^^ reference svelte-example 1.0.0 `helper.ts`/User#name. + diff --git a/snapshots/output/svelte/Plain.svelte b/snapshots/output/svelte/Plain.svelte new file mode 100644 index 00000000..35cd8f96 --- /dev/null +++ b/snapshots/output/svelte/Plain.svelte @@ -0,0 +1,14 @@ +// language Svelte +// < definition svelte-example 1.0.0 `Plain.svelte`/ + + + +{#if enabled} +// ^^^^^^^ reference local 2 +

enabled

+{/if} + diff --git a/snapshots/output/svelte/helper.ts b/snapshots/output/svelte/helper.ts new file mode 100644 index 00000000..0a1adecc --- /dev/null +++ b/snapshots/output/svelte/helper.ts @@ -0,0 +1,17 @@ +// language TypeScript +// < definition svelte-example 1.0.0 `helper.ts`/ + +export interface User { +// ^^^^ definition svelte-example 1.0.0 `helper.ts`/User# + name: string +//^^^^ definition svelte-example 1.0.0 `helper.ts`/User#name. +} + +export function format(name: string): string { +// ^^^^^^ definition svelte-example 1.0.0 `helper.ts`/format(). +// ^^^^ definition svelte-example 1.0.0 `helper.ts`/format().(name) + return name.toUpperCase() +// ^^^^ reference svelte-example 1.0.0 `helper.ts`/format().(name) +// ^^^^^^^^^^^ reference typescript 6.0.3 lib/`lib.es5.d.ts`/String#toUpperCase(). +} + diff --git a/snapshots/output/svelte/index.ts b/snapshots/output/svelte/index.ts new file mode 100644 index 00000000..ea6a2d97 --- /dev/null +++ b/snapshots/output/svelte/index.ts @@ -0,0 +1,20 @@ +// language TypeScript +// < definition svelte-example 1.0.0 `index.ts`/ + +import Legacy from './Legacy.svelte' +// ^^^^^^ reference svelte-example 1.0.0 `Legacy.svelte`/ +// ^^^^^^^^^^^^^^^^^ reference svelte-example 1.0.0 `Legacy.svelte`/ +import Parent from './Parent.svelte' +// ^^^^^^ reference svelte-example 1.0.0 `Parent.svelte`/ +// ^^^^^^^^^^^^^^^^^ reference svelte-example 1.0.0 `Parent.svelte`/ + +export const component = Parent +// ^^^^^^^^^ definition svelte-example 1.0.0 `index.ts`/component. +// ^^^^^^ reference svelte-example 1.0.0 `Parent.svelte`/ +export const components = { Legacy, Parent } +// ^^^^^^^^^^ definition svelte-example 1.0.0 `index.ts`/components. +// ^^^^^^ definition svelte-example 1.0.0 `index.ts`/Legacy0: +// ^^^^^^ reference svelte-example 1.0.0 `Legacy.svelte`/ +// ^^^^^^ definition svelte-example 1.0.0 `index.ts`/Parent0: +// ^^^^^^ reference svelte-example 1.0.0 `Parent.svelte`/ + diff --git a/snapshots/output/svelte/stores.ts b/snapshots/output/svelte/stores.ts new file mode 100644 index 00000000..d83377b3 --- /dev/null +++ b/snapshots/output/svelte/stores.ts @@ -0,0 +1,11 @@ +// language TypeScript +// < definition svelte-example 1.0.0 `stores.ts`/ + +import { writable } from 'svelte/store' +// ^^^^^^^^ reference svelte 5.56.10 types/`index.d.ts`/`'svelte/store'`/writable(). +// ^^^^^^^^^^^^^^ reference svelte 5.56.10 types/`index.d.ts`/`'svelte/store'`/ + +export const importedCount = writable(3) +// ^^^^^^^^^^^^^ definition svelte-example 1.0.0 `stores.ts`/importedCount. +// ^^^^^^^^ reference svelte 5.56.10 types/`index.d.ts`/`'svelte/store'`/writable(). + diff --git a/src/CommandLineOptions.ts b/src/CommandLineOptions.ts index cecfbeee..1b84548b 100644 --- a/src/CommandLineOptions.ts +++ b/src/CommandLineOptions.ts @@ -5,6 +5,7 @@ import packageJson from '../package.json' import { parseHumanByteSizeIntoNumber } from './parseHumanByteSizeIntoNumber' import * as scip from './scip' +import { SourceInfo } from './SourceInfo' /** Configuration options to index a multi-project workspace. */ export interface MultiProjectOptions { @@ -36,6 +37,7 @@ export interface GlobalCache { > parsedCommandLines: Map indexedFiles: Set + sourceInfos: Map } export function mainCommand( @@ -46,7 +48,7 @@ export function mainCommand( .name('scip-typescript') .version(packageJson.version) .description( - 'SCIP indexer for TypeScript and JavaScript\nFor usage examples, see https://github.com/sourcegraph/scip-typescript/blob/main/README.md' + 'SCIP indexer for TypeScript, JavaScript, and Svelte\nFor usage examples, see https://github.com/sourcegraph/scip-typescript/blob/main/README.md' ) command .command('index') diff --git a/src/FileIndexer.ts b/src/FileIndexer.ts index 31f87342..87e8a0e6 100644 --- a/src/FileIndexer.ts +++ b/src/FileIndexer.ts @@ -16,9 +16,9 @@ import { import { Input } from './Input' import { Packages } from './Packages' import { formatByteSizeAsHumanReadable } from './parseHumanByteSizeIntoNumber' -import { Range } from './Range' import * as scip from './scip' import { ScipSymbol } from './ScipSymbol' +import { SourceInfo } from './SourceInfo' import * as ts_inline from './TypeScriptInternal' export class FileIndexer { @@ -37,7 +37,9 @@ export class FileIndexer { public readonly globalSymbolTable: Map, public readonly globalConstructorTable: Map, public readonly packages: Packages, - public readonly sourceFile: ts.SourceFile + public readonly sourceFile: ts.SourceFile, + public readonly sourceInfo: SourceInfo, + public readonly sourceInfos: Map ) { this.workingDirectoryRegExp = new RegExp(options.cwd, 'g') } @@ -47,7 +49,7 @@ export class FileIndexer { // return // } - const byteSize = Buffer.from(this.sourceFile.getText()).length + const byteSize = Buffer.from(this.sourceInfo.text).length if ( this.options.maxFileByteSizeNumber && byteSize > this.options.maxFileByteSizeNumber @@ -102,17 +104,18 @@ export class FileIndexer { this.pushOccurrence( new scip.scip.Occurrence({ range: [0, 0, 0], - enclosing_range: Range.fromNode(this.sourceFile).toLsif(), + enclosing_range: this.sourceInfo.range(this.sourceFile), symbol: symbol.value, symbol_roles: scip.scip.SymbolRole.Definition, }) ) const moduleName = - this.sourceFile.moduleName || path.basename(this.sourceFile.fileName) + this.sourceFile.moduleName || path.basename(this.sourceInfo.fileName) + const language = this.sourceInfo.language ?? 'ts' this.pushSymbolInformation( new scip.scip.SymbolInformation({ symbol: symbol.value, - documentation: ['```ts\nmodule "' + moduleName + '"\n```'], + documentation: [`\`\`\`${language}\nmodule "${moduleName}"\n\`\`\``], kind: scip.scip.SymbolInformation.Kind.File, }) ) @@ -190,9 +193,15 @@ export class FileIndexer { if (contextualType === undefined) { return } + // svelte2tsx gives the generated props object the contextual type + // `Props | undefined`. Property lookup on that union cannot find `label` + // in ``, so remove the nullish branch first. + const propertyOwner = this.sourceInfo.isSvelte + ? this.checker.getNonNullableType(contextualType) + : contextualType const symbol = ts_inline.getPropertySymbolFromContextualType( objectElement, - contextualType + propertyOwner ) const declarations = symbol?.getDeclarations() // Inferred object types can contextually resolve a property back to its @@ -206,9 +215,12 @@ export class FileIndexer { // For constructors, this method is passed the declaration node and not the identifier node. // In either case, this method needs to get the range of the "name" of the declaration, for constructors we // get the firstToken which contains the text "constructor". - const range = Range.fromNode( + const range = this.sourceInfo.range( isConstructor ? (node.getFirstToken() ?? node) : node - ).toLsif() + ) + if (!range) { + return + } let role = 0 let declarations: ts.Node[] = this.getDeclarationsForPropertyAssignment(node) ?? [] @@ -242,7 +254,7 @@ export class FileIndexer { declaration.initializer && ts.isFunctionLike(declaration.initializer) ) { - enclosingRange = Range.fromNode(declaration.initializer).toLsif() + enclosingRange = this.sourceInfo.range(declaration.initializer) } else if ( ts.isFunctionDeclaration(declaration) || ts.isEnumDeclaration(declaration) || @@ -252,7 +264,7 @@ export class FileIndexer { ts.isInterfaceDeclaration(declaration) || ts.isConstructorDeclaration(declaration) ) { - enclosingRange = Range.fromNode(declaration).toLsif() + enclosingRange = this.sourceInfo.range(declaration) } if ( @@ -368,8 +380,21 @@ export class FileIndexer { } } - private hideWorkingDirectory(value: string): string { - return value.replace(this.workingDirectoryRegExp, '') + private sanitizeDocumentation(value: string): string { + // TypeScript signatures expose implementation names from svelte2tsx. + // Keep SCIP hover documentation in terms of the component source instead + // of leaking generated helpers that users cannot navigate to or import. + return ( + value + // Absolute paths make documentation machine-specific. + .replace(this.workingDirectoryRegExp, '') + // Legacy-mode components are represented by this generated helper type. + .replace(/\b__sveltets_\d+_IsomorphicComponent\b/g, 'Component') + // Generic components get a generated `Name__SvelteComponent_` type. + .replace(/\b([A-Za-z_$][\w$]*)__SvelteComponent_/g, '$1') + // Inline `$props()` annotations are moved into this generated alias. + .replace(/\$\$ComponentProps/g, 'Props') + ) } private addSymbolInformation( node: ts.Node, @@ -379,7 +404,7 @@ export class FileIndexer { ): void { const documentation = [ '```ts\n' + - this.hideWorkingDirectory( + this.sanitizeDocumentation( this.signatureForDocumentation(node, sym, declaration) ) + '\n```', @@ -529,6 +554,32 @@ export class FileIndexer { if (fromCache) { return fromCache } + // svelte2tsx introduces declarations with no user-authored identity. + // Normalize those declarations before the regular SCIP symbol algorithm + // assigns local or generated-name symbols to them. + const sourceInfo = this.sourceInfos.get(node.getSourceFile()) + const canonicalDeclaration = sourceInfo?.canonicalDeclaration(node) + if (canonicalDeclaration && canonicalDeclaration !== node) { + // Store auto-subscriptions resolve to the original store, while generic + // parameters owned by the generated $$render resolve to the component. + return this.cached(node, this.scipSymbol(canonicalDeclaration)) + } + if (sourceInfo?.isComponentPropsDeclaration(node)) { + // Give generated inline component props a stable, cross-file identity so + // component attributes navigate to their declaration in the .svelte file. + return this.cached( + node, + ScipSymbol.global( + this.scipSymbol(node.getSourceFile()), + typeDescriptor('Props') + ) + ) + } + if (sourceInfo?.isComponentDeclaration(node)) { + // The generated default export has no source range. Use the file symbol + // so imports and component tags navigate to the component document. + return this.cached(node, this.scipSymbol(node.getSourceFile())) + } if (ts.isBlock(node)) { return ScipSymbol.empty() } @@ -553,6 +604,20 @@ export class FileIndexer { return this.cached(node, symbol) } + if ( + ts.isPropertyAssignment(node) && + sourceInfo?.isComponentPropsDeclaration(node.parent) + ) { + // JavaScript-mode legacy components express props as generated object + // properties rather than a type. Give them the same stable Props members. + return this.cached( + node, + ScipSymbol.global( + this.scipSymbol(node.parent), + termDescriptor(node.name.getText()) + ) + ) + } if ( ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node) @@ -617,6 +682,22 @@ export class FileIndexer { ts.isImportClause(node) || ts.isNamespaceImport(node) ) { + // Resolve the import binding rather than its inferred type. This matters + // for Svelte components and imported stores, whose inferred types point + // at framework helpers instead of the user-authored declaration. + const alias = node.name + ? this.checker.getSymbolAtLocation(node.name) + : undefined + if ( + (sourceInfo?.isSvelte || isSvelteImport(node)) && + alias && + (alias.flags & ts.SymbolFlags.Alias) !== 0 + ) { + const imported = this.checker.getAliasedSymbol(alias) + for (const declaration of imported.declarations || []) { + return this.scipSymbol(declaration) + } + } const tpe = this.checker.getTypeAtLocation(node) for (const declaration of tpe.symbol?.declarations || []) { return this.scipSymbol(declaration) @@ -889,6 +970,20 @@ function isAnonymousContainerOfSymbols(node: ts.Node): boolean { ) } +function isSvelteImport( + node: ts.ImportSpecifier | ts.ImportClause | ts.NamespaceImport +): boolean { + let parent: ts.Node | undefined = node.parent + while (parent && !ts.isImportDeclaration(parent)) { + parent = parent.parent + } + return ( + !!parent && + ts.isStringLiteral(parent.moduleSpecifier) && + parent.moduleSpecifier.text.endsWith('.svelte') + ) +} + function scriptElementKind( node: ts.Node, sym: ts.Symbol diff --git a/src/ProjectIndexer.test.ts b/src/ProjectIndexer.test.ts index bccf9bdb..717a2e0b 100644 --- a/src/ProjectIndexer.test.ts +++ b/src/ProjectIndexer.test.ts @@ -8,10 +8,12 @@ import * as assert from 'uvu/assert' import { GlobalCache, ProjectOptions } from './CommandLineOptions' import { + deduplicateOccurrences, languageForFileName, prettyMilliseconds, ProjectIndexer, } from './ProjectIndexer' +import * as scip from './scip' function minute(x: number): number { return x * 60 * 1000 @@ -58,6 +60,7 @@ test('only deduplicates documents after successful emission', () => { sources: new Map(), parsedCommandLines: new Map(), indexedFiles: new Set(), + sourceInfos: new Map(), } const options: ProjectOptions = { cwd: projectRoot, @@ -91,4 +94,33 @@ test('only deduplicates documents after successful emission', () => { } }) +test('Svelte occurrence deduplication preserves definition metadata', () => { + const reference = new scip.scip.Occurrence({ + range: [1, 2, 3], + symbol: 'local 0', + }) + const definition = new scip.scip.Occurrence({ + range: [1, 2, 3], + enclosing_range: [1, 0, 5, 0], + symbol: 'local 0', + symbol_roles: scip.scip.SymbolRole.Definition, + diagnostics: [ + new scip.scip.Diagnostic({ message: 'definition diagnostic' }), + ], + }) + const document = new scip.scip.Document({ + occurrences: [reference, definition], + }) + + deduplicateOccurrences(document) + + assert.is(document.occurrences.length, 1) + assert.is(document.occurrences[0], definition) + assert.equal(document.occurrences[0].enclosing_range, [1, 0, 5, 0]) + assert.is( + document.occurrences[0].diagnostics[0].message, + 'definition diagnostic' + ) +}) + test.run() diff --git a/src/ProjectIndexer.ts b/src/ProjectIndexer.ts index 92935c6f..c32b7031 100644 --- a/src/ProjectIndexer.ts +++ b/src/ProjectIndexer.ts @@ -9,33 +9,62 @@ import { Input } from './Input' import { Packages } from './Packages' import * as scip from './scip' import { ScipSymbol } from './ScipSymbol' +import { SourceInfo, typescriptSourceInfo } from './SourceInfo' +import { isSvelteFile, SvelteSupport } from './Svelte' function createCompilerHost( cache: GlobalCache, compilerOptions: ts.CompilerOptions, - projectOptions: ProjectOptions + projectOptions: ProjectOptions, + hasSvelte: boolean, + sourceInfos: Map ): ts.CompilerHost { const host = ts.createCompilerHost(compilerOptions) - if (!projectOptions.globalCaches) { + if (!hasSvelte && !projectOptions.globalCaches) { return host } const hostCopy = { ...host } - host.getParsedCommandLine = (fileName: string) => { - if (!hostCopy.getParsedCommandLine) { - return undefined - } - const fromCache = cache.parsedCommandLines.get(fileName) - if (fromCache !== undefined) { - return fromCache - } - const result = hostCopy.getParsedCommandLine(fileName) - if (result !== undefined) { - // Don't cache undefined results even if they could be cached - // theoretically. The big performance gains from this cache come from - // caching non-undefined results. - cache.parsedCommandLines.set(fileName, result) + const svelte = hasSvelte + ? new SvelteSupport(hostCopy, compilerOptions, sourceInfos) + : undefined + if (svelte) { + host.fileExists = fileName => svelte.fileExists(fileName) + host.readFile = fileName => svelte.readFile(fileName) + host.realpath = fileName => svelte.realpath(fileName) + host.resolveModuleNameLiterals = ( + moduleLiterals, + containingFile, + redirectedReference, + options, + containingSourceFile + ) => + svelte.resolveModuleNameLiterals( + moduleLiterals, + containingFile, + redirectedReference, + options, + containingSourceFile + ) + } + + if (projectOptions.globalCaches) { + host.getParsedCommandLine = (fileName: string) => { + if (!hostCopy.getParsedCommandLine) { + return undefined + } + const fromCache = cache.parsedCommandLines.get(fileName) + if (fromCache !== undefined) { + return fromCache + } + const result = hostCopy.getParsedCommandLine(fileName) + if (result !== undefined) { + // Don't cache undefined results even if they could be cached + // theoretically. The big performance gains from this cache come from + // caching non-undefined results. + cache.parsedCommandLines.set(fileName, result) + } + return result } - return result } host.getSourceFile = ( fileName, @@ -43,20 +72,29 @@ function createCompilerHost( onError, shouldCreateNewSourceFile ) => { - const fromCache = cache.sources.get(fileName) - if (fromCache !== undefined) { - const [sourceFile, cachedLanguageVersion] = fromCache - if (isSameLanguageVersion(languageVersion, cachedLanguageVersion)) { - return sourceFile + if (projectOptions.globalCaches) { + const fromCache = cache.sources.get(fileName) + if (fromCache !== undefined) { + const [sourceFile, cachedLanguageVersion] = fromCache + if (isSameLanguageVersion(languageVersion, cachedLanguageVersion)) { + return sourceFile + } } } - const result = hostCopy.getSourceFile( - fileName, - languageVersion, - onError, - shouldCreateNewSourceFile - ) - if (result !== undefined) { + const result = svelte + ? svelte.getSourceFile( + fileName, + languageVersion, + onError, + shouldCreateNewSourceFile + ) + : hostCopy.getSourceFile( + fileName, + languageVersion, + onError, + shouldCreateNewSourceFile + ) + if (projectOptions.globalCaches && result !== undefined) { // Don't cache undefined results even if they could be cached // theoretically. The big performance gains from this cache come from // caching non-undefined results. @@ -74,16 +112,37 @@ export class ProjectIndexer { private hasConstructor: Map = new Map() private packages: Packages private indexedFiles: Set + private sourceInfos: Map constructor( public readonly config: ts.ParsedCommandLine, public readonly options: ProjectOptions, cache: GlobalCache ) { - const host = createCompilerHost(cache, config.options, options) - this.program = ts.createProgram(config.fileNames, config.options, host) + const hasSvelte = config.fileNames.some(isSvelteFile) + const sourceInfos = options.globalCaches + ? cache.sourceInfos + : new Map() + const host = createCompilerHost( + cache, + config.options, + options, + hasSvelte, + sourceInfos + ) + const rootNames = hasSvelte + ? [ + ...config.fileNames, + // Ambient declarations for the __sveltets helpers emitted by + // svelte2tsx. They let TypeScript infer component props and generics, + // but are not in config.fileNames and therefore are not indexed. + require.resolve('svelte2tsx/svelte-shims-v4.d.ts'), + ] + : config.fileNames + this.program = ts.createProgram(rootNames, config.options, host) this.checker = this.program.getTypeChecker() this.packages = new Packages(options.projectRoot) this.indexedFiles = cache.indexedFiles + this.sourceInfos = sourceInfos } public index(): void { const startTimestamp = Date.now() @@ -141,12 +200,15 @@ export class ProjectIndexer { process.stdout.write('.') } } + const sourceInfo = + this.sourceInfos.get(sourceFile) ?? typescriptSourceInfo(sourceFile) const document = new scip.scip.Document({ - language: languageForFileName(sourceFile.fileName), - relative_path: path.relative(this.options.cwd, sourceFile.fileName), + language: + sourceInfo.language ?? languageForFileName(sourceInfo.fileName), + relative_path: path.relative(this.options.cwd, sourceInfo.fileName), occurrences: [], }) - const input = new Input(sourceFile.fileName, sourceFile.getText()) + const input = new Input(sourceInfo.fileName, sourceInfo.text) const visitor = new FileIndexer( this.checker, this.options, @@ -155,7 +217,9 @@ export class ProjectIndexer { this.symbolCache, this.hasConstructor, this.packages, - sourceFile + sourceFile, + sourceInfo, + this.sourceInfos ) try { visitor.index() @@ -165,6 +229,9 @@ export class ProjectIndexer { error ) } + if (sourceInfo.isSvelte) { + deduplicateOccurrences(visitor.document) + } if (visitor.document.occurrences.length > 0) { this.options.writeIndex( new scip.scip.Index({ @@ -204,6 +271,45 @@ export function languageForFileName(fileName: string): string { return '' } +export function deduplicateOccurrences(document: scip.scip.Document): void { + const occurrences = new Map() + for (const occurrence of document.occurrences) { + const key = `${occurrence.range.join(':')} ${occurrence.symbol}` + const existing = occurrences.get(key) + if (existing) { + const symbolRoles = existing.symbol_roles | occurrence.symbol_roles + const existingIsDefinition = + (existing.symbol_roles & scip.scip.SymbolRole.Definition) !== 0 + const occurrenceIsDefinition = + (occurrence.symbol_roles & scip.scip.SymbolRole.Definition) !== 0 + // svelte2tsx can map a generated reference and definition to the same + // source range. Keep the definition as the survivor because it carries + // the enclosing range and diagnostics associated with the declaration. + if (occurrenceIsDefinition && !existingIsDefinition) { + occurrence.symbol_roles = symbolRoles + if (occurrence.enclosing_range.length === 0) { + occurrence.enclosing_range = existing.enclosing_range + } + if (occurrence.diagnostics.length === 0) { + occurrence.diagnostics = existing.diagnostics + } + occurrences.set(key, occurrence) + } else { + existing.symbol_roles = symbolRoles + if (existing.enclosing_range.length === 0) { + existing.enclosing_range = occurrence.enclosing_range + } + if (existing.diagnostics.length === 0) { + existing.diagnostics = occurrence.diagnostics + } + } + } else { + occurrences.set(key, occurrence) + } + } + document.occurrences = [...occurrences.values()] +} + export function prettyMilliseconds(milliseconds: number): string { let ms = Math.floor(milliseconds) let result = '' diff --git a/src/SourceInfo.ts b/src/SourceInfo.ts new file mode 100644 index 00000000..4f113c15 --- /dev/null +++ b/src/SourceInfo.ts @@ -0,0 +1,27 @@ +import * as ts from 'typescript' + +import { Range } from './Range' + +export interface SourceInfo { + readonly fileName: string + readonly text: string + readonly language?: string + readonly isSvelte: boolean + + range(node: ts.Node): number[] | undefined + isComponentDeclaration(node: ts.Node): boolean + isComponentPropsDeclaration(node: ts.Node): boolean + canonicalDeclaration(node: ts.Node): ts.Node +} + +export function typescriptSourceInfo(sourceFile: ts.SourceFile): SourceInfo { + return { + fileName: sourceFile.fileName, + text: sourceFile.getText(), + isSvelte: false, + range: node => Range.fromNode(node).toLsif(), + isComponentDeclaration: () => false, + isComponentPropsDeclaration: () => false, + canonicalDeclaration: node => node, + } +} diff --git a/src/Svelte.test.ts b/src/Svelte.test.ts new file mode 100644 index 00000000..568db9df --- /dev/null +++ b/src/Svelte.test.ts @@ -0,0 +1,179 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' + +import { svelte2tsx } from 'svelte2tsx' +import * as ts from 'typescript' +import { test } from 'uvu' +import * as assert from 'uvu/assert' + +import { GlobalCache, ProjectOptions } from './CommandLineOptions' +import { ProjectIndexer } from './ProjectIndexer' +import * as scip from './scip' +import { SourceInfo } from './SourceInfo' +import { SvelteSupport } from './Svelte' + +test('svelte2tsx generated declaration conventions', () => { + const transformed = svelte2tsx( + ` + {value}`, + { + filename: 'Generic.svelte', + isTsFile: true, + emitOnTemplateError: true, + } + ) + + // SvelteSourceInfo canonicalizes these declarations. If an upgrade changes + // them, update the normalization and documentation filtering together. + assert.ok(transformed.code.includes('function $$render')) + assert.ok(transformed.code.includes('type $$ComponentProps')) + assert.ok(transformed.code.includes('Generic__SvelteComponent_')) + + const legacy = svelte2tsx( + ` + {$count}: {value}`, + { + filename: 'Legacy.svelte', + isTsFile: true, + emitOnTemplateError: true, + } + ) + assert.ok(legacy.code.includes('return { props:')) + assert.ok(legacy.code.includes('as {value: string}')) + assert.ok(legacy.code.includes('let $count = __sveltets_2_store_get(count)')) + + const javascript = svelte2tsx(``, { + filename: 'Javascript.svelte', + isTsFile: false, + emitOnTemplateError: true, + }) + assert.ok(javascript.code.includes('props: {enabled: enabled}')) +}) + +test('Svelte host preserves modern module resolution and rune modules', () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'scip-svelte-')) + try { + const packageDirectory = path.join(directory, 'node_modules', 'dual') + fs.mkdirSync(packageDirectory, { recursive: true }) + fs.writeFileSync( + path.join(packageDirectory, 'package.json'), + JSON.stringify({ + name: 'dual', + exports: { + '.': { + import: './import.d.ts', + require: './require.d.ts', + }, + }, + }) + ) + fs.writeFileSync(path.join(packageDirectory, 'import.d.ts'), 'export {}') + fs.writeFileSync(path.join(packageDirectory, 'require.d.ts'), 'export {}') + + const options: ts.CompilerOptions = { + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + } + const host = ts.createCompilerHost(options) + const sourceInfos = new Map() + const svelte = new SvelteSupport(host, options, sourceInfos) + const containingFile = path.join(directory, 'index.mts') + const sourceFile = ts.createSourceFile( + containingFile, + "import 'dual'", + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ) + sourceFile.impliedNodeFormat = ts.ModuleKind.ESNext + const statement = sourceFile.statements[0] + assert.ok(ts.isImportDeclaration(statement)) + assert.ok(ts.isStringLiteral(statement.moduleSpecifier)) + const [resolution] = svelte.resolveModuleNameLiterals( + [statement.moduleSpecifier], + containingFile, + undefined, + options, + sourceFile + ) + assert.ok( + resolution.resolvedModule?.resolvedFileName.endsWith('import.d.ts') + ) + + const runeModule = path.join(directory, 'counter.svelte.ts') + fs.writeFileSync(runeModule, 'export const count = 1') + fs.writeFileSync(path.join(directory, 'counter.svelte'), '

component

') + assert.is(svelte.readFile(runeModule), 'export const count = 1') + + const brokenComponent = path.join(directory, 'Broken.svelte') + fs.writeFileSync(brokenComponent, '