Skip to content

Commit

Permalink
Extract into simple component refactoring (#2496)
Browse files Browse the repository at this point in the history
Co-authored-by: johnsoncodehk <johnsoncodehk@gmail.com>
  • Loading branch information
zardoy and johnsoncodehk authored Jul 21, 2023
1 parent e55641b commit b7fd537
Show file tree
Hide file tree
Showing 7 changed files with 305 additions and 6 deletions.
2 changes: 1 addition & 1 deletion packages/vscode-vue/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 0 additions & 1 deletion packages/vscode-vue/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,3 @@ try {
} catch {
module.exports = require('./dist/server');
}

10 changes: 10 additions & 0 deletions packages/vscode-vue/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down
2 changes: 2 additions & 0 deletions packages/vue-language-service/src/languageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
285 changes: 285 additions & 0 deletions packages/vue-language-service/src/plugins/vue-extract-file.ts
Original file line number Diff line number Diff line change
@@ -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<Provide> | undefined, modules): ReturnType<Service> => {

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<string, boolean>;
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<string, {
name: string;
type: string;
model: boolean;
}>();
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</${name}>\n`;
}

function isInitialIndentNeeded(ts: typeof import("typescript/lib/tsserverlibrary"), languageKind: ts.ScriptKind, initialIndentSetting: Record<string, boolean>) {
const languageKindIdMap = {
[ts.ScriptKind.JS]: 'javascript',
[ts.ScriptKind.TS]: 'typescript',
[ts.ScriptKind.JSX]: 'javascriptreact',
[ts.ScriptKind.TSX]: 'typescriptreact',
} as Record<ts.ScriptKind, string>;
return initialIndentSetting[languageKindIdMap[languageKind]] ?? false;
}
7 changes: 4 additions & 3 deletions packages/vue-language-service/tests/utils/createTester.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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 }]);
Expand Down
4 changes: 3 additions & 1 deletion packages/vue-tsc/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) {
Expand Down

0 comments on commit b7fd537

Please sign in to comment.