From 1341018d2f21456c3b4853490a565a775b3a2425 Mon Sep 17 00:00:00 2001 From: "Lyu, Wei Da" Date: Sat, 8 Nov 2025 15:47:01 +0800 Subject: [PATCH 1/4] feat: quick fix for adding lang="ts" --- .changeset/four-papers-learn.md | 5 + .../features/CodeActionsProvider.ts | 322 ++++++------------ .../features/CodeActionsProvider.test.ts | 117 +++++++ .../codeaction-add-lang-ts-no-script.svelte | 1 + .../codeaction-add-lang-ts.svelte | 7 + packages/svelte2tsx/package.json | 2 +- 6 files changed, 236 insertions(+), 218 deletions(-) create mode 100644 .changeset/four-papers-learn.md create mode 100644 packages/language-server/test/plugins/typescript/testfiles/code-actions/codeaction-add-lang-ts-no-script.svelte create mode 100644 packages/language-server/test/plugins/typescript/testfiles/code-actions/codeaction-add-lang-ts.svelte diff --git a/.changeset/four-papers-learn.md b/.changeset/four-papers-learn.md new file mode 100644 index 000000000..f5838bc91 --- /dev/null +++ b/.changeset/four-papers-learn.md @@ -0,0 +1,5 @@ +--- +'svelte-language-server': patch +--- + +feat: quick fix for adding lang="ts" diff --git a/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts b/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts index 693c2581a..5d1ffa265 100644 --- a/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts +++ b/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts @@ -59,6 +59,7 @@ import { isTextSpanInGeneratedCode, SnapshotMap } from './utils'; +import { Node } from 'vscode-html-languageservice'; /** * TODO change this to protocol constant if it's part of the protocol @@ -698,15 +699,6 @@ export class CodeActionsProviderImpl implements CodeActionsProvider { errorCodes, formatCodeSettings, userPreferences - ), - ...this.getSvelteQuickFixes( - lang, - document, - cannotFindNameDiagnostic, - tsDoc, - formatCodeBasis, - userPreferences, - formatCodeSettings ) ); } @@ -760,8 +752,18 @@ export class CodeActionsProviderImpl implements CodeActionsProvider { lang ); + const addLangCodeAction = this.getAddLangTSCodeAction( + document, + context, + tsDoc, + formatCodeBasis + ); + // filter out empty code action - return codeActionsNotFilteredOut.map(({ codeAction }) => codeAction).concat(fixAllActions); + const result = codeActionsNotFilteredOut + .map(({ codeAction }) => codeAction) + .concat(fixAllActions); + return addLangCodeAction ? [addLangCodeAction].concat(result) : result; } private async convertAndFixCodeFixAction({ @@ -1126,88 +1128,6 @@ export class CodeActionsProviderImpl implements CodeActionsProvider { return path.split('/').length - 1; } - private getSvelteQuickFixes( - lang: ts.LanguageService, - document: Document, - cannotFindNameDiagnostics: Diagnostic[], - tsDoc: DocumentSnapshot, - formatCodeBasis: FormatCodeBasis, - userPreferences: ts.UserPreferences, - formatCodeSettings: ts.FormatCodeSettings - ): CustomFixCannotFindNameInfo[] { - const program = lang.getProgram(); - const sourceFile = program?.getSourceFile(tsDoc.filePath); - if (!program || !sourceFile) { - return []; - } - - const typeChecker = program.getTypeChecker(); - const results: CustomFixCannotFindNameInfo[] = []; - const quote = getQuotePreference(sourceFile, userPreferences); - const getGlobalCompletion = memoize(() => - lang.getCompletionsAtPosition(tsDoc.filePath, 0, userPreferences, formatCodeSettings) - ); - const [tsMajorStr] = ts.version.split('.'); - const tsSupportHandlerQuickFix = parseInt(tsMajorStr) >= 5; - - for (const diagnostic of cannotFindNameDiagnostics) { - const identifier = this.findIdentifierForDiagnostic(tsDoc, diagnostic, sourceFile); - - if (!identifier) { - continue; - } - - const isQuickFixTargetTargetStore = identifier?.escapedText.toString().startsWith('$'); - - const fixes: ts.CodeFixAction[] = []; - if (isQuickFixTargetTargetStore) { - fixes.push( - ...this.getSvelteStoreQuickFixes( - identifier, - lang, - tsDoc, - userPreferences, - formatCodeSettings, - getGlobalCompletion - ) - ); - } - - if (!tsSupportHandlerQuickFix) { - const isQuickFixTargetEventHandler = this.isQuickFixForEventHandler( - document, - diagnostic - ); - if (isQuickFixTargetEventHandler) { - fixes.push( - ...this.getEventHandlerQuickFixes( - identifier, - tsDoc, - typeChecker, - quote, - formatCodeBasis - ) - ); - } - } - - if (!fixes.length) { - continue; - } - - const originalPosition = tsDoc.getOriginalPosition(tsDoc.positionAt(identifier.pos)); - results.push( - ...fixes.map((fix) => ({ - name: identifier.getText(), - position: originalPosition, - ...fix - })) - ); - } - - return results; - } - private findIdentifierForDiagnostic( tsDoc: DocumentSnapshot, diagnostic: Diagnostic, @@ -1225,151 +1145,119 @@ export class CodeActionsProviderImpl implements CodeActionsProvider { return identifier; } - // TODO: Remove this in late 2023 - // when most users have upgraded to TS 5.0+ - private getSvelteStoreQuickFixes( - identifier: ts.Identifier, - lang: ts.LanguageService, - tsDoc: DocumentSnapshot, - userPreferences: ts.UserPreferences, - formatCodeSettings: ts.FormatCodeSettings, - getCompletions: () => ts.CompletionInfo | undefined - ): ts.CodeFixAction[] { - const storeIdentifier = identifier.escapedText.toString().substring(1); - const completion = getCompletions(); - - if (!completion) { - return []; - } - - const toFix = (c: ts.CompletionEntry) => - lang - .getCompletionEntryDetails( - tsDoc.filePath, - 0, - c.name, - formatCodeSettings, - c.source, - userPreferences, - c.data - ) - ?.codeActions?.map((a) => ({ - ...a, - changes: a.changes.map((change) => { - return { - ...change, - textChanges: change.textChanges.map((textChange) => { - // For some reason, TS sometimes adds the `type` modifier. Remove it. - return { - ...textChange, - newText: textChange.newText.replace(' type ', ' ') - }; - }) - }; - }), - fixName: FIX_IMPORT_FIX_NAME, - fixId: FIX_IMPORT_FIX_ID, - fixAllDescription: FIX_IMPORT_FIX_DESCRIPTION - })) ?? []; - - return flatten(completion.entries.filter((c) => c.name === storeIdentifier).map(toFix)); - } - - /** - * Workaround for TypeScript doesn't provide a quick fix if the signature is typed as union type, like `(() => void) | null` - * We can remove this once TypeScript doesn't have this limitation. - */ - private getEventHandlerQuickFixes( - identifier: ts.Identifier, + private getAddLangTSCodeAction( + document: Document, + context: CodeActionContext, tsDoc: DocumentSnapshot, - typeChecker: ts.TypeChecker, - quote: string, formatCodeBasis: FormatCodeBasis - ): ts.CodeFixAction[] { - const type = identifier && typeChecker.getContextualType(identifier); - - // if it's not union typescript should be able to do it. no need to enhance - if (!type || !type.isUnion()) { - return []; + ) { + if (tsDoc.scriptKind !== ts.ScriptKind.JS) { + return; } - const nonNullable = type.getNonNullableType(); - - if ( - !( - nonNullable.flags & ts.TypeFlags.Object && - (nonNullable as ts.ObjectType).objectFlags & ts.ObjectFlags.Anonymous - ) - ) { - return []; + let hasTSOnlyDiagnostic = false; + for (const diagnostic of context.diagnostics) { + const num = Number(diagnostic.code); + const canOnlyBeUsedInTS = num >= 8004 && num <= 8017; + if (canOnlyBeUsedInTS) { + hasTSOnlyDiagnostic = true; + break; + } + } + if (!hasTSOnlyDiagnostic) { + return; } - const signature = typeChecker.getSignaturesOfType(nonNullable, ts.SignatureKind.Call)[0]; + if (!document.scriptInfo && !document.moduleScriptInfo) { + const hasNonTopLevelLang = document.html.roots.some((node) => + this.hasNonTopLevelLangTsScriptTag(node) + ); + // Might be because issue with parsing the script tag, so don't suggest adding a new one + if (hasNonTopLevelLang) { + return; + } + } - const parameters = signature.parameters.map((p) => { - const declaration = p.valueDeclaration ?? p.declarations?.[0]; - const typeString = declaration - ? typeChecker.typeToString(typeChecker.getTypeOfSymbolAtLocation(p, declaration)) - : ''; + const edits = [document.scriptInfo, document.moduleScriptInfo] + .map((info) => { + if (!info) { + return; + } - return { name: p.name, typeString }; - }); + const startTagNameEnd = document.positionAt(info.container.start + 7); // ' + formatCodeBasis.newLine } ] } ] - } - ]; + }, + CodeActionKind.QuickFix + ); } - private isQuickFixForEventHandler(document: Document, diagnostic: Diagnostic) { - const htmlNode = document.html.findNodeAt(document.offsetAt(diagnostic.range.start)); - if ( - !htmlNode.attributes || - !Object.keys(htmlNode.attributes).some((attr) => attr.startsWith('on:')) - ) { - return false; + private hasNonTopLevelLangTsScriptTag(node: Node): boolean { + for (const element of node.children) { + if ( + element.tag === 'script' && + (element.attributes?.lang === '"ts"' || element.attributes?.lang === "'ts'") && + element.parent + ) { + return true; + } + if (this.hasNonTopLevelLangTsScriptTag(element)) { + return true; + } } - - return true; + return false; } private async getApplicableRefactors( diff --git a/packages/language-server/test/plugins/typescript/features/CodeActionsProvider.test.ts b/packages/language-server/test/plugins/typescript/features/CodeActionsProvider.test.ts index 5c0af619b..2e87114ce 100644 --- a/packages/language-server/test/plugins/typescript/features/CodeActionsProvider.test.ts +++ b/packages/language-server/test/plugins/typescript/features/CodeActionsProvider.test.ts @@ -2512,6 +2512,71 @@ describe('CodeActionsProvider', function () { assert.deepStrictEqual(codeActions, []); }); + it('provides code action for adding lang="ts"', async () => { + const { provider, document } = setup('codeaction-add-lang-ts.svelte'); + + const codeActions = await provider.getCodeActions( + document, + Range.create(Position.create(0, 0), Position.create(0, 1)), + { + diagnostics: [ + { + code: 8010, + message: 'Type annotations can only be used in TypeScript files.', + range: Range.create(Position.create(1, 18), Position.create(1, 24)), + source: 'ts' + } + ], + only: [CodeActionKind.QuickFix] + } + ); + + assert.deepStrictEqual(codeActions, [ + { + title: 'Add lang="ts" to \n', + range: { + start: { + character: 0, + line: 0 + }, + end: { + character: 0, + line: 0 + } + } + } + ], + textDocument: { + uri: getUri('codeaction-add-lang-ts-no-script.svelte'), + version: null + } + } + ] + } + } + ]); + }); }); diff --git a/packages/language-server/test/plugins/typescript/testfiles/code-actions/codeaction-add-lang-ts-no-script.svelte b/packages/language-server/test/plugins/typescript/testfiles/code-actions/codeaction-add-lang-ts-no-script.svelte new file mode 100644 index 000000000..3a462a520 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/testfiles/code-actions/codeaction-add-lang-ts-no-script.svelte @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/language-server/test/plugins/typescript/testfiles/code-actions/codeaction-add-lang-ts.svelte b/packages/language-server/test/plugins/typescript/testfiles/code-actions/codeaction-add-lang-ts.svelte new file mode 100644 index 000000000..0c39d5de6 --- /dev/null +++ b/packages/language-server/test/plugins/typescript/testfiles/code-actions/codeaction-add-lang-ts.svelte @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/packages/svelte2tsx/package.json b/packages/svelte2tsx/package.json index 15b242f11..833c8e36a 100644 --- a/packages/svelte2tsx/package.json +++ b/packages/svelte2tsx/package.json @@ -50,7 +50,7 @@ "build": "rollup -c", "prepublishOnly": "npm run build", "dev": "rollup -c -w", - "test": "mocha test/test.ts" + "test": "mocha test/test.ts --no-experimental-strip-types" }, "files": [ "index.mjs", From 3c4e36644d9e393d8cbb03d895a4a33ace7190bc Mon Sep 17 00:00:00 2001 From: "Lyu, Wei Da" Date: Sat, 8 Nov 2025 16:03:07 +0800 Subject: [PATCH 2/4] oops the todo comment is on the wrong method --- .../features/CodeActionsProvider.ts | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts b/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts index 5d1ffa265..5f5922acb 100644 --- a/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts +++ b/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts @@ -699,6 +699,13 @@ export class CodeActionsProviderImpl implements CodeActionsProvider { errorCodes, formatCodeSettings, userPreferences + ), + ...this.getSvelteQuickFixes( + lang, + cannotFindNameDiagnostic, + tsDoc, + userPreferences, + formatCodeSettings ) ); } @@ -1128,6 +1135,64 @@ export class CodeActionsProviderImpl implements CodeActionsProvider { return path.split('/').length - 1; } + private getSvelteQuickFixes( + lang: ts.LanguageService, + cannotFindNameDiagnostics: Diagnostic[], + tsDoc: DocumentSnapshot, + userPreferences: ts.UserPreferences, + formatCodeSettings: ts.FormatCodeSettings + ): CustomFixCannotFindNameInfo[] { + const program = lang.getProgram(); + const sourceFile = program?.getSourceFile(tsDoc.filePath); + if (!program || !sourceFile) { + return []; + } + + const results: CustomFixCannotFindNameInfo[] = []; + const getGlobalCompletion = memoize(() => + lang.getCompletionsAtPosition(tsDoc.filePath, 0, userPreferences, formatCodeSettings) + ); + + for (const diagnostic of cannotFindNameDiagnostics) { + const identifier = this.findIdentifierForDiagnostic(tsDoc, diagnostic, sourceFile); + + if (!identifier) { + continue; + } + + const isQuickFixTargetTargetStore = identifier?.escapedText.toString().startsWith('$'); + + const fixes: ts.CodeFixAction[] = []; + if (isQuickFixTargetTargetStore) { + fixes.push( + ...this.getSvelteStoreQuickFixes( + identifier, + lang, + tsDoc, + userPreferences, + formatCodeSettings, + getGlobalCompletion + ) + ); + } + + if (!fixes.length) { + continue; + } + + const originalPosition = tsDoc.getOriginalPosition(tsDoc.positionAt(identifier.pos)); + results.push( + ...fixes.map((fix) => ({ + name: identifier.getText(), + position: originalPosition, + ...fix + })) + ); + } + + return results; + } + private findIdentifierForDiagnostic( tsDoc: DocumentSnapshot, diagnostic: Diagnostic, @@ -1145,6 +1210,54 @@ export class CodeActionsProviderImpl implements CodeActionsProvider { return identifier; } + private getSvelteStoreQuickFixes( + identifier: ts.Identifier, + lang: ts.LanguageService, + tsDoc: DocumentSnapshot, + userPreferences: ts.UserPreferences, + formatCodeSettings: ts.FormatCodeSettings, + getCompletions: () => ts.CompletionInfo | undefined + ): ts.CodeFixAction[] { + const storeIdentifier = identifier.escapedText.toString().substring(1); + const completion = getCompletions(); + + if (!completion) { + return []; + } + + const toFix = (c: ts.CompletionEntry) => + lang + .getCompletionEntryDetails( + tsDoc.filePath, + 0, + c.name, + formatCodeSettings, + c.source, + userPreferences, + c.data + ) + ?.codeActions?.map((a) => ({ + ...a, + changes: a.changes.map((change) => { + return { + ...change, + textChanges: change.textChanges.map((textChange) => { + // For some reason, TS sometimes adds the `type` modifier. Remove it. + return { + ...textChange, + newText: textChange.newText.replace(' type ', ' ') + }; + }) + }; + }), + fixName: FIX_IMPORT_FIX_NAME, + fixId: FIX_IMPORT_FIX_ID, + fixAllDescription: FIX_IMPORT_FIX_DESCRIPTION + })) ?? []; + + return flatten(completion.entries.filter((c) => c.name === storeIdentifier).map(toFix)); + } + private getAddLangTSCodeAction( document: Document, context: CodeActionContext, From 73d53a0b3b626b95c600cdb6d69b9c00f06368df Mon Sep 17 00:00:00 2001 From: "Lyu, Wei Da" Date: Tue, 11 Nov 2025 09:35:48 +0800 Subject: [PATCH 3/4] check top-level as well It could still be considered as non-top-level if "under if block" checks failed --- .../features/CodeActionsProvider.ts | 20 +++++++++---------- .../features/CodeActionsProvider.test.ts | 4 ++++ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts b/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts index 5f5922acb..763e30ee2 100644 --- a/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts +++ b/packages/language-server/src/plugins/typescript/features/CodeActionsProvider.ts @@ -1283,7 +1283,7 @@ export class CodeActionsProviderImpl implements CodeActionsProvider { if (!document.scriptInfo && !document.moduleScriptInfo) { const hasNonTopLevelLang = document.html.roots.some((node) => - this.hasNonTopLevelLangTsScriptTag(node) + this.hasLangTsScriptTag(node) ); // Might be because issue with parsing the script tag, so don't suggest adding a new one if (hasNonTopLevelLang) { @@ -1357,16 +1357,16 @@ export class CodeActionsProviderImpl implements CodeActionsProvider { ); } - private hasNonTopLevelLangTsScriptTag(node: Node): boolean { + private hasLangTsScriptTag(node: Node): boolean { + if ( + node.tag === 'script' && + (node.attributes?.lang === '"ts"' || node.attributes?.lang === "'ts'") && + node.parent + ) { + return true; + } for (const element of node.children) { - if ( - element.tag === 'script' && - (element.attributes?.lang === '"ts"' || element.attributes?.lang === "'ts'") && - element.parent - ) { - return true; - } - if (this.hasNonTopLevelLangTsScriptTag(element)) { + if (this.hasLangTsScriptTag(element)) { return true; } } diff --git a/packages/language-server/test/plugins/typescript/features/CodeActionsProvider.test.ts b/packages/language-server/test/plugins/typescript/features/CodeActionsProvider.test.ts index 2e87114ce..52cb59733 100644 --- a/packages/language-server/test/plugins/typescript/features/CodeActionsProvider.test.ts +++ b/packages/language-server/test/plugins/typescript/features/CodeActionsProvider.test.ts @@ -2662,6 +2662,10 @@ describe('CodeActionsProvider', function () { } ); + (codeActions[0]?.edit?.documentChanges?.[0])?.edits.forEach( + (edit) => (edit.newText = harmonizeNewLines(edit.newText)) + ); + assert.deepStrictEqual(codeActions, [ { title: 'Add ' + formatCodeBasis.newLine + } + ] + } + ] + }, + CodeActionKind.QuickFix + ); } const edits = [document.scriptInfo, document.moduleScriptInfo] @@ -1304,12 +1328,11 @@ export class CodeActionsProviderImpl implements CodeActionsProvider { end: document.positionAt(info.start) }) .indexOf('lang='); + if (existingLangOffset !== -1) { - return { - range: Range.create(startTagNameEnd, startTagNameEnd), - newText: ' lang="ts"' - }; + return; } + return { range: Range.create(startTagNameEnd, startTagNameEnd), newText: ' lang="ts"' @@ -1334,27 +1357,6 @@ export class CodeActionsProviderImpl implements CodeActionsProvider { CodeActionKind.QuickFix ); } - - return CodeAction.create( - 'Add ' + formatCodeBasis.newLine - } - ] - } - ] - }, - CodeActionKind.QuickFix - ); } private hasLangTsScriptTag(node: Node): boolean {