使用antlr&monaco editor实现自定义语法的代码编辑器

最近在日常搬砖时接到一个需求,需要在网页上实现一个代码编辑器,编辑器支持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文件大概是这样

image.png g4文件具体怎么编写可以查看上面一篇关于antlr的文章。简单来说我定义了词法和语法。词法分别为加减乘除等于括号和数字。语法分别为括号语法、加法减法、乘法除法。编写完G4文件后需要使用antlr4ts-cli来生成解析器

npx antlr4ts -visitor src/parser/calc.g4
复制代码

运行完这个命令后可以发现生成了几个文件

image.png 大家可以去查看一下这几个文件的内容。也可以大致明白是干什么的了

实现关键词高亮

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看看打印出什么

image.png 可以看到打印了一个token数组,我们点开第一个token看看里面是什么

image.png 他分析出了我们输入的所有词法的位置和他的类型,这里类型是个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的主题颜色就可以看到高亮效果了

image.png 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看看是什么

image.png 我们可以通过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类型正确
  }
}

复制代码

效果

image.png

实现错误捕获

关于代码错误捕获使用的是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;
};

复制代码

image.png 当然你也可以用上面实现hover时的分析器去实现自定义的语言错误,比如在做需求时的变量未定义,函数参数个数错误等。

总结

其实有了这个语法分析器后我们还可以做更多的事,我们只需将获得的数组拼装成monaco想要的格式就可以了。这里就不演示其他功能了,有兴趣的同学可以自己去研究一下。我相信antlr之后会在前端领域起到大作用。
本文的demo已上传github:my-monaco-editor
杭州字节跳动抖音社区安全前端团队招人啦,团队氛围佳,内推投递地址

猜你喜欢

转载自juejin.im/post/7066757022552686605