最近在日常搬砖时接到一个需求,需要在网页上实现一个代码编辑器,编辑器支持govaluate语法(govaluate语法简介请戳这里),需要有代码编辑器最基本的交互效果,代码提示、关键词高亮、代码错误捕获、hover提示、自动格式化等。我们知道,网页上的代码编辑器肯定使用monaco editor,monaco editor已经内部支持了主流的编程语言,比如js,java,go等。但是这回需求的语言是go的一个库,monaco并没有支持这种语言,所以需要我们使用monaco自定义的语言能力去完成这个需求。
经过调研和公司内部有类似自己实现语言的代码编辑器,我了解到了antlr这个库,可以使用它来完成自定义monaco语言。
关于antlr
ANTLR(全名:ANother Tool for Language Recognition)是用 Java 语言编写的功能强大的语法分析器自动生成工具,由旧金山大学的 Terence Parr 博士等人于 1989 年推出第一代,迭代到现在是第四代,因此一般称之为 Antlr4。该工具本身是 java 语言的工具,但产出的语法分析器可以是包括 js 和 ts 语言在内的主流编程语言,因此基本上可以认为 Antlr4 是当前使用最广泛的一款语法分析器自动生成工具。
这里有一篇更详细的文章来介绍antlr以及它的用法,感兴趣的同学可以移步这里
编译技术在前端的实践(二)—— Antlr 及其应用
注意
这篇文章我就不会讲很多monaco editor的一些用法,主要是通过一个小demo来向同学们说明antlr怎么实现自定义monaco语言。
monaco editor官网
技术选型
使用react+ts+antlr4ts+react-monaco-editor(也可以用没封装过的monaco editor,我只是为了偷懒,毕竟monaco editor不是重点。)
初始化项目安装依赖
npm i react-monaco-editor
npm i antlr4ts
npm i antlr4ts-cli -D
复制代码
使用monaco editor
import React from 'react';
import './App.css';
import MonacoEditor from 'react-monaco-editor';
function App(): JSX.Element {
return (
<div className="App">
<MonacoEditor
width={800}
height={600}
options={{
fontSize: 20,
}}
language="javascript"
theme="vs-dark"
/>
</div>
);
}
export default App;
复制代码
当然使用monaco editor还得装个webpack插件monaco-editor-webpack-plugin 同学们可以去google怎么使用。
编写G4文件 生成解析器
因为是小demo,我们就用最简单的加减乘除语法来做个例子,G4文件大概是这样
g4文件具体怎么编写可以查看上面一篇关于antlr的文章。简单来说我定义了词法和语法。词法分别为加减乘除等于括号和数字。语法分别为括号语法、加法减法、乘法除法。编写完G4文件后需要使用antlr4ts-cli来生成解析器
npx antlr4ts -visitor src/parser/calc.g4
复制代码
运行完这个命令后可以发现生成了几个文件
大家可以去查看一下这几个文件的内容。也可以大致明白是干什么的了
实现关键词高亮
monaco实现高亮是用setTokensProvider这个api,我们只需获得文本里各个关键词的位置拼装成monaco想要的数据就可以实现高亮。所以实现高亮只需要用到词法分析器就可以了。关于我们的计算表达式语法我们就高亮数字和操作符即可。
实现TokenProvider类
首先先声明一个monaco需要高亮格式的一个类
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
function getTokens(input: string) {
return []
}
function tokenForLine(input: string) {
const tokens = getTokens(input);
return { tokens, endState: new State() };
}
class State implements monaco.languages.IState {
clone(): monaco.languages.IState {
return new State();
}
equals(other: State): boolean {
return true;
}
}
export class TokensProviders implements monaco.languages.TokensProvider {
tokenize(line: string, state: State): monaco.languages.ILineTokens {
return tokenForLine(line);
}
getInitialState(): monaco.languages.IState {
return new State();
}
}
复制代码
我们主要的分析逻辑在getTokens这个函数,我们需要的一个返回格式参照文档IToken 首先我们要分析出传进来的文本里哪些位置是我们配置过的词法。我们使用calcLexer这个类获取文本的token流。
import { CharStreams } from 'antlr4ts';
import { calcLexer } from '../parser/calcLexer';
// 初始化lexer
const chars = CharStreams.fromString(input);
const lexer = new calcLexer(chars);
lexer.removeErrorListeners();
// 获取token流
const tokens = lexer.getAllTokens();
console.log(tokens)
复制代码
我们在编辑器里输入1+1=2看看打印出什么
可以看到打印了一个token数组,我们点开第一个token看看里面是什么
他分析出了我们输入的所有词法的位置和他的类型,这里类型是个index还需要去转换一下
const type = lexer.ruleNames[token.type - 1];
复制代码
这样就能拿到第一个词的类型为number 因为我们加法减法等都是操作符,所以需要把加法减法等都转为同一个类型传给monaco
export const TokenMap: Record<string, string> = {
ADD: 'operator',
SUB: 'operator',
DIV: 'operator',
MUL: 'operator',
EQUAL: 'operator',
OpenParen: 'operator',
CloseParen: 'operator',
NUMBER: 'keyword',
UnexpectedCharacter: '',
};
复制代码
我们也可以捕获一些我们没配置过的词法,把他变为红色
console errors = [];
lexer.addErrorListener({
syntaxError(_1, _2, _3, charPositionInLine: number) {
errors.push(charPositionInLine);
},
});
复制代码
最后我们配置一个monaco的主题颜色就可以看到高亮效果了
getTokens完整代码
function getTokens(input: string) {
const lexer = createLexer(input); // 初始化lexer封装成了一个函数
// 捕获词法错误
const errors: number[] = [];
lexer.removeErrorListeners();
lexer.addErrorListener({
syntaxError(_1, _2, _3, charPositionInLine: number) {
errors.push(charPositionInLine);
},
});
// 获取token流
const tokens = lexer.getAllTokens();
console.log(tokens);
const res: monaco.languages.IToken[] = tokens.map(token => {
const type = lexer.ruleNames[token.type - 1];
const typeName = TokenMap[type] || TokenMap.UnexpectedCharacter;
return {
scopes: typeName,
startIndex: token.charPositionInLine,
};
});
// 将捕获到的错误加入res中
errors.forEach(point => res.push({ scopes: 'error', startIndex: point }));
return res;
}
复制代码
到这利用词法分析器实现的关键词高亮就完成了。当然实际做需求的时候还可以更灵活,比如检测到后面跟上了括号就认为这个词为一个函数。
实现代码hover提示
hover提示我们就使用一下语法分析器去实现。首先还是一样实现hover类
实现HoverProvider类
export class HoverProvider implements monaco.languages.HoverProvider {
provideHover(model: monaco.editor.IModel, position: monaco.Position) {
return {
contents: [],
};
}
}
复制代码
providerHover函数需要返回的格式看这里providerHover 我们利用语法分析器把传入的文本转为AST数,再通过对应的方法去获取鼠标划到的关键词是什么,首先生成AST
export const getParser = (input: string) => {
const lexer = createLexer(input); // 初始化词法分析器
const tokenStream = new CommonTokenStream(lexer);
const parser = new calcParser(tokenStream);
parser.removeErrorListeners();
lexer.removeErrorListeners();
return parser;
};
export const getAST = (input: string) => {
const parser = getParser(input);
const ast = parser.start();
return ast;
};
复制代码
怎么分析生成的AST呢,我们需要antlr4提供的ParseTreeWalker来实现,具体用法为
import { ParseTreeWalker } from 'antlr4ts/tree/ParseTreeWalker';
ParseTreeWalker.DEFAULT.walk(finder, AST); // 分析AST
复制代码
这个finder就是一个回调函数类,这个类是implementscalcListener这个接口的。他分析到什么语法就会进入到对对应的回调中。
class HoverFinder implements calcListener {
result?: {
range: monaco.Range;
type: 'string';
name?: string;
};
private position: monaco.Position;
constructor(position: monaco.Position) {
this.position = position;
}
enterNumber(ctx: NumberContext) {
console.log(ctx);
}
}
复制代码
我们打印ctx看看是什么
我们可以通过start属性拿到token,也可以拿到关键词的位置。使用monaco.Range.containsPosition看看是否匹配上。
const getRangeFromToken = (input: Token) => {
const startLineNumber = input.line;
const startColumn = input.charPositionInLine + 1;
const length = input.text?.length || 1;
return new monaco.Range(startLineNumber, startColumn, startLineNumber, startColumn + length);
};
enterNumber(ctx: NumberContext) {
if (!this.result) {
console.log(ctx);
const range = getRangeFromToken(ctx.start);
const matched = monaco.Range.containsPosition(range, this.position);
if (matched) {
this.result = {
range,
type: 'number',
name: ctx.start.text,
};
}
}
}
复制代码
这样我们通过finder里面的result就可以知道是否触发到了hover弹窗,即是否鼠标滑到了数字上,所以完整代码为
import { Token } from 'antlr4ts';
import { ParseTreeWalker } from 'antlr4ts/tree/ParseTreeWalker';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { getAST } from '../common';
import { calcListener } from '../parser/calcListener';
import { NumberContext } from '../parser/calcParser';
export class HoverProvider implements monaco.languages.HoverProvider {
provideHover(model: monaco.editor.IModel, position: monaco.Position) {
const content = model.getValue();
const AST = getAST(content || '');
const finder = new HoverFinder(position);
ParseTreeWalker.DEFAULT.walk(finder, AST); // 遍历AST
const { result } = finder;
if (result.type === 'number') {
return {
contents: [
{
value: `数字${result.name}`,
},
],
range: result.range,
};
}
return {
contents: [],
};
}
}
const getRangeFromToken = (input: Token) => {
const startLineNumber = input.line;
const startColumn = input.charPositionInLine + 1;
const length = input.text?.length || 1;
return new monaco.Range(startLineNumber, startColumn, startLineNumber, startColumn + length);
};
class HoverFinder implements calcListener {
result?: {
range: monaco.Range;
type: string;
name?: string;
};
private position: monaco.Position;
constructor(position: monaco.Position) {
this.position = position;
}
enterNumber(ctx: NumberContext) {
if (!this.result) {
console.log(ctx);
const range = getRangeFromToken(ctx.start);
const matched = monaco.Range.containsPosition(range, this.position);
if (matched) {
this.result = {
range,
type: 'number',
name: ctx.start.text,
};
}
}
}
visitErrorNode() {
// 为了ts类型正确
}
}
复制代码
效果
实现错误捕获
关于代码错误捕获使用的是monaco.editor.setModelMarkers这个api,我们需要在文本改变的时候实时检测错误。 我们需要实现一个validate函数,在文本改变的时候调用它,这个函数返回一个数组代表着错误位置和内容,我们使用setModelMarkers这个api去标识错误。
我们将使用语法和词法的错误监听功能去实现。 具体代码
import { CommonTokenStream, Token } from 'antlr4ts';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { createLexer } from '../common';
import { calcParser } from '../parser/calcParser';
const getPositionByToken = (token: Token) => ({
startLineNumber: token.line,
startColumn: token.charPositionInLine + 1,
endLineNumber: token.line,
endColumn: token.charPositionInLine + (token.text?.length || 0) + 1,
});
export const validate = async (model: monaco.editor.IModel) => {
let content = '';
try {
content = model.getValue();
console.log(content);
} catch {
monaco.editor.setModelMarkers(model, 'ruleLint', []);
return;
}
if (!content.trim()) {
monaco.editor.setModelMarkers(model, 'ruleLint', []);
return;
}
const lexer = createLexer(content);
const tokenStream = new CommonTokenStream(lexer);
const parser = new calcParser(tokenStream);
lexer.removeErrorListeners();
parser.removeErrorListeners();
const errors: monaco.editor.IMarkerData[] = [];
// 收集词法错误和语法错误
lexer.addErrorListener({
syntaxError(_1, _2, line, charPositionInLine, msg, _6) {
errors.push({
message: msg,
severity: monaco.MarkerSeverity.Error,
source: 'validator',
startLineNumber: line,
startColumn: charPositionInLine + 1,
endLineNumber: line,
endColumn: charPositionInLine + 2,
code: 'lexer',
});
},
});
parser.addErrorListener({
syntaxError(_1, offendingSymbol, _3, _4, msg, _6) {
if (offendingSymbol) {
errors.push({
message: msg,
severity: monaco.MarkerSeverity.Error,
source: 'validator',
code: 'parser',
...getPositionByToken(offendingSymbol),
});
}
},
});
parser.start();
return errors;
};
复制代码
当然你也可以用上面实现hover时的分析器去实现自定义的语言错误,比如在做需求时的变量未定义,函数参数个数错误等。
总结
其实有了这个语法分析器后我们还可以做更多的事,我们只需将获得的数组拼装成monaco想要的格式就可以了。这里就不演示其他功能了,有兴趣的同学可以自己去研究一下。我相信antlr之后会在前端领域起到大作用。
本文的demo已上传github:my-monaco-editor
杭州字节跳动抖音社区安全前端团队招人啦,团队氛围佳,内推投递地址