From dc0c6689049aaea9ffe56194d1e88c9891d06afb Mon Sep 17 00:00:00 2001 From: Erik Seliger Date: Sat, 22 Aug 2026 17:50:15 +0000 Subject: [PATCH 1/5] Add Svelte indexing support Transform Svelte components with svelte2tsx while mapping symbols and ranges back to the original source. Cover runes, legacy and JavaScript components, imported stores, malformed files, modern module resolution, and cross-project prop identity. Amp-Thread-ID: https://ampcode.com/threads/T-01a029e2-5095-710a-9cce-0c6d8645c173 --- README.md | 15 +- package.json | 5 +- .../packages/a/src/Component.svelte | 6 + .../multi-project/packages/b/src/App.svelte | 5 + snapshots/input/svelte/Child.svelte | 6 + snapshots/input/svelte/Generic.svelte | 5 + snapshots/input/svelte/Legacy.svelte | 5 + snapshots/input/svelte/Parent.svelte | 31 ++ snapshots/input/svelte/Plain.svelte | 7 + snapshots/input/svelte/helper.ts | 7 + snapshots/input/svelte/index.ts | 5 + snapshots/input/svelte/package.json | 5 + snapshots/input/svelte/stores.ts | 3 + snapshots/input/svelte/tsconfig.json | 10 + .../packages/a/src/Component.svelte | 17 + .../multi-project/packages/b/src/App.svelte | 12 + snapshots/output/svelte/Child.svelte | 23 + snapshots/output/svelte/Generic.svelte | 16 + snapshots/output/svelte/Legacy.svelte | 11 + snapshots/output/svelte/Parent.svelte | 89 ++++ snapshots/output/svelte/Plain.svelte | 13 + snapshots/output/svelte/helper.ts | 16 + snapshots/output/svelte/index.ts | 19 + snapshots/output/svelte/stores.ts | 10 + src/CommandLineOptions.ts | 4 +- src/FileIndexer.ts | 105 +++- src/ProjectIndexer.ts | 141 ++++-- src/SourceInfo.ts | 27 + src/Svelte.test.ts | 129 +++++ src/Svelte.ts | 478 ++++++++++++++++++ src/main.test.ts | 36 ++ src/main.ts | 21 +- tsconfig.json | 2 +- yarn.lock | 144 +++++- 34 files changed, 1372 insertions(+), 56 deletions(-) create mode 100644 snapshots/input/multi-project/packages/a/src/Component.svelte create mode 100644 snapshots/input/multi-project/packages/b/src/App.svelte create mode 100644 snapshots/input/svelte/Child.svelte create mode 100644 snapshots/input/svelte/Generic.svelte create mode 100644 snapshots/input/svelte/Legacy.svelte create mode 100644 snapshots/input/svelte/Parent.svelte create mode 100644 snapshots/input/svelte/Plain.svelte create mode 100644 snapshots/input/svelte/helper.ts create mode 100644 snapshots/input/svelte/index.ts create mode 100644 snapshots/input/svelte/package.json create mode 100644 snapshots/input/svelte/stores.ts create mode 100644 snapshots/input/svelte/tsconfig.json create mode 100644 snapshots/output/multi-project/packages/a/src/Component.svelte create mode 100644 snapshots/output/multi-project/packages/b/src/App.svelte create mode 100644 snapshots/output/svelte/Child.svelte create mode 100644 snapshots/output/svelte/Generic.svelte create mode 100644 snapshots/output/svelte/Legacy.svelte create mode 100644 snapshots/output/svelte/Parent.svelte create mode 100644 snapshots/output/svelte/Plain.svelte create mode 100644 snapshots/output/svelte/helper.ts create mode 100644 snapshots/output/svelte/index.ts create mode 100644 snapshots/output/svelte/stores.ts create mode 100644 src/SourceInfo.ts create mode 100644 src/Svelte.test.ts create mode 100644 src/Svelte.ts 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..0c52f4a8 --- /dev/null +++ b/snapshots/output/multi-project/packages/a/src/Component.svelte @@ -0,0 +1,17 @@ +// < 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..e1af4399 --- /dev/null +++ b/snapshots/output/multi-project/packages/b/src/App.svelte @@ -0,0 +1,12 @@ +// < 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..f9af6150 --- /dev/null +++ b/snapshots/output/svelte/Child.svelte @@ -0,0 +1,23 @@ +// < 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..afcc5cc8 --- /dev/null +++ b/snapshots/output/svelte/Generic.svelte @@ -0,0 +1,16 @@ +// < 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..3761612b --- /dev/null +++ b/snapshots/output/svelte/Legacy.svelte @@ -0,0 +1,11 @@ +// < 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..b4cc0578 --- /dev/null +++ b/snapshots/output/svelte/Parent.svelte @@ -0,0 +1,89 @@ +// < 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..8eef9379 --- /dev/null +++ b/snapshots/output/svelte/Plain.svelte @@ -0,0 +1,13 @@ +// < 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..163bcade --- /dev/null +++ b/snapshots/output/svelte/helper.ts @@ -0,0 +1,16 @@ +// < 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..2b62ad1b --- /dev/null +++ b/snapshots/output/svelte/index.ts @@ -0,0 +1,19 @@ +// < 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..9ea55af0 --- /dev/null +++ b/snapshots/output/svelte/stores.ts @@ -0,0 +1,10 @@ +// < 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..dc1fcad1 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,18 @@ 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 (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) diff --git a/src/ProjectIndexer.ts b/src/ProjectIndexer.ts index 92935c6f..017d8b36 100644 --- a/src/ProjectIndexer.ts +++ b/src/ProjectIndexer.ts @@ -9,33 +9,61 @@ 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 ): 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, cache.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 +71,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 +111,28 @@ 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 host = createCompilerHost(cache, config.options, options, hasSvelte) + 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 = cache.sourceInfos } public index(): void { const startTimestamp = Date.now() @@ -141,12 +190,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 +207,9 @@ export class ProjectIndexer { this.symbolCache, this.hasConstructor, this.packages, - sourceFile + sourceFile, + sourceInfo, + this.sourceInfos ) try { visitor.index() @@ -165,6 +219,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 +261,20 @@ export function languageForFileName(fileName: string): string { return '' } +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) { + existing.symbol_roles |= occurrence.symbol_roles + } 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..5741b916 --- /dev/null +++ b/src/Svelte.test.ts @@ -0,0 +1,129 @@ +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 { 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, '