补给资料 管注公众号:码农补给站
目前我司的多个产品中都支持在线编辑 SQL 来生成对应的任务。为了优化用户体验,在使用 MonacoEditor 为编辑器的基础上,我们还支持了如下几个重要功能:
本文旨在讲解上述功能的实现思路,对于技术细节,由于篇幅原因不会阐述的太详细。
Monaco Editor 内置了相当多的 languages,比如 javaScript
、CSS
、Shell
等。
Monaco Editor 依赖包的 ESM 入口文件为 ./esm/vs/editor/editor.main.ts
而在入口文件中,Monaco Editor 引入了所有内置的 Languages。
这里 languages 文件可以分为两类,一类是../language
文件夹下的,支持自动补全和飘红提示等高级功能;另一类则是../basic-languages
文件夹下的,只支持一些基本功能。
以使用 typescript
language 为例:
import { editor } from 'monaco-editor';
const container = document.getElementById('container');
editor.create(container, {
language: 'typescript'
})
此时我们会发现,编辑器已经有语法高亮的功能了,但是浏览器控制台会抛异常,另外也没有自动补全功能和飘红提示功能,
这其实是因为,Monaco Editor 无法加载到 language 对应的 worker,对应的解决办法看这里: Monaco integrate-esm。
这里我们使用 Using plain webpack
的方式,首先将对应的 worker 文件设置为 webpack entry:
module.exports = {
entry: {
index: path.resolve( __dirname, './src/index.ts'),
'editor.worker': 'monaco-editor/esm/vs/editor/editor.worker.js',
'ts.worker': 'monaco-editor/esm/vs/language/typescript/ts.worker.js'
},
}
另外还需要设置 Monaco Editor 的全局环境变量,这主要是为了告诉 Monaco Editor 对应的 worker 文件的路径
import { editor } from 'monaco-editor';
(window as any).MonacoEnvironment = {
getWorkerUrl: function (_moduleId, label) {
switch (label) {
case 'flink': {
return './flink.worker.js';
}
case 'typescript': {
return './ts.worker.js'
}
default: {
return './editor.worker.js';
}
}
}
};
const container = document.getElementById('container');
editor.create(container, {
language: 'typescript'
})
这样一个具有语法高亮
、自动补全
、飘红提示
功能的 typescript 编辑器就设置好了
首先上文中提到了当我们直接从 Monaco Editor 的入口文件中导入时,会自动的引入所有内置的 Languages,但是实际上这其中绝大都是我们不需要的,而由于其导入方式,很显然我们不需要的 languages 也无法被 treeShaking。要解决这个问题我们可以选择从 monaco-editor/esm/vs/editor/editor.api
文件中导入Monaco Editor 核心 API,然后通过 monaco-editor-webpack-plugin 来按需导入所需要的功能。另外这个插件也可以自动处理Monaco Editor 内置的 worker 文件的打包问题,以及自动注入 MonacoEnvironment
全局环境变量。
Monaco Editor 提供了 monaco.languages.register
方法,用来自定义 language
/**
* Register information about a new language.
*/
export function register(language: ILanguageExtensionPoint): void;
export interface ILanguageExtensionPoint {
id: string;
extensions?: string[];
filenames?: string[];
filenamePatterns?: string[];
firstLine?: string;
aliases?: string[];
mimetypes?: string[];
configuration?: Uri;
}
第一步,我们需要注册一个 language, 配置项中 id 对应的就是语言名称(其他配置项可以暂时不填),这里自定义的 language 名为 myLang
import { editor, languages } from 'monaco-editor';
languages.register({
id: "myLang"
});
const container = document.getElementById('container');
editor.create(container, {
language: 'myLang'
})
此时可以发现,页面上的编辑器没有任何其他附加功能,就是普通的文本编辑器。
通过 monaco.languages.setLanguageConfiguration
,可以对 language 进行配置
/**
* Set the editing configuration for a language.
*/
export function setLanguageConfiguration(
languageId: string,
configuration: LanguageConfiguration
): IDisposable;
/**
* The language configuration interface defines the contract between extensions and
* various editor features, like automatic bracket insertion, automatic indentation etc.
*/
export interface LanguageConfiguration {
comments?: CommentRule;
brackets?: CharacterPair[];
wordPattern?: RegExp;
indentationRules?: IndentationRule;
onEnterRules?: OnEnterRule[];
autoClosingPairs?: IAutoClosingPairConditional[];
surroundingPairs?: IAutoClosingPair[];
colorizedBracketPairs?: CharacterPair[];
autoCloseBefore?: string;
folding?: FoldingRules;
}
这些配置会影响 Monaco Editor 的一些默认行为,比如设置 autoClosingPairs
中有一项为一对圆括号,那么当输入左圆括号后,会自动补全右圆括号。
import { languages } from "monaco-editor";
const conf: languages.LanguageConfiguration = {
comments: {
lineComment: "--",
blockComment: ["/*", "*/"],
},
brackets: [
["(", ")"],
],
autoClosingPairs: [
{ open: "(", close: ")" },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
],
surroundingPairs: [
{ open: "(", close: ")" },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
],
};
languages.setLanguageConfiguration('myLang', conf)
Moanco Editor 内置了 Monarch,用于实现语法高亮功能,它本质上是一个有限状态机,我们可以通过JSON的形式来配置其状态流转逻辑,并通过monaco.languages.setMonarchTokensProvider
API 应用该配置。关于Monarch 的具体用法可以看一下这篇文章 以及 Monarch Document。
配置中最重要的是 tokenizer
属性,意思是分词器,分词器会自动对编辑器内部的文本进行分词处理,每个分词器都有一个 root state,在 root state 中可以有多条规则,规则内部可以引用其他 state。
下面是一个简单的配置示例
import { languages } from "monaco-editor";
export const language: languages.IMonarchLanguage = {
ignoreCase: true,
tokenizer: {
root: [
{ include: '@comments' }, // 引用下面的 comments 规则
{ include: '@whitespace' }, // 引用下面的 whiteSpace 规则
{ include: '@strings' },// 引用下面的 strings 规则
],
whitespace: [[/\s+/, 'white']],
comments: [
[/--+.*/, 'comment'],
[//*/, { token: 'comment.quote', next: '@comment' }]
],
comment: [
[/[^*/]+/, 'comment'],
[/*//, { token: 'comment.quote', next: '@pop' }],
[/./, 'comment']
],
strings: [
[/'/, { token: 'string', next: '@string' }]
],
string: [
[/[^']+/, 'string'],
[/''/, 'string'],
[/'/, { token: 'string', next: '@pop' }]
],
}
};
languages.setMonarchTokensProvider("myLang", language);
上面的配置中 root 下面有三条规则分别匹配 注释(comments)
、字符串(strings)
以及空白字符(whiteSpace)
, 每条规则可以大体分为两部分:
比如上述配置中 tokenizer.comments
规则
comments: [
[/--+.*/, 'comment'], // 左边是正则表达式用来匹配文本,右边是该规则对应的 token 名称
[//*/, { token: 'comment.quote', next: '@comment' }] // 左边是正则表达式用来匹配文本,右边显示声明对应的 token 名称
],
配置了如上 Monarch 之后,在编辑器内部输入注释或者字符串,那么Monaco editor 就会根据输入的内容进行分词处理
可以看到目前字符串和注释已经被高亮了。这里有一个新的问题,不同类型的分词的颜色是怎么设置的?
从上图中右侧的 Elements 面板中可以看到,不同类型的分词,对应的标签的 className 不同,它们是由 Monarch 配置中的 token 映射而来的。MonacoEditor 内置了一些 Theme,默认的 Theme 是 vs
,而默认的 theme 中已经设置了上述 Monarch 中的 token 对应的颜色,所以我们应用上述配置后,对应的分词直接就有了高亮颜色。
我们可以通过 monaco.editor.defineTheme
来定义一种新的 theme,如下例所示:
editor.defineTheme('myTheme', {
base: 'vs',
inherit: true,
rules: [
{ token: 'comment', foreground: 'ff4400' },
{ token: 'string', foreground: '0000ff' }
],
colors: {
},
});
// xxxx
editor.create(container, {
language: "myLang",
theme: "myTheme"
});
这里将注释设置为红色,字符串设置为蓝色,显示效果如下图所示
飘红提示的功能就是在代码错误的位置打上标记(一般是红色波浪线),可以通过 monaco.editor.setModelMarkers
API 来实现。比如我们想为 第1行的第1个字符到第2行的第2个字符 之间打上错误标记:
const editorIns = editor.create(container, {
language: "myLang",
theme: "myTheme",
value: `hello
world`
});
const model = editorIns.getModel();
editor.setModelMarkers(model, 'myLang', [
{
startLineNumber: 1,
startColumn: 1,
endLineNumber: 2,
endColumn: 2,
message: "语法错误",
severity: MarkerSeverity.Error
}
])
severity 是标记类型,message 是提示信息,效果如下所示。
到此为止,实现了飘红的功能,但是没有实现在语法错误处飘红的功能,这需要额外的语法解析器支持,会在下文中讲到。
Monaco Editor 提供了 monaco.languages.registerCompletionItemProvider
API 来实现自动补全功能
import { editor, languages, MarkerSeverity, Position, CancellationToken, Range } from "monaco-editor";
languages.registerCompletionItemProvider('myLang', {
triggerCharacters: ['.', '*'],
provideCompletionItems(
model: editor.IReadOnlyModel,
position: Position,
context: languages.CompletionContext,
token: CancellationToken
){
const wordInfo = model.getWordUntilPosition(position);
const wordRange = new Range(
position.lineNumber,
wordInfo.startColumn,
position.lineNumber,
wordInfo.endColumn
);
return new Promise((resolve) => {
resolve({
suggestions: [
{
label: "SELECT",
kind: languages.CompletionItemKind.Keyword,
insertText: "SELECT",
range: wordRange,
detail: '关键字',
},
{
label: "SET",
kind: languages.CompletionItemKind.Keyword,
insertText: "SET",
range: wordRange,
detail: '关键字',
},
{
label: "SHOW",
kind: languages.CompletionItemKind.Keyword,
insertText: "SHOW",
range: wordRange,
detail: '关键字',
},
]
})
})
}
})
registerCompletionItemProvider
接受两个参数,第一个参数是 languageId 也就是 language 名称,
第二个参数是一个 CompletionItemProvider
,CompletionItemProvider
中 triggerCharacters
用来配置触发自动补全的字符有哪些,而 provideCompletionItems
则是一个函数,它接收 Monaco Editor 提供的当前的上下文信息,返回自动补全项列表。如上例中返回了三个自动补全项,那么当我们在编辑器中输入 S
时,就会出现配置的自动补全项候选菜单。
通过这个 API 我们可以实现一种语言的关键字自动补全,只需要在CompletionItemProvider
中返回该语言所有的关键字对应的自动补全项即可。
但是registerCompletionItemProvider
目前做不到根据语义进行自动补全。
比如用户写一段 flinkSQL,当用户输入完 CREATE
关键字并按下空格后,应该出现的自动补全项应该是只有TABLE
、CATALOG
、DATABASE
、FUNCTION
、 VIEW
。
再比如当用户输入 SELECT * FROM
时,后面应该提示表名而不是其他无关的关键字。与上文中的飘红提示一样,这些语义信息需要单独的语法解析器来分析。
到此为止,在自定义 language 这一节中,我们已经了解了,在 Monaco Editor 中如何实现自定义语言的 语法高亮
、错误处飘红提示
、自动补全
。
在数栈产品中,本节讲到的功能都通过引入 monaco-sql-languages 依赖来实现,这是我们数栈 UED 团队自研的开源项目,目前已经支持多种 SQL Languages。
由于目前为止没有实现自定义 language 的语义分析功能,导致目前实现的编辑器不够智能。 另外,对于第一节中提到的 web worker ,在第二节中也没有有提到,实际上 Monaco Editor 自带的 web worker,也都是为了实现 language 的语义分析功能,下一节将阐述这一部分内容。
要实现语义分析功能,很显然我们需要一个语法解析器。除了基本的语法解析的基础功能以外,我们还需要
SELECT
关键字后面可以跟字段或者函数,那么我们所要实现的 sql parser 就应该提示出在 SELECT
关键字后面的候选项应该是字段或者函数。我们使用 Antlr4 来实现一个基本的 SQL Parser。Antlr4 是一个强大的解析器生成器,它能根据用户自定义的语法文件来生成对应的解析器。Antlr4 的语法文件为 .g4
文件,内部可以包含多条规则,规则可以分为词法规则和语法规则,词法规则用于生成词法分析器,语法规则用于生成语法解析器。
例,我们现在写一份语法规则,匹配最简单的 SELECT 语句(不包括子查询、别名等规则),比如
SELECT * FROM table1; -- eg1
SELECT table2.name, age FROM schema2.table2; -- eg2
那么在antlr4中这份语法文件应该这样写:
grammar SelectStatement;
/** 语法规则 begin */
program: selectStatement? EOF;
// 声明 语句的匹配规则
selectStatement: KW_SELECT columnGroup KW_FROM tablePath SEMICOLON?;
// 声明 语句中字段部分的匹配规则,字段部分可能为 col1, col2 的形式
columnGroup: columnPath (COMMA columnPath)*;
// 声明 字段名匹配规则,字段名有可能为 db.table.col 或者 * 的形式
columnPath: dot_id | OP_STAR;
// 声明 表名匹配规则,表名有可能为 db.table 的形式
tablePath: dot_id;
// 匹配 id.id 形式的标识符号
dot_id: IDENTIFIER_LITERAL (DOT IDENTIFIER_LITERAL)*;
/** 语法规则 end */
/** 词法规则 begin */
KW_SELECT: 'SELECT'; // 匹配 SELECT 关键字
KW_FROM: 'FROM'; // 匹配 FROM 关键字
OP_STAR: '*'; // 匹配 *
DOT: '.'; // 匹配 .
COMMA: ','; // 匹配 ,
SEMICOLON: ';'; // 匹配 ;
IDENTIFIER_LITERAL: [A-Z_a-z][A-Z_0-9a-z]*; // 匹配标识符
WS: [ \t\n\r]+ -> skip ; // 忽略空格换行等空白字符
/** 词法规则 end */
语法规则的编写格式类似于 EBNF。
然后运行 antlr4 命令,根据所写的语法文件生成对应的解析器。可以直接使用官方文档中提供的方式 antlr4 typescript-target doc ,或者直接使用社区提供的 antlr4ts 包,这里以使用 antlr4ts 为例。
生成的文件结果如下所示:
在使用Antlr4 的生成的 Parser 之前我们需要安装,Antlr4 的运行时包。你可以将 Antlr4 的运行时包与通过语法文件生成的parser文件之间的关系,类比为 react 和 react-dom之间的关系。这里以使用 antlr4ts 为运行时
import { CommonTokenStream, CharStreams } from 'antlr4ts'; import { SelectStatementLexer } from '../lib/selectStatement/SelectStatementLexer'; import { SelectStatementParser } from '../lib/selectStatement/SelectStatementParser'; class SelectParser { private createLexer(input: string) { const inputStream = CharStreams.fromString(input); const lexer = new SelectStatementLexer(inputStream); return lexer } private createParser (input: string) { const lexer = this.createLexer(input); const tokens = new CommonTokenStream(lexer); const parser = new SelectStatementParser(tokens); return parser } parse (sql: string) { const parser = this.createParser(sql) const parseTree = parser.selectStatement(); return parseTree; } } // 试一下效果 const selectParser = new SelectParser(); const parseTree = selectParser.parse('SELECT * FROM table1');
当解析一个含有错误的文本时,Antlr4 会输出错误信息,例如输入
selectParser.parse('SELECT id FRO');
控制台打印
可以看到错误信息中包含了文本中的错误所处的位置,我们可以通过使用 Antlr4 ParserErrorListener 来获取错误信息。
import { ParserErrorListener } from 'antlr4ts';
export class SelectErrorListener implements ParserErrorListener {
private _parserErrorSet: Set = new Set();
syntaxError(_rec, _ofSym, line, charPosInLine, msg) {
let endCol = charPosInLine + 1;
this._parserErrorSet.add({
startLine: line,
endLine: line,
startCol: charPosInLine,
endCol: endCol,
message: msg,
})
}
clear () {
this._parserErrorSet.clear();
}
get parserErrors () {
return Array.from(this._parserErrorSet)
}
}
import { CommonTokenStream, CharStreams } from 'antlr4ts';
import { SelectStatementLexer } from '../lib/selectStatement/SelectStatementLexer';
import { SelectStatementParser } from '../lib/selectStatement/SelectStatementParser';
class SelectParser {
private _errorListener = new SelectErrorListener();
createLexer(input: string) {
const inputStream = CharStreams.fromString(input);
const lexer = new SelectStatementLexer(inputStream);
this._errorListener.clear();
lexer.removeErrorListeners(); // 移除 Antlr4 内置的 ErrorListener
lexer.addErrorListener(this._errorListener)
return lexer
}
createParser (input: string) {
const lexer = this.createLexer(input);
const tokens = new CommonTokenStream(lexer);
const parser = new SelectStatementParser(tokens);
parser.removeErrorListeners(); // 移除 Antlr4 内置的 ErrorListener
parser.addErrorListener(this._errorListener);
return parser
}
parse (sql: string) {
const parser = this.createParser(sql)
const parseTree = parser.selectStatement();
console.log(this._errorListener.parserErrors);
return {
parseTree,
errors: this._errorListener.parserErrors,
};
}
}
// 试一下效果
const selectParser = new SelectParser();
const { errors } = selectParser.parse('SELECT id FRO');
console.log(errors);
打印结果
这样我们就获取到了文本中的语法错误出现的位置,以及错误信息。
到此为止上文中遗留的第一个问题就已经差不多解决了,我们只需要在合适的时机将编辑器的内容进行解析,拿到错误信息并且通过 editor.setModelMarkers
这个 API 让错误的位置飘红就大功告成了。
对于自动补全功能,Antlr4 并没有直接提供,但是社区已经有了比较优秀的解决方案 - antlr-c3 。它的作用是根据Antlr4 Parser 的解析结果,分析指定位置填哪些词法/语法规则是合法的。
antlr4-c3 的使用方式比较简单。
import { CodeCompletionCore } from "antlr4-c3";
// 这里 parser 是 parser 实例
let core = new CodeCompletionCore(parser);
// tokenIndex 是想要自动补全的位置,对应由编辑器的光标位置转换而来
// parserContext 则是解析完之后的返回的 ParserTree 或者 ParserTree 的子节点(传入子节点可以更高效)
let candidates = core.collectCandidates(tokenIndex, parserContext);
那么结合上文中写的 SelectParser,代码应该是这样
import { CodeCompletionCore } from "antlr4-c3";
import { SelectParser } from "./selectParser";
/**
* input 源文本
* caretPosition 编辑器光标位置
*/
function getSuggestions(input: string, caretPosition) {
const selectParser = new SelectParser();
const parserIns = selectParser.createParser(input)
let core = new CodeCompletionCore(parserIns);
const parserContext = parserIns.selectStatement();
// 伪代码
const tokenIndex = caretPosition2TokenIndex(caretPosition)
let candidates = core.collectCandidates(tokenIndex, parserContext);
}
core.collectCandidates
的返回值的数据类型如下:
interface CandidatesCollection {
tokens: Map;
rules: Map;
}
tokens 对应的是词法规则提示,比如关键字等,rules 对应的是语法规则,比如上述语法文件中的 columnPath
和 tablePath
等。
需要注意的是,antlr4-c3 默认不收集语法规则,需要我们手动设置需要收集的语法规则
import { SelectStatementParser } from '../lib/selectStatement/SelectStatementParser';
let core = new CodeCompletionCore(parserIns);
core.preferredRules= new Set([
SelectStatementParser.RULE_tablePath,
SelectStatementParser.RULE_columnPath
])
// 设置需要收集 tablePath 和 columnPath
这样我们就收集到了在指定位置的可以填什么。接下来我们需要将结果进行转换成我们需要的数据结果
import { CodeCompletionCore } from "antlr4-c3";
import { SelectParser } from "./selectParser";
import { SelectStatementParser } from '../lib/selectStatement/SelectStatementParser';
/**
* input 源文本
* caretPosition 编辑器光标位置
*/
export function getSuggestions(input: string, caretPosition?: any) {
const selectParser = new SelectParser();
const parserIns = selectParser.createParser(input)
let core = new CodeCompletionCore(parserIns);
core.preferredRules= new Set([
SelectStatementParser.RULE_tablePath,
SelectStatementParser.RULE_columnPath
])
const parserContext = parserIns.selectStatement();
const tokenIndex = caretPosition2TokenIndex(caretPosition);
let candidates = core.collectCandidates(tokenIndex, parserContext);
const rule = [];
const keywords = []
for (let candidate of candidates.rules) {
const [ruleType] = candidate;
let syntaxContextType;
switch (ruleType) {
case SelectStatementParser.RULE_tablePath: {
syntaxContextType = 'table';
break;
}
case SelectStatementParser.RULE_columnPath: {
syntaxContextType = 'column';
break;
}
default:
break;
}
if (syntaxContextType) {
rule.push(syntaxContextType)
}
}
for (let candidate of candidates.tokens) {
const symbolicName = parserIns.vocabulary.getSymbolicName(candidate[0]);
const displayName = parserIns.vocabulary.getDisplayName(candidate[0]);
if(symbolicName && symbolicName.startsWith('KW_')) {
const keyword = displayName.startsWith("'") && displayName.endsWith("'")
? displayName.slice(1, -1)
: displayName
keywords.push(keyword);
}
}
console.log('===== suggest keywords: ',keywords);
console.log('===== suggest rules:', rule);
}
这样我们就拿到了要提示的关键字和语法规则。关键字可以直接用于生成自动补全项,语法规则可以用于提示表名、字段名等。
在这一节中,我们已经了解了,如何使用 Antlr4 和 antlr4-c3 来实现更加智能的飘红提示以及自动补全功能。
这一部分功能,在 monaco-sql-languages 中通过引入数栈前端团队自研的开源项目 dt-sql-parser 实现。
前文中提到的 worker 文件也正是用于运行 sql parser,因为dt-sql-parser 的解析可能会比较耗时,为了避免阻塞用户交互,将 sql parser 放到 web worker 中运行显然是更明智的选择。
原文链接:https://juejin.cn/post/7297917491794984972