Skip to content
索引

源码解析

vscode语言服务器

以下内容翻译自vscode官方文档:

语言服务器是一种特殊的 Visual Studio Code 扩展,可为许多编程语言提供编辑体验。 使用语言服务器,您可以实现自动完成、错误检查(诊断)、跳转到定义以及 VS Code 支持的许多其他语言功能。

然而,在 VS Code 中实现对语言功能的支持时,我们发现了三个常见问题:

首先,语言服务器通常以其本机编程语言实现,这给将它们与具有 Node.js 运行时的 VS Code 集成带来了挑战 。

此外,语言功能可能是资源密集型的。 例如,为了正确验证文件,语言服务器需要解析大量文件,为它们建立抽象语法树并执行静态程序分析。 这些操作可能会导致大量的 CPU 和内存使用,我们需要确保 VS Code 的性能不受影响。

最后,将多种语言工具与多个代码编辑器集成可能需要付出巨大的努力。 从语言工具的角度来看,它们需要适应具有不同API的代码编辑器。 从代码编辑者的角度来看,他们不能期望语言工具提供任何统一的 API。 这使得在 N 个代码编辑器中实现对 M 种语言的语言支持成为 M * N 的工作。

为了解决这些问题,Microsoft 指定了语言服务器协议 Language Server Protocol, 该协议标准化了语言工具和代码编辑器之间的通信。 这样,语言服务器可以用任何语言实现并在自己的进程中运行,以避免性能成本,因为它们通过语言服务器协议与代码编辑器进行通信。 此外,任何符合 LSP 的语言工具都可以与多个符合 LSP 的代码编辑器集成,并且任何符合 LSP 的代码编辑器都可以轻松选择多个符合 LSP 的语言工具。 LSP 对于语言工具提供商和代码编辑器供应商来说都是双赢!

实现语言服务器

在 VS Code 中,语言服务器有两部分:

语言客户端:用 JavaScript / TypeScript 编写的普通 VS Code 扩展。 此扩展可以访问所有 VS Code 命名空间 API。 语言服务器:在单独进程中运行的语言分析工具。

如上所述,在单独的进程中运行语言服务器有两个好处:

该分析工具可以用任何语言实现,只要它能够按照语言服务器协议与语言客户端进行通信即可。 由于语言分析工具通常会占用大量 CPU 和内存,因此在单独的进程中运行它们可以避免性能成本。 例如,HTML 语言客户端和 PHP 语言客户端是用 TypeScript 编写的普通 VS Code 扩展。 它们各自实例化一个对应的Language Server,并通过LSP与其进行通信。 尽管PHP语言服务器是用PHP编写的,但它仍然可以通过LSP与PHP语言客户端进行通信。

serverMain.ts:创建与vscode客户端的连接

ts
import { TextDocument } from 'vscode-languageserver-textdocument';
import {
  createConnection,
  TextDocuments,
  TextDocumentSyncKind
} from 'vscode-languageserver/node';
import { autoRenameTag, autoRenameTagRequestType } from './autoRenameTag';
import {
  enableBetterErrorHandlingAndLogging,
  handleError
} from './errorHandlingAndLogging';

const connection = createConnection();
const documents = new TextDocuments(TextDocument);

enableBetterErrorHandlingAndLogging(connection);

connection.onInitialize(() => ({
  capabilities: {
    textDocumentSync: TextDocumentSyncKind.Incremental
  }
}));

connection.onInitialized(() => {
  console.log('Auto Rename Tag has been initialized.');
});

const handleRequest: <Params, Result>(
  fn: (params: Params) => Result
) => (params: Params) => Result = fn => params => {
  try {
    return fn(params);
  } catch (error) {
    handleError(error);
    throw error;
  }
};

connection.onRequest(
  autoRenameTagRequestType,
  handleRequest(autoRenameTag(documents))
);

documents.listen(connection);
connection.listen();
import { TextDocument } from 'vscode-languageserver-textdocument';
import {
  createConnection,
  TextDocuments,
  TextDocumentSyncKind
} from 'vscode-languageserver/node';
import { autoRenameTag, autoRenameTagRequestType } from './autoRenameTag';
import {
  enableBetterErrorHandlingAndLogging,
  handleError
} from './errorHandlingAndLogging';

const connection = createConnection();
const documents = new TextDocuments(TextDocument);

enableBetterErrorHandlingAndLogging(connection);

connection.onInitialize(() => ({
  capabilities: {
    textDocumentSync: TextDocumentSyncKind.Incremental
  }
}));

connection.onInitialized(() => {
  console.log('Auto Rename Tag has been initialized.');
});

const handleRequest: <Params, Result>(
  fn: (params: Params) => Result
) => (params: Params) => Result = fn => params => {
  try {
    return fn(params);
  } catch (error) {
    handleError(error);
    throw error;
  }
};

connection.onRequest(
  autoRenameTagRequestType,
  handleRequest(autoRenameTag(documents))
);

documents.listen(connection);
connection.listen();

使用vscode-languageserver-textdocument模块进行文本文档的访问和操作。下面是对代码的解释:

  1. 导入模块:
javascript
import { TextDocument } from "vscode-languageserver-textdocument";
import { createConnection, TextDocuments, TextDocumentSyncKind } from "vscode-languageserver/node";
import { autoRenameTag, autoRenameTagRequestType } from "./autoRenameTag";
import { enableBetterErrorHandlingAndLogging, handleError } from "./errorHandlingAndLogging";
import { TextDocument } from "vscode-languageserver-textdocument";
import { createConnection, TextDocuments, TextDocumentSyncKind } from "vscode-languageserver/node";
import { autoRenameTag, autoRenameTagRequestType } from "./autoRenameTag";
import { enableBetterErrorHandlingAndLogging, handleError } from "./errorHandlingAndLogging";

首先,代码导入了所需的模块。vscode-languageserver-textdocument模块用于访问和操作文本文档,createConnectionTextDocuments模块用于创建与客户端的连接和管理文本文档集合。另外,代码还导入了自定义的autoRenameTag和错误处理相关的模块。

  1. 创建连接和文档集合:
javascript
const connection = createConnection();
const documents = new TextDocuments(TextDocument);
const connection = createConnection();
const documents = new TextDocuments(TextDocument);

代码创建了与客户端的连接实例,并使用TextDocuments类创建了一个documents对象,用于管理打开的文本文档。

  1. 初始化和事件处理:
javascript
enableBetterErrorHandlingAndLogging(connection);

connection.onInitialize(() => ({
  capabilities: {
    textDocumentSync: TextDocumentSyncKind.Incremental,
  },
}));

connection.onInitialized(() => {
  console.log("Auto Rename Tag has been initialized.");
});
enableBetterErrorHandlingAndLogging(connection);

connection.onInitialize(() => ({
  capabilities: {
    textDocumentSync: TextDocumentSyncKind.Incremental,
  },
}));

connection.onInitialized(() => {
  console.log("Auto Rename Tag has been initialized.");
});

代码注册了一些事件处理程序。enableBetterErrorHandlingAndLogging函数用于改善错误处理和日志记录。onInitialize事件处理程序在插件初始化时被触发,返回了插件的功能。在这个例子中,只定义了文本文档同步方式为增量同步。onInitialized事件处理程序在插件初始化完成后被触发,这里简单地输出一条初始化完成的日志信息。

  1. 请求处理和自动重命名标签:
javascript
const handleRequest: <Params, Result>(
  fn: (params: Params) => Result
) => (params: Params) => Result = (fn) => (params) => {
  try {
    return fn(params);
  } catch (error) {
    handleError(error);
    throw error;
  }
};

connection.onRequest(
  autoRenameTagRequestType,
  handleRequest(autoRenameTag(documents))
);
const handleRequest: <Params, Result>(
  fn: (params: Params) => Result
) => (params: Params) => Result = (fn) => (params) => {
  try {
    return fn(params);
  } catch (error) {
    handleError(error);
    throw error;
  }
};

connection.onRequest(
  autoRenameTagRequestType,
  handleRequest(autoRenameTag(documents))
);

这段代码定义了一个handleRequest函数,用于处理请求并捕获可能的错误。然后,使用onRequest方法注册了处理自动重命名标签请求的处理程序,当收到该请求时,会调用autoRenameTag函数进行处理。

  1. 监听文档变化和启动连接:
javascript
documents.listen(connection);
connection.listen();
documents.listen(connection);
connection.listen();

通过调用documents.listen方法,将documents对象与连接关联起来,使其能够处理文档的打开、关闭、更改等事件。最后,调用connection.listen方法启动连接,使插件能够接收来自客户端的请求并发送响应。

createLanguageClientProxy 创建语言客户端代理

插件客户端请求服务器

ts
import * as vscode from 'vscode';
import {
  Code2ProtocolConverter,
  LanguageClient,
  LanguageClientOptions,
  RequestType,
  ServerOptions,
  TransportKind
} from 'vscode-languageclient/node';

type VslSendRequest = <P, R, E>(
  type: RequestType<P, R, E>,
  params: P
) => Thenable<R>;

export interface LanguageClientProxy {
  readonly code2ProtocolConverter: Code2ProtocolConverter;
  readonly sendRequest: VslSendRequest;
}

export const createLanguageClientProxy: (
  context: vscode.ExtensionContext,
  id: string,
  name: string,
  clientOptions: LanguageClientOptions
) => Promise<LanguageClientProxy> = async (
  context,
  id,
  name,
  clientOptions
) => {
  const serverModule = context.asAbsolutePath('../server/dist/serverMain.js');
  const serverOptions: ServerOptions = {
    run: { module: serverModule, transport: TransportKind.ipc },
    debug: {
      module: serverModule,
      transport: TransportKind.ipc,
      options: { execArgv: ['--nolazy', '--inspect=6009'] }
    }
  };
  const outputChannel = vscode.window.createOutputChannel(name);
  clientOptions.outputChannel = {
    name: outputChannel.name,
    append() {},
    appendLine(value: string) {
      try {
        const message = JSON.parse(value);
        if (!message.isLSPMessage) {
          outputChannel.appendLine(value);
        }
      } catch (error) {
        if (typeof value !== 'object') {
          outputChannel.appendLine(value);
        }
      }
    },
    replace(value) {
      outputChannel.replace(value);
    },
    clear() {
      outputChannel.clear();
    },
    show() {
      outputChannel.show();
    },
    hide() {
      outputChannel.hide();
    },
    dispose() {
      outputChannel.dispose();
    }
  };

  const languageClient = new LanguageClient(
    id,
    name,
    serverOptions,
    clientOptions
  );

  languageClient.registerProposedFeatures();
  context.subscriptions.push(languageClient.start());
  await languageClient.onReady();
  const languageClientProxy: LanguageClientProxy = {
    code2ProtocolConverter: languageClient.code2ProtocolConverter,
    sendRequest: (type, params) => languageClient.sendRequest(type, params)
  };
  return languageClientProxy;
};
import * as vscode from 'vscode';
import {
  Code2ProtocolConverter,
  LanguageClient,
  LanguageClientOptions,
  RequestType,
  ServerOptions,
  TransportKind
} from 'vscode-languageclient/node';

type VslSendRequest = <P, R, E>(
  type: RequestType<P, R, E>,
  params: P
) => Thenable<R>;

export interface LanguageClientProxy {
  readonly code2ProtocolConverter: Code2ProtocolConverter;
  readonly sendRequest: VslSendRequest;
}

export const createLanguageClientProxy: (
  context: vscode.ExtensionContext,
  id: string,
  name: string,
  clientOptions: LanguageClientOptions
) => Promise<LanguageClientProxy> = async (
  context,
  id,
  name,
  clientOptions
) => {
  const serverModule = context.asAbsolutePath('../server/dist/serverMain.js');
  const serverOptions: ServerOptions = {
    run: { module: serverModule, transport: TransportKind.ipc },
    debug: {
      module: serverModule,
      transport: TransportKind.ipc,
      options: { execArgv: ['--nolazy', '--inspect=6009'] }
    }
  };
  const outputChannel = vscode.window.createOutputChannel(name);
  clientOptions.outputChannel = {
    name: outputChannel.name,
    append() {},
    appendLine(value: string) {
      try {
        const message = JSON.parse(value);
        if (!message.isLSPMessage) {
          outputChannel.appendLine(value);
        }
      } catch (error) {
        if (typeof value !== 'object') {
          outputChannel.appendLine(value);
        }
      }
    },
    replace(value) {
      outputChannel.replace(value);
    },
    clear() {
      outputChannel.clear();
    },
    show() {
      outputChannel.show();
    },
    hide() {
      outputChannel.hide();
    },
    dispose() {
      outputChannel.dispose();
    }
  };

  const languageClient = new LanguageClient(
    id,
    name,
    serverOptions,
    clientOptions
  );

  languageClient.registerProposedFeatures();
  context.subscriptions.push(languageClient.start());
  await languageClient.onReady();
  const languageClientProxy: LanguageClientProxy = {
    code2ProtocolConverter: languageClient.code2ProtocolConverter,
    sendRequest: (type, params) => languageClient.sendRequest(type, params)
  };
  return languageClientProxy;
};

插件主函数

  1. 插件会把每次的修改内容发送给vscode服务器,服务器处理后,返回处理结果
  2. 获取返回结果后调用 vscode.window.activeTextEditor.edit方法来修改编辑器从而达到重命名标签的效果(这一部分代码看applyResults函数)
  3. doAutoCompletionElementRenameTag方法里调用applyResults

applyResults函数

ts
 const prev = vscode.window.activeTextEditor.document.version;
 const applied = await vscode.window.activeTextEditor.edit(
    editBuilder => {
      assertDefined(vscode.window.activeTextEditor);
      for (const result of results) {
        const startPosition =
          vscode.window.activeTextEditor.document.positionAt(
            result.startOffset
          );
        const endPosition = vscode.window.activeTextEditor.document.positionAt(
          result.endOffset
        );
        const range = new vscode.Range(startPosition, endPosition);
        editBuilder.replace(range, result.tagName);
      }
    },
    {
      undoStopBefore: false,
      undoStopAfter: false
    }
  );
  const next = vscode.window.activeTextEditor.document.version;
 const prev = vscode.window.activeTextEditor.document.version;
 const applied = await vscode.window.activeTextEditor.edit(
    editBuilder => {
      assertDefined(vscode.window.activeTextEditor);
      for (const result of results) {
        const startPosition =
          vscode.window.activeTextEditor.document.positionAt(
            result.startOffset
          );
        const endPosition = vscode.window.activeTextEditor.document.positionAt(
          result.endOffset
        );
        const range = new vscode.Range(startPosition, endPosition);
        editBuilder.replace(range, result.tagName);
      }
    },
    {
      undoStopBefore: false,
      undoStopAfter: false
    }
  );
  const next = vscode.window.activeTextEditor.document.version;

vscode.window.activeTextEditor.document.version文档版本号是一个整数值,用于标识文档的当前版本。因为调用vscode.window.activeTextEditor.edit,会进行文档的修改操作,当文档的内容发生更改时,版本号会递增,表示文档已经被修改。

这里的 undoStopBefore 和 undoStopAfter 是两个布尔类型的属性,用于控制编辑操作的撤销行为。undoStopBefore:表示在编辑操作执行前是否要插入撤销点。如果设置为 false,则编辑操作不会被记录为撤销的起始点,这意味着在执行撤销操作时,编辑操作将被跳过。undoStopAfter:表示在编辑操作执行后是否要插入撤销点。如果设置为 false,则编辑操作不会被记录为撤销的结束点,这意味着在执行恢复操作时,编辑操作将被跳过。 通过将这两个属性设置为 false,可以避免每次替换都生成大量的撤销记录。

脚本(scripts/package.js)

ts
const path = require('path');
const fs = require('fs-extra');

const root = path.join(__dirname, '..');

if (!fs.existsSync(path.join(root, 'dist'))) {
  fs.mkdirSync(path.join(root, 'dist'));
}

// @ts-ignore
const pkg = require('../packages/extension/package.json');

pkg.main = './packages/extension/dist/extensionMain.js';

delete pkg.dependencies;
delete pkg.devDependencies;
delete pkg.scripts;
delete pkg.enableProposedApi;

fs.writeFileSync(
  path.join(root, 'dist/package.json'),
  `${JSON.stringify(pkg, null, 2)}\n`
);

fs.copyFileSync(
  path.join(root, 'README.md'),
  path.join(root, 'dist/README.md')
);

fs.copyFileSync(
  path.join(root, 'CHANGELOG.md'),
  path.join(root, 'dist/CHANGELOG.md')
);

fs.copyFileSync(path.join(root, 'LICENSE'), path.join(root, 'dist/LICENSE'));

fs.ensureDirSync(path.join(root, 'dist/images'));
fs.copyFileSync(
  path.join(root, 'images/logo.png'),
  path.join(root, 'dist/images/logo.png')
);

let extensionMain = fs
  .readFileSync(
    path.join(root, `dist/packages/extension/dist/extensionMain.js`)
  )
  .toString();

extensionMain = extensionMain.replace(
  '../server/dist/serverMain.js',
  './packages/server/dist/serverMain.js'
);

fs.writeFileSync(
  path.join(root, `dist/packages/extension/dist/extensionMain.js`),
  extensionMain
);
const path = require('path');
const fs = require('fs-extra');

const root = path.join(__dirname, '..');

if (!fs.existsSync(path.join(root, 'dist'))) {
  fs.mkdirSync(path.join(root, 'dist'));
}

// @ts-ignore
const pkg = require('../packages/extension/package.json');

pkg.main = './packages/extension/dist/extensionMain.js';

delete pkg.dependencies;
delete pkg.devDependencies;
delete pkg.scripts;
delete pkg.enableProposedApi;

fs.writeFileSync(
  path.join(root, 'dist/package.json'),
  `${JSON.stringify(pkg, null, 2)}\n`
);

fs.copyFileSync(
  path.join(root, 'README.md'),
  path.join(root, 'dist/README.md')
);

fs.copyFileSync(
  path.join(root, 'CHANGELOG.md'),
  path.join(root, 'dist/CHANGELOG.md')
);

fs.copyFileSync(path.join(root, 'LICENSE'), path.join(root, 'dist/LICENSE'));

fs.ensureDirSync(path.join(root, 'dist/images'));
fs.copyFileSync(
  path.join(root, 'images/logo.png'),
  path.join(root, 'dist/images/logo.png')
);

let extensionMain = fs
  .readFileSync(
    path.join(root, `dist/packages/extension/dist/extensionMain.js`)
  )
  .toString();

extensionMain = extensionMain.replace(
  '../server/dist/serverMain.js',
  './packages/server/dist/serverMain.js'
);

fs.writeFileSync(
  path.join(root, `dist/packages/extension/dist/extensionMain.js`),
  extensionMain
);

这段代码是一个Node.js脚本,它执行了一系列文件操作和路径处理操作。一步一步解释每个部分的功能:

  1. 引入pathfs-extra模块:
javascript
const path = require('path');
const fs = require('fs-extra');
const path = require('path');
const fs = require('fs-extra');

这里使用了Node.js内置的path模块来处理文件路径,以及第三方库fs-extra来进行文件操作,它是fs模块的增强版。

  1. 定义根路径:
javascript
const root = path.join(__dirname, '..');
const root = path.join(__dirname, '..');

这行代码将当前脚本文件的路径(__dirname)与其父目录(..)拼接起来,得到了根路径。

  1. 创建dist目录(如果不存在):
javascript
if (!fs.existsSync(path.join(root, 'dist'))) {
  fs.mkdirSync(path.join(root, 'dist'));
}
if (!fs.existsSync(path.join(root, 'dist'))) {
  fs.mkdirSync(path.join(root, 'dist'));
}

这段代码首先使用path.join方法将根路径与子路径dist拼接起来,然后通过fs.existsSync检查该路径是否存在。如果路径不存在,则使用fs.mkdirSync创建该目录。

  1. 加载package.json文件并进行修改:
javascript
const pkg = require('../packages/extension/package.json');

pkg.main = './packages/extension/dist/extensionMain.js';

delete pkg.dependencies;
delete pkg.devDependencies;
delete pkg.scripts;
delete pkg.enableProposedApi;
const pkg = require('../packages/extension/package.json');

pkg.main = './packages/extension/dist/extensionMain.js';

delete pkg.dependencies;
delete pkg.devDependencies;
delete pkg.scripts;
delete pkg.enableProposedApi;

这部分代码通过require方法加载了../packages/extension/package.json文件,并将其内容赋值给变量pkg。然后,它修改了pkg对象的一些属性,包括main属性的值以及删除了dependenciesdevDependenciesscriptsenableProposedApi属性。

  1. 将修改后的package.json文件写入到dist目录中:
javascript
fs.writeFileSync(
  path.join(root, 'dist/package.json'),
  `${JSON.stringify(pkg, null, 2)}\n`
);
fs.writeFileSync(
  path.join(root, 'dist/package.json'),
  `${JSON.stringify(pkg, null, 2)}\n`
);

这行代码使用fs.writeFileSync方法将修改后的pkg对象以格式化的JSON字符串形式写入到dist/package.json文件中。

  1. 复制其他文件到dist目录:
javascript
fs.copyFileSync(
  path.join(root, 'README.md'),
  path.join(root, 'dist/README.md')
);

fs.copyFileSync(
  path.join(root, 'CHANGELOG.md'),
  path.join(root, 'dist/CHANGELOG.md')
);

fs.copyFileSync(path.join(root, 'LICENSE'), path.join(root, 'dist/LICENSE'));

fs.ensureDirSync(path.join(root, 'dist/images'));
fs.copyFileSync(
  path.join(root, 'images/logo.png'),
  path.join(root, 'dist/images/logo.png')
);
fs.copyFileSync(
  path.join(root, 'README.md'),
  path.join(root, 'dist/README.md')
);

fs.copyFileSync(
  path.join(root, 'CHANGELOG.md'),
  path.join(root, 'dist/CHANGELOG.md')
);

fs.copyFileSync(path.join(root, 'LICENSE'), path.join(root, 'dist/LICENSE'));

fs.ensureDirSync(path.join(root, 'dist/images'));
fs.copyFileSync(
  path.join(root, 'images/logo.png'),
  path.join(root, 'dist/images/logo.png')
);

这部分代码使用fs.copyFileSync方法将README.mdCHANGELOG.mdLICENSE文件以及images/logo.png文件复制到dist目录中。如果dist/images目录不存在,将使用fs.ensureDirSync方法创建该目录。

  1. 修改扩展的主文件路径:
javascript
let extensionMain = fs
  .readFileSync(
    path.join(root, `dist/packages/extension/dist/extensionMain.js`)
  )
  .toString();

extensionMain = extensionMain.replace(
  '../server/dist/serverMain.js',
  './packages/server/dist/serverMain.js'
);

fs.writeFileSync(
  path.join(root, `dist/packages/extension/dist/extensionMain.js`),
  extensionMain
);
let extensionMain = fs
  .readFileSync(
    path.join(root, `dist/packages/extension/dist/extensionMain.js`)
  )
  .toString();

extensionMain = extensionMain.replace(
  '../server/dist/serverMain.js',
  './packages/server/dist/serverMain.js'
);

fs.writeFileSync(
  path.join(root, `dist/packages/extension/dist/extensionMain.js`),
  extensionMain
);

这段代码读取dist/packages/extension/dist/extensionMain.js文件的内容,并将其赋值给变量extensionMain。然后,它使用replace方法将文件内容中的../server/dist/serverMain.js替换为./packages/server/dist/serverMain.js。最后,将修改后的内容写回到dist/packages/extension/dist/extensionMain.js文件中。

这段代码的目的是构建和准备项目的发布版本,将需要的文件和目录复制到dist目录,并对package.json和扩展的主文件进行一些修改和调整。

server项目依赖service项目

server项目中调用service项目的方法,可以看见只写了一个service,而不是相对路径指向service项目主文件

js
import { doAutoRenameTag } from 'service';
import { doAutoRenameTag } from 'service';

再看下service项目的package.json,可以看到service就是service项目的包名

json
{
  "name": "service",
  ...
}
{
  "name": "service",
  ...
}

这是通过references实现的

这段代码片段是一个server项目中的tsconfig.json文件的一部分,其中包含了一个references数组。

在TypeScript中,references用于指定项目之间的依赖关系。在这个例子中,references数组包含了一个对象,该对象具有一个path属性,指定了另一个项目的tsconfig.json文件的路径。

json
"references": [
  {
    "path": "../service/tsconfig.json"
  }
]
"references": [
  {
    "path": "../service/tsconfig.json"
  }
]

这段代码的意思是,当前的server项目依赖于位于相对路径../service/tsconfig.json的service项目。通过将这个tsconfig.json文件添加到当前项目的references数组中,TypeScript编译器将会考虑到这个依赖关系,并在构建或编译项目时处理和包含被引用的项目。

这种依赖关系可以确保在构建或编译项目时,被引用的项目先进行处理,以便在当前项目中正确地使用其定义、类型和其他资源。这在多项目的代码库中很常见,其中一个项目依赖于另一个项目的代码或类型定义。

service项目

service项目中其实只做了一件事,实现了一个doAutoRenameTag函数

ts
export { doAutoRenameTag } from './doAutoRenameTag';
export { doAutoRenameTag } from './doAutoRenameTag';

doAutoRenameTag函数源码解析

ts
import { getMatchingTagPairs } from './getMatchingTagPairs';
import {
  createScannerFast,
  ScannerStateFast
} from './htmlScanner/htmlScannerFast';
import { isSelfClosingTagInLanguage } from './isSelfClosingTag';
import { getNextClosingTagName } from './util/getNextClosingTagName';
import { getPreviousOpeningTagName } from './util/getPreviousOpenTagName';

export const doAutoRenameTag: (
  text: string,
  offset: number,
  newWord: string,
  oldWord: string,
  languageId: string
) =>
  | {
      startOffset: number;
      endOffset: number;
      tagName: string;
    }
  | undefined = (text, offset, newWord, oldWord, languageId) => {
  const matchingTagPairs = getMatchingTagPairs(languageId);
  const isSelfClosingTag = isSelfClosingTagInLanguage(languageId);
  const isReact =
    languageId === 'javascript' ||
    languageId === 'typescript' ||
    languageId === 'javascriptreact' ||
    languageId === 'typescriptreact';
  const scanner = createScannerFast({
    input: text,
    initialOffset: 0,
    initialState: ScannerStateFast.WithinContent,
    matchingTagPairs
  });
  if (newWord.startsWith('</')) {
    scanner.stream.goTo(offset);
    const tagName = newWord.slice(2);
    const oldTagName = oldWord.slice(2);
    if (oldTagName.startsWith('script') || oldTagName.startsWith('style')) {
      const tag = `<${oldTagName}`;
      let i = scanner.stream.position;
      let found = false;
      while (i--) {
        if (text.slice(i).startsWith(tag)) {
          found = true;
          break;
        }
      }
      if (!found) {
        return undefined;
      }
      return {
        startOffset: i + 1,
        endOffset: i + 1 + oldTagName.length,
        tagName
      };
    }
    const parent = getPreviousOpeningTagName(
      scanner,
      scanner.stream.position,
      isSelfClosingTag,
      isReact
    );
    if (!parent) {
      return undefined;
    }
    if (parent.tagName === tagName) {
      return undefined;
    }
    if (parent.tagName !== oldTagName) {
      return undefined;
    }
    if (!parent.seenRightAngleBracket) {
      return undefined;
    }
    const startOffset = parent.offset;
    const endOffset = parent.offset + parent.tagName.length;
    return {
      startOffset,
      endOffset,
      tagName
    };
  } else {
    scanner.stream.goTo(offset + 1);
    const tagName = newWord.slice(1);
    const oldTagName = oldWord.slice(1);
    if (oldTagName.startsWith('script') || oldTagName.startsWith('style')) {
      const hasAdvanced = scanner.stream.advanceUntilEitherChar(
        ['>'],
        true,
        isReact
      );
      if (!hasAdvanced) {
        return undefined;
      }
      const match = text
        .slice(scanner.stream.position)
        .match(new RegExp(`</${oldTagName}`));
      if (!match) {
        return undefined;
      }
      const index = match.index as number;
      return {
        startOffset: scanner.stream.position + index + 2,
        endOffset: scanner.stream.position + index + 2 + oldTagName.length,
        tagName
      };
    }
    const hasAdvanced = scanner.stream.advanceUntilEitherChar(
      ['<', '>'],
      true,
      isReact
    );
    // if start tag is not closed, return undefined
    if (scanner.stream.peekRight(0) === '<') {
      return undefined;
    }
    if (!hasAdvanced) {
      return undefined;
    }
    if (scanner.stream.peekLeft(1) === '/') {
      return undefined;
    }
    const possibleEndOfStartTag = scanner.stream.position;
    // check if we might be at an end tag
    while (scanner.stream.peekLeft(1).match(/[a-zA-Z\-\:]/)) {
      scanner.stream.goBack(1);
      if (scanner.stream.peekLeft(1) === '/') {
        return undefined;
      }
    }
    scanner.stream.goTo(possibleEndOfStartTag);
    scanner.stream.advance(1);
    const nextClosingTag = getNextClosingTagName(
      scanner,
      scanner.stream.position,
      isSelfClosingTag,
      isReact
    );
    if (!nextClosingTag) {
      return undefined;
    }
    if (nextClosingTag.tagName === tagName) {
      return undefined;
    }
    if (nextClosingTag.tagName !== oldTagName) {
      return undefined;
    }
    const previousOpenTag = getPreviousOpeningTagName(
      scanner,
      offset,
      isSelfClosingTag,
      isReact
    );

    if (
      previousOpenTag &&
      previousOpenTag.tagName === oldTagName &&
      previousOpenTag.indent === nextClosingTag.indent
    ) {
      return undefined;
    }

    const startOffset = nextClosingTag.offset;
    const endOffset = nextClosingTag.offset + nextClosingTag.tagName.length;

    return {
      startOffset,
      endOffset,
      tagName
    };
  }
};

// const testCase = {
//   text: '<div>\n  <di>\n  <div></div>\n</div>',
//   offset: 8,
//   newWord: '<di',
//   oldWord: '<div'
// };
// doAutoRenameTag(
//   testCase.text,
//   testCase.offset,
//   testCase.newWord,
//   testCase.oldWord,
//   'html'
// ); //?

// doAutoRenameTag(
//   `<div>
//   <div>
//   <div></div>
// </div>`,
//   9,
//   '<span',
//   '<div',
//   'html'
// ); //?
import { getMatchingTagPairs } from './getMatchingTagPairs';
import {
  createScannerFast,
  ScannerStateFast
} from './htmlScanner/htmlScannerFast';
import { isSelfClosingTagInLanguage } from './isSelfClosingTag';
import { getNextClosingTagName } from './util/getNextClosingTagName';
import { getPreviousOpeningTagName } from './util/getPreviousOpenTagName';

export const doAutoRenameTag: (
  text: string,
  offset: number,
  newWord: string,
  oldWord: string,
  languageId: string
) =>
  | {
      startOffset: number;
      endOffset: number;
      tagName: string;
    }
  | undefined = (text, offset, newWord, oldWord, languageId) => {
  const matchingTagPairs = getMatchingTagPairs(languageId);
  const isSelfClosingTag = isSelfClosingTagInLanguage(languageId);
  const isReact =
    languageId === 'javascript' ||
    languageId === 'typescript' ||
    languageId === 'javascriptreact' ||
    languageId === 'typescriptreact';
  const scanner = createScannerFast({
    input: text,
    initialOffset: 0,
    initialState: ScannerStateFast.WithinContent,
    matchingTagPairs
  });
  if (newWord.startsWith('</')) {
    scanner.stream.goTo(offset);
    const tagName = newWord.slice(2);
    const oldTagName = oldWord.slice(2);
    if (oldTagName.startsWith('script') || oldTagName.startsWith('style')) {
      const tag = `<${oldTagName}`;
      let i = scanner.stream.position;
      let found = false;
      while (i--) {
        if (text.slice(i).startsWith(tag)) {
          found = true;
          break;
        }
      }
      if (!found) {
        return undefined;
      }
      return {
        startOffset: i + 1,
        endOffset: i + 1 + oldTagName.length,
        tagName
      };
    }
    const parent = getPreviousOpeningTagName(
      scanner,
      scanner.stream.position,
      isSelfClosingTag,
      isReact
    );
    if (!parent) {
      return undefined;
    }
    if (parent.tagName === tagName) {
      return undefined;
    }
    if (parent.tagName !== oldTagName) {
      return undefined;
    }
    if (!parent.seenRightAngleBracket) {
      return undefined;
    }
    const startOffset = parent.offset;
    const endOffset = parent.offset + parent.tagName.length;
    return {
      startOffset,
      endOffset,
      tagName
    };
  } else {
    scanner.stream.goTo(offset + 1);
    const tagName = newWord.slice(1);
    const oldTagName = oldWord.slice(1);
    if (oldTagName.startsWith('script') || oldTagName.startsWith('style')) {
      const hasAdvanced = scanner.stream.advanceUntilEitherChar(
        ['>'],
        true,
        isReact
      );
      if (!hasAdvanced) {
        return undefined;
      }
      const match = text
        .slice(scanner.stream.position)
        .match(new RegExp(`</${oldTagName}`));
      if (!match) {
        return undefined;
      }
      const index = match.index as number;
      return {
        startOffset: scanner.stream.position + index + 2,
        endOffset: scanner.stream.position + index + 2 + oldTagName.length,
        tagName
      };
    }
    const hasAdvanced = scanner.stream.advanceUntilEitherChar(
      ['<', '>'],
      true,
      isReact
    );
    // if start tag is not closed, return undefined
    if (scanner.stream.peekRight(0) === '<') {
      return undefined;
    }
    if (!hasAdvanced) {
      return undefined;
    }
    if (scanner.stream.peekLeft(1) === '/') {
      return undefined;
    }
    const possibleEndOfStartTag = scanner.stream.position;
    // check if we might be at an end tag
    while (scanner.stream.peekLeft(1).match(/[a-zA-Z\-\:]/)) {
      scanner.stream.goBack(1);
      if (scanner.stream.peekLeft(1) === '/') {
        return undefined;
      }
    }
    scanner.stream.goTo(possibleEndOfStartTag);
    scanner.stream.advance(1);
    const nextClosingTag = getNextClosingTagName(
      scanner,
      scanner.stream.position,
      isSelfClosingTag,
      isReact
    );
    if (!nextClosingTag) {
      return undefined;
    }
    if (nextClosingTag.tagName === tagName) {
      return undefined;
    }
    if (nextClosingTag.tagName !== oldTagName) {
      return undefined;
    }
    const previousOpenTag = getPreviousOpeningTagName(
      scanner,
      offset,
      isSelfClosingTag,
      isReact
    );

    if (
      previousOpenTag &&
      previousOpenTag.tagName === oldTagName &&
      previousOpenTag.indent === nextClosingTag.indent
    ) {
      return undefined;
    }

    const startOffset = nextClosingTag.offset;
    const endOffset = nextClosingTag.offset + nextClosingTag.tagName.length;

    return {
      startOffset,
      endOffset,
      tagName
    };
  }
};

// const testCase = {
//   text: '<div>\n  <di>\n  <div></div>\n</div>',
//   offset: 8,
//   newWord: '<di',
//   oldWord: '<div'
// };
// doAutoRenameTag(
//   testCase.text,
//   testCase.offset,
//   testCase.newWord,
//   testCase.oldWord,
//   'html'
// ); //?

// doAutoRenameTag(
//   `<div>
//   <div>
//   <div></div>
// </div>`,
//   9,
//   '<span',
//   '<div',
//   'html'
// ); //?

doAutoRenameTag函数内部一上来先调用了这两个函数

ts
  const matchingTagPairs = getMatchingTagPairs(languageId);
  const isSelfClosingTag = isSelfClosingTagInLanguage(languageId);
  const matchingTagPairs = getMatchingTagPairs(languageId);
  const isSelfClosingTag = isSelfClosingTagInLanguage(languageId);

直接看下这两个函数完整代码

ts
const matchingTagPairs: { [languageId: string]: [string, string][] } = {
  css: [
    ['/*', '*/'],
    ['"', '"'],
    ["'", "'"]
  ],
  ejs: [['<%', '%>']],
  ruby: [
    ['<%=', '%>'],
    ['"', '"'],
    ["'", "'"]
  ],
  html: [
    ['<!--', '-->'],
    ['"', '"'],
    ["'", "'"],
    ['<style', '</style>'],
    ['<script', '</script'],
    ['<%=', '%>'] // support for html-webpack-plugin
  ],
  markdown: [
    ['<!--', '-->'],
    ['"', '"'],
    ["'", "'"],
    ['```', '```'],
    ['<?', '?>']
  ],
  marko: [
    ['<!--', '-->'],
    ['${', '}'],
    ['<html-comment>', '</html-comment>']
  ],
  nunjucks: [
    ['{%', '%}'],
    ['{{', '}}'],
    ['{#', '#}']
  ],
  plaintext: [
    ['<!--', '-->'],
    ['"', '"'],
    ["'", "'"]
  ],
  php: [
    ['<!--', '-->'],
    ['<?', '?>'],
    ['"', '"'],
    ["'", "'"]
  ],
  javascript: [
    ['<!--', '-->'],
    ['{/*', '*/}'],
    ["'", "'"],
    ['"', '"'],
    ['`', '`']
  ],
  javascriptreact: [
    ['{/*', '*/}'],
    ["'", "'"],
    ['"', '"'],
    ['`', '`']
  ],
  mustache: [['{{', '}}']],
  razor: [
    ['<!--', '-->'],
    ['@{', '}'],
    ['"', '"'],
    ["'", "'"]
  ],
  svelte: [
    ['<!--', '-->'],
    ['"', '"'],
    ["'", "'"]
  ],
  svg: [
    ['<!--', '-->'],
    ['"', '"'],
    ["'", "'"]
  ],
  typescript: [
    ['<!--', '-->'],
    ['{/*', '*/}'],
    ["'", "'"],
    ['"', '"'],
    ['`', '`']
  ],
  typescriptreact: [
    ['{/*', '*/}'],
    ["'", "'"],
    ['"', '"'],
    ['`', '`']
  ],
  twig: [
    ['<!--', '-->'],
    ['"', '"'],
    ["'", "'"],
    ['{{', '}}'],
    ['{%', '%}']
  ],
  volt: [
    ['{#', '#}'],
    ['{%', '%}'],
    ['{{', '}}']
  ],
  vue: [
    ['<!--', '-->'],
    ['"', '"'],
    ["'", "'"],
    ['{{', '}}']
  ],
  xml: [
    ['<!--', '-->'],
    ['"', '"'],
    ["'", "'"],
    ['<?', '?>']
  ]
};

export const getMatchingTagPairs: (
  languageId: string
) => [string, string][] = languageId =>
  matchingTagPairs[languageId] || matchingTagPairs['html'];
const matchingTagPairs: { [languageId: string]: [string, string][] } = {
  css: [
    ['/*', '*/'],
    ['"', '"'],
    ["'", "'"]
  ],
  ejs: [['<%', '%>']],
  ruby: [
    ['<%=', '%>'],
    ['"', '"'],
    ["'", "'"]
  ],
  html: [
    ['<!--', '-->'],
    ['"', '"'],
    ["'", "'"],
    ['<style', '</style>'],
    ['<script', '</script'],
    ['<%=', '%>'] // support for html-webpack-plugin
  ],
  markdown: [
    ['<!--', '-->'],
    ['"', '"'],
    ["'", "'"],
    ['```', '```'],
    ['<?', '?>']
  ],
  marko: [
    ['<!--', '-->'],
    ['${', '}'],
    ['<html-comment>', '</html-comment>']
  ],
  nunjucks: [
    ['{%', '%}'],
    ['{{', '}}'],
    ['{#', '#}']
  ],
  plaintext: [
    ['<!--', '-->'],
    ['"', '"'],
    ["'", "'"]
  ],
  php: [
    ['<!--', '-->'],
    ['<?', '?>'],
    ['"', '"'],
    ["'", "'"]
  ],
  javascript: [
    ['<!--', '-->'],
    ['{/*', '*/}'],
    ["'", "'"],
    ['"', '"'],
    ['`', '`']
  ],
  javascriptreact: [
    ['{/*', '*/}'],
    ["'", "'"],
    ['"', '"'],
    ['`', '`']
  ],
  mustache: [['{{', '}}']],
  razor: [
    ['<!--', '-->'],
    ['@{', '}'],
    ['"', '"'],
    ["'", "'"]
  ],
  svelte: [
    ['<!--', '-->'],
    ['"', '"'],
    ["'", "'"]
  ],
  svg: [
    ['<!--', '-->'],
    ['"', '"'],
    ["'", "'"]
  ],
  typescript: [
    ['<!--', '-->'],
    ['{/*', '*/}'],
    ["'", "'"],
    ['"', '"'],
    ['`', '`']
  ],
  typescriptreact: [
    ['{/*', '*/}'],
    ["'", "'"],
    ['"', '"'],
    ['`', '`']
  ],
  twig: [
    ['<!--', '-->'],
    ['"', '"'],
    ["'", "'"],
    ['{{', '}}'],
    ['{%', '%}']
  ],
  volt: [
    ['{#', '#}'],
    ['{%', '%}'],
    ['{{', '}}']
  ],
  vue: [
    ['<!--', '-->'],
    ['"', '"'],
    ["'", "'"],
    ['{{', '}}']
  ],
  xml: [
    ['<!--', '-->'],
    ['"', '"'],
    ["'", "'"],
    ['<?', '?>']
  ]
};

export const getMatchingTagPairs: (
  languageId: string
) => [string, string][] = languageId =>
  matchingTagPairs[languageId] || matchingTagPairs['html'];

该函数用于获取相应编程语言中匹配的标签对数组,找不到相应编程语言,就默认取html的

ts
const tagsThatAreSelfClosingInHtml: Set<string> = new Set([
  'area',
  'base',
  'br',
  'col',
  'command',
  'embed',
  'hr',
  'img',
  'input',
  'keygen',
  'link',
  'menuitem',
  'meta',
  'param',
  'source',
  'track',
  'wbr'
]);

const EMPTY_SET: Set<string> = new Set();

const tagsThatAreSelfClosing: { [languageId: string]: Set<string> } = {
  css: tagsThatAreSelfClosingInHtml,
  ejs: tagsThatAreSelfClosingInHtml,
  ruby: tagsThatAreSelfClosingInHtml,
  html: tagsThatAreSelfClosingInHtml,
  markdown: tagsThatAreSelfClosingInHtml,
  marko: tagsThatAreSelfClosingInHtml,
  nunjucks: tagsThatAreSelfClosingInHtml,
  plaintext: tagsThatAreSelfClosingInHtml,
  php: tagsThatAreSelfClosingInHtml,
  javascript: tagsThatAreSelfClosingInHtml,
  javascriptreact: EMPTY_SET,
  mustache: tagsThatAreSelfClosingInHtml,
  razor: tagsThatAreSelfClosingInHtml,
  svelte: tagsThatAreSelfClosingInHtml,
  svg: EMPTY_SET,
  typescript: tagsThatAreSelfClosingInHtml,
  typescriptreact: EMPTY_SET,
  twig: tagsThatAreSelfClosingInHtml,
  volt: tagsThatAreSelfClosingInHtml,
  vue: EMPTY_SET,
  xml: EMPTY_SET
};

export const isSelfClosingTagInLanguage: (
  languageId: string
) => (tagName: string) => boolean = languageId => tagName =>
  (tagsThatAreSelfClosing[languageId] || tagsThatAreSelfClosing['html']).has(
    tagName
  );
const tagsThatAreSelfClosingInHtml: Set<string> = new Set([
  'area',
  'base',
  'br',
  'col',
  'command',
  'embed',
  'hr',
  'img',
  'input',
  'keygen',
  'link',
  'menuitem',
  'meta',
  'param',
  'source',
  'track',
  'wbr'
]);

const EMPTY_SET: Set<string> = new Set();

const tagsThatAreSelfClosing: { [languageId: string]: Set<string> } = {
  css: tagsThatAreSelfClosingInHtml,
  ejs: tagsThatAreSelfClosingInHtml,
  ruby: tagsThatAreSelfClosingInHtml,
  html: tagsThatAreSelfClosingInHtml,
  markdown: tagsThatAreSelfClosingInHtml,
  marko: tagsThatAreSelfClosingInHtml,
  nunjucks: tagsThatAreSelfClosingInHtml,
  plaintext: tagsThatAreSelfClosingInHtml,
  php: tagsThatAreSelfClosingInHtml,
  javascript: tagsThatAreSelfClosingInHtml,
  javascriptreact: EMPTY_SET,
  mustache: tagsThatAreSelfClosingInHtml,
  razor: tagsThatAreSelfClosingInHtml,
  svelte: tagsThatAreSelfClosingInHtml,
  svg: EMPTY_SET,
  typescript: tagsThatAreSelfClosingInHtml,
  typescriptreact: EMPTY_SET,
  twig: tagsThatAreSelfClosingInHtml,
  volt: tagsThatAreSelfClosingInHtml,
  vue: EMPTY_SET,
  xml: EMPTY_SET
};

export const isSelfClosingTagInLanguage: (
  languageId: string
) => (tagName: string) => boolean = languageId => tagName =>
  (tagsThatAreSelfClosing[languageId] || tagsThatAreSelfClosing['html']).has(
    tagName
  );

isSelfClosingTagInLanguage函数用于根据语言判断是否是自闭合标签(包含了所有HTML中的自闭合标签)

对于vue,将其对应的自闭合标签集合设置为EMPTY_SET,即一个空的Set,没有包含任何标签。这是因为在Vue.js中,标签不像HTML那样有明确的自闭合和非自闭合的区分。Vue会根据数据和指令动态地生成相应的DOM结构。因此,对于Vue来说,没有固定的一组自闭合标签。代码中将EMPTY_SET用于javascriptreacttypescriptreact编程语言的自闭合标签集合,这也是因为在React和TypeScript中,同样也没有固定的一组自闭合标签,而是通过组件和JSX的语法来生成DOM元素。

而在XML和SVG中,标签的自闭合性与HTML不同。在XML和SVG中,标签是否自闭合取决于其是否具有子元素。对于没有子元素的标签,可以使用自闭合形式,而对于有子元素的标签,则需要使用起始标签和结束标签来包裹子元素。

ts
  const isReact =
    languageId === 'javascript' ||
    languageId === 'typescript' ||
    languageId === 'javascriptreact' ||
    languageId === 'typescriptreact';
  const isReact =
    languageId === 'javascript' ||
    languageId === 'typescript' ||
    languageId === 'javascriptreact' ||
    languageId === 'typescriptreact';

这四种语言情况都有可能存在react语法(jsx和tsx)

创建了一个html扫描器

Released under the MIT License.