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, '