diff --git a/packages/vscode-vue/README.md b/packages/vscode-vue/README.md index e4b72671c1..865ad977d5 100644 --- a/packages/vscode-vue/README.md +++ b/packages/vscode-vue/README.md @@ -14,7 +14,7 @@ Vue Language Features is a language support extension built for Vue, Vitepress a - [Vitesse](https://github.com/antfu/vitesse) - [petite](https://github.com/JessicaSachs/petite) - [vue3-eslint-stylelint-demo](https://github.com/sethidden/vue3-eslint-stylelint-demo) (Volar + ESLint + stylelint + husky) -- [volar-starter](https://github.com/vuejs/language-tools-starter) (For bug report and experiment features testing) +- [volar-starter](https://github.com/johnsoncodehk/volar-starter) (For bug report and experiment features testing) ## Usage diff --git a/packages/vscode-vue/server.js b/packages/vscode-vue/server.js index 1af694d286..9e69eb2101 100644 --- a/packages/vscode-vue/server.js +++ b/packages/vscode-vue/server.js @@ -3,4 +3,3 @@ try { } catch { module.exports = require('./dist/server'); } - diff --git a/packages/vscode-vue/src/middleware.ts b/packages/vscode-vue/src/middleware.ts index 125f3525b5..98662100d2 100644 --- a/packages/vscode-vue/src/middleware.ts +++ b/packages/vscode-vue/src/middleware.ts @@ -6,6 +6,16 @@ import { attrNameCasings, tagNameCasings } from './features/nameCasing'; export const middleware: lsp.Middleware = { ...baseMiddleware, + async resolveCodeAction(item, token, next) { + if (item.kind?.value === 'refactor.move.newFile.dumb') { + const inputName = await vscode.window.showInputBox({ value: (item as any).data.original.data.newName }); + if (!inputName) { + return item; // cancel + } + (item as any).data.original.data.newName = inputName; + } + return await (baseMiddleware.resolveCodeAction?.(item, token, next) ?? next(item, token)); + }, workspace: { configuration(params, token, next) { if (params.items.some(item => item.section === 'vue.complete.casing.props' || item.section === 'vue.complete.casing.tags')) { diff --git a/packages/vue-language-service/src/languageService.ts b/packages/vue-language-service/src/languageService.ts index 0bc063bc93..d23acf7222 100644 --- a/packages/vue-language-service/src/languageService.ts +++ b/packages/vue-language-service/src/languageService.ts @@ -23,6 +23,7 @@ import createVueTemplateLanguageService from './plugins/vue-template'; import createVueTqService from './plugins/vue-twoslash-queries'; import createVisualizeHiddenCallbackParamService from './plugins/vue-visualize-hidden-callback-param'; import createDirectiveCommentsService from './plugins/vue-directive-comments'; +import createExtractComponentService from './plugins/vue-extract-file'; import { TagNameCasing, VueCompilerOptions } from './types'; export interface Settings { @@ -291,6 +292,7 @@ function resolvePlugins( services['vue/autoInsertSpaces'] ??= createAutoAddSpaceService(); services['vue/visualizeHiddenCallbackParam'] ??= createVisualizeHiddenCallbackParamService(); services['vue/directiveComments'] ??= createDirectiveCommentsService(); + services['vue/extractComponent'] ??= createExtractComponentService(); services.emmet ??= createEmmetService(); return services; diff --git a/packages/vue-language-service/src/plugins/vue-extract-file.ts b/packages/vue-language-service/src/plugins/vue-extract-file.ts new file mode 100644 index 0000000000..568013e1ff --- /dev/null +++ b/packages/vue-language-service/src/plugins/vue-extract-file.ts @@ -0,0 +1,285 @@ +import { CreateFile, Service, ServiceContext, TextDocumentEdit, TextEdit } from '@volar/language-service'; +import type { ElementNode, RootNode } from '@vue/compiler-dom'; +import { SfcBlock, VueFile, walkElementNodes } from '@vue/language-core'; +import type * as ts from 'typescript/lib/tsserverlibrary'; +import type { Provide } from 'volar-service-typescript'; + +interface ActionData { + uri: string; + range: [number, number]; + newName: string; +} + +export default function (): Service { + + return (ctx: ServiceContext | undefined, modules): ReturnType => { + + if (!modules?.typescript) + return {}; + + const ts = modules.typescript; + + return { + + async provideCodeActions(document, range, _context) { + + const startOffset = document.offsetAt(range.start); + const endOffset = document.offsetAt(range.end); + if (startOffset === endOffset) { + return; + } + + const [vueFile] = ctx!.documents.getVirtualFileByUri(document.uri); + if (!vueFile || !(vueFile instanceof VueFile)) + return; + + const { sfc } = vueFile; + const script = sfc.scriptSetup ?? sfc.script; + const scriptAst = sfc.scriptSetupAst ?? sfc.scriptAst; + + if (!sfc.template || !sfc.templateAst || !script || !scriptAst) + return; + + const templateCodeRange = selectTemplateCode(startOffset, endOffset, sfc.template, sfc.templateAst); + if (!templateCodeRange) + return; + + return [ + { + title: 'Extract into new dumb component', + kind: 'refactor.move.newFile.dumb', + data: { + uri: document.uri, + range: [startOffset, endOffset], + newName: 'NewComponent', + } satisfies ActionData, + }, + ]; + }, + + async resolveCodeAction(codeAction) { + + const { uri, range, newName } = codeAction.data as ActionData; + const document = ctx!.getTextDocument(uri)!; + const [startOffset, endOffset]: [number, number] = range; + const [vueFile] = ctx!.documents.getVirtualFileByUri(document.uri) as [VueFile, any]; + const { sfc } = vueFile; + const script = sfc.scriptSetup ?? sfc.script; + const scriptAst = sfc.scriptSetupAst ?? sfc.scriptAst; + + if (!sfc.template || !sfc.templateAst || !script || !scriptAst) + return codeAction; + + const templateCodeRange = selectTemplateCode(startOffset, endOffset, sfc.template, sfc.templateAst); + if (!templateCodeRange) + return codeAction; + + const languageService = ctx!.inject('typescript/languageService'); + const languageServiceHost = ctx!.inject('typescript/languageServiceHost'); + const sourceFile = languageService.getProgram()!.getSourceFile(vueFile.mainScriptName)!; + const sourceFileKind = languageServiceHost.getScriptKind?.(vueFile.mainScriptName); + const toExtract = collectExtractProps(); + const initialIndentSetting = await ctx!.env.getConfiguration!('volar.format.initialIndent') as Record; + const newUri = document.uri.substring(0, document.uri.lastIndexOf('/') + 1) + `${newName}.vue`; + const lastImportNode = getLastImportNode(scriptAst); + + let newFileTags = []; + + newFileTags.push( + constructTag('template', [], initialIndentSetting.html, sfc.template.content.substring(templateCodeRange[0], templateCodeRange[1])) + ); + + if (toExtract.length) { + newFileTags.push( + constructTag('script', ['setup', 'lang="ts"'], isInitialIndentNeeded(ts, sourceFileKind!, initialIndentSetting), generateNewScriptContents()) + ); + } + if (sfc.template.startTagEnd > script.startTagEnd) { + newFileTags = newFileTags.reverse(); + } + + return { + ...codeAction, + edit: { + documentChanges: [ + // editing current file + { + textDocument: { + uri: document.uri, + version: null, + }, + edits: [ + { + range: { + start: document.positionAt(sfc.template.startTagEnd + templateCodeRange[0]), + end: document.positionAt(sfc.template.startTagEnd + templateCodeRange[1]), + }, + newText: generateReplaceTemplate(), + } satisfies TextEdit, + { + range: lastImportNode ? { + start: document.positionAt(script.startTagEnd + lastImportNode.end), + end: document.positionAt(script.startTagEnd + lastImportNode.end), + } : { + start: document.positionAt(script.startTagEnd), + end: document.positionAt(script.startTagEnd), + }, + newText: `\nimport ${newName} from './${newName}.vue'`, + } satisfies TextEdit, + ], + } satisfies TextDocumentEdit, + + // creating new file with content + { + uri: newUri, + kind: 'create', + } satisfies CreateFile, + { + textDocument: { + uri: newUri, + version: null, + }, + edits: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: newFileTags.join('\n'), + } satisfies TextEdit, + ], + } satisfies TextDocumentEdit, + ], + }, + }; + + function getLastImportNode(sourceFile: ts.SourceFile) { + + let lastImportNode: ts.Node | undefined; + + for (const statement of sourceFile.statements) { + if (ts.isImportDeclaration(statement)) { + lastImportNode = statement; + } + else { + break; + } + } + + return lastImportNode; + } + + function collectExtractProps() { + + const result = new Map(); + const checker = languageService.getProgram()!.getTypeChecker(); + const maps = [...ctx!.documents.getMapsByVirtualFileName(vueFile.mainScriptName)]; + + sourceFile.forEachChild(function visit(node) { + if ( + ts.isPropertyAccessExpression(node) + && ts.isIdentifier(node.expression) + && node.expression.text === '__VLS_ctx' + && ts.isIdentifier(node.name) + ) { + const { name } = node; + for (const [_, map] of maps) { + const source = map.map.toSourceOffset(name.getEnd()); + if (source && source[0] >= sfc.template!.startTagEnd + templateCodeRange![0] && source[0] <= sfc.template!.startTagEnd + templateCodeRange![1] && source[1].data.semanticTokens) { + if (!result.has(name.text)) { + const type = checker.getTypeAtLocation(node); + const typeString = checker.typeToString(type, node, ts.TypeFormatFlags.NoTruncation); + result.set(name.text, { + name: name.text, + type: typeString.includes('__VLS_') ? 'any' : typeString, + model: false, + }); + } + const isModel = ts.isPostfixUnaryExpression(node.parent) || ts.isBinaryExpression(node.parent); + if (isModel) { + result.get(name.text)!.model = true; + } + break; + } + } + } + node.forEachChild(visit); + }); + + return [...result.values()]; + } + + function generateNewScriptContents() { + const lines = []; + const props = [...toExtract.values()].filter(p => !p.model); + const models = [...toExtract.values()].filter(p => p.model); + if (props.length) { + lines.push(`defineProps<{ \n\t${props.map(p => `${p.name}: ${p.type};`).join('\n\t')}\n}>()`); + } + for (const model of models) { + lines.push(`const ${model.name} = defineModel<${model.type}>('${model.name}', { required: true })`); + } + return lines.join('\n'); + } + + function generateReplaceTemplate() { + const props = [...toExtract.values()].filter(p => !p.model); + const models = [...toExtract.values()].filter(p => p.model); + return [ + `<${newName}`, + ...props.map(p => `:${p.name}="${p.name}"`), + ...models.map(p => `v-model:${p.name}="${p.name}"`), + `/>`, + ].join(' '); + } + }, + + transformCodeAction(item) { + return item; // ignore mapping + }, + }; + }; +} + +function selectTemplateCode(startOffset: number, endOffset: number, templateBlock: SfcBlock, templateAst: RootNode) { + + if (startOffset < templateBlock.startTagEnd || endOffset > templateBlock.endTagStart) + return; + + const insideNodes: ElementNode[] = []; + + walkElementNodes(templateAst, (node) => { + if ( + node.loc.start.offset + templateBlock.startTagEnd >= startOffset + && node.loc.end.offset + templateBlock.startTagEnd <= endOffset + ) { + insideNodes.push(node); + } + }); + + if (insideNodes.length) { + const first = insideNodes.sort((a, b) => a.loc.start.offset - b.loc.start.offset)[0]; + const last = insideNodes.sort((a, b) => b.loc.end.offset - a.loc.end.offset)[0]; + return [first.loc.start.offset, last.loc.end.offset]; + } +} + +function constructTag(name: string, attributes: string[], initialIndent: boolean, content: string) { + if (initialIndent) content = content.split('\n').map(line => `\t${line}`).join('\n'); + const attributesString = attributes.length ? ` ${attributes.join(' ')}` : ''; + return `<${name}${attributesString}>\n${content}\n\n`; +} + +function isInitialIndentNeeded(ts: typeof import("typescript/lib/tsserverlibrary"), languageKind: ts.ScriptKind, initialIndentSetting: Record) { + const languageKindIdMap = { + [ts.ScriptKind.JS]: 'javascript', + [ts.ScriptKind.TS]: 'typescript', + [ts.ScriptKind.JSX]: 'javascriptreact', + [ts.ScriptKind.TSX]: 'typescriptreact', + } as Record; + return initialIndentSetting[languageKindIdMap[languageKind]] ?? false; +} diff --git a/packages/vue-language-service/tests/utils/createTester.ts b/packages/vue-language-service/tests/utils/createTester.ts index 51edf99933..a976fc499d 100644 --- a/packages/vue-language-service/tests/utils/createTester.ts +++ b/packages/vue-language-service/tests/utils/createTester.ts @@ -1,9 +1,9 @@ -import { resolveConfig } from '../..'; -import * as ts from 'typescript'; +import { FileType, TypeScriptLanguageHost, createLanguageService } from '@volar/language-service'; import * as fs from 'fs'; import * as path from 'path'; +import type * as ts from 'typescript/lib/tsserverlibrary'; import { URI } from 'vscode-uri'; -import { FileType, TypeScriptLanguageHost, createLanguageService } from '@volar/language-service'; +import { resolveConfig } from '../..'; const uriToFileName = (uri: string) => URI.parse(uri).fsPath.replace(/\\/g, '/'); const fileNameToUri = (fileName: string) => URI.file(fileName).toString(); @@ -14,6 +14,7 @@ export const tester = createTester(testRoot); function createTester(root: string) { + const ts = require('typescript') as typeof import('typescript/lib/tsserverlibrary'); const realTsConfig = path.join(root, 'tsconfig.json').replace(/\\/g, '/'); const config = ts.readJsonConfigFile(realTsConfig, ts.sys.readFile); const parsedCommandLine = ts.parseJsonSourceFileConfigFileContent(config, ts.sys, path.dirname(realTsConfig), {}, realTsConfig, undefined, [{ extension: 'vue', isMixedContent: true, scriptKind: ts.ScriptKind.Deferred }]); diff --git a/packages/vue-tsc/src/index.ts b/packages/vue-tsc/src/index.ts index 0815b596ba..73c8064b43 100644 --- a/packages/vue-tsc/src/index.ts +++ b/packages/vue-tsc/src/index.ts @@ -1,4 +1,4 @@ -import * as ts from 'typescript'; +import type * as ts from 'typescript/lib/tsserverlibrary'; import * as vue from '@vue/language-core'; import * as vueTs from '@vue/typescript'; import { state } from './shared'; @@ -31,6 +31,8 @@ export function createProgram(options: ts.CreateProgramOptions) { if (!options.host) throw toThrow('!options.host'); + const ts = require('typescript') as typeof import('typescript/lib/tsserverlibrary'); + let program = options.oldProgram as _Program | undefined; if (state.hook) {