如何为小众编程语言开发一个在线IDE?

3,554 阅读8分钟

前言摘要

大众编程语言的通用IDE已经很多、很成熟了,像JetBrains系列、VSCode、Eclipse等,但小众编程语言的通用IDE似乎不多(或者在通用IDE里面以插件形式支持),再小众一些的编程语言就似乎没有了,为了弥补这一空白,于是决定开发一个面向小众编程语言的IDE(另外还有个小目标,后续再写)。

功能需求

  • 语法高亮
  • 代码补全
  • 语法检查
  • 代码执行
  • vim模式
  • 代码格式化
  • API封装

技术选择

从零开发一个IDE太难,也没有必要,当然也有例外。在网上搜了很多资料,无意中发一篇整合Monaco Editor和Antlr的文章(后面有参考链接),正好满足以上功能需求,于是就以此为主体框架。

语言选择

之前接触过国人开发的aviator表达式引擎(现在已经算是比较完备的脚本语言),算是相对熟悉的小众编程语言,于是就用此做为试点。

功能实现

以下内容讲解功能实现的技术细节,以及踩过的坑、填过的坑。

语法高亮

语法高亮部分直接使用Monaco Editor中的Monarch库(命名挺有意思,前缀相同),支持直接配置词法高亮,包括:关键词、运算符、字面量(数字、字符串)、空白、注释,参考官方文档和AviatorScript的语法很容易进行配置。

代码补全

代码补全部分也是直接使用Monaco Editor提供的配置功能,当我们写到一个单词的前缀时,自动把后面部分提示出来,按Tab或回车就可以补全出来,比如我们要输入println,输入p时,后面就提示完整内容,同时可以把整个语句都提示出来,提升了编码效率。

编辑切换为居中

代码补全效果图

AviatorScript的官方提供了大量的函数,把函数里面整理到excel中,然后批量拼接补全规则,就支持全量函数提示和关键词提示。由于文件太长,只粘贴一段

[{
    label: 'println',
    kind: monaco.languages.CompletionItemKind.Text,
    insertText: 'println(${0:text})',
    insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
}
。。。
]

语法检查

语法检查是最难、最耗时的部分,把官方提供的75个demo全部测试通过,花费了不少时间,由于是小众语言,作者的创造空间较大,给语法解析带来了部分困难。

使用Antlr的g4按照AviatorScript的官方文档编写语法文件,之前写过g4文件相对比较熟悉,其中很难的部分就是字符歧义处理耗时比较多,下面是两个典型的场景

【+-】:既可以是加减号,也可以是正负号,之前把正负号和数字字面量一起解析,发现和加减号冲突,最后都统一当成表达式来解析,并且正负号优先级高于加减号。

正则表达式:AviatorScript使用JavaScript的语法形式,/regexp/,这个时候又和除号冲突,正则表达式要使用Antlr的反向语法,只能使用词法解析,就不使用使用之前的加减号的处理方式,最后把正则表达式和之前的运算符一起解析才算解决。

语法文件编写完,就是和Monaco Editor整合,之前的实现思路是:后台提供一个把代码解析成AST的服务,并且把语法错误信息一起返回,然后由前端编辑器提示语法错误,这种性能比较差,语法需要实时检查,每次更改都会发起服务端请求,耗时很多,幸好Antlr提供了JavaScript的运行时,方案改成:把语法文件用Antlr生成JavaScript代码,然后加载JavaScript到浏览器中,在浏览器中实现AST生成和语法检查,这时性能就好了很多。下面是生成词法解析部分截图

当出现语法错误时,会给出行列号,然后结合Monaco Editor提供的语法错误校验、标注函数进行错误提示,下图是解析AST语法树和错误信息

Monaco Editor语法错误标注代码部分,将AST中的错误信息传递到编辑器中

页面显示效果

代码执行

代码执行部分相对简单,后台起一个SpringBoot服务,引入AviatorScript的包,封装成一个REST服务调用执行,然后返回结果,代码如下:

import com.alibaba.fastjson.JSONObject;
import com.googlecode.aviator.AviatorEvaluator;
import com.googlecode.aviator.Expression;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.util.HashMap;
import java.util.Map;

@SpringBootApplication
@RestController
@RequestMapping("/service")
public class AviatorServiceApplication {

    Logger logger = LoggerFactory.getLogger(this.getClass());

    public static void main(String[] args) {
        SpringApplication.run(AviatorServiceApplication.class, args);
    }

    @RequestMapping("/execute")
    public Object service(@RequestBody String input) {
        JSONObject logInfo = new JSONObject(true);
        logInfo.put("input", input);
        JSONObject inputJson = JSONObject.parseObject(input);
        String code = inputJson.getString("code");
        Map param = new HashMap();
        if (inputJson.containsKey("param")) {
            param = inputJson.getJSONObject("param");
        }
        Map output = new HashMap();

        PrintStream systemOut = System.out;
        try {
            ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
            System.setOut(new PrintStream(outputStream));
            AviatorEvaluator.setTraceOutputStream(outputStream);
            Expression compiledExp = AviatorEvaluator.compile(code);
            Object result = compiledExp.execute(param);
            String printString = outputStream.toString();
            output.put("output", result);
            output.put("print", printString);
        } catch (Exception e) {
            output.put("error", e.getMessage());
        } finally {
            System.setOut(systemOut);
        }
        logInfo.put("output", output);
        logger.info("input-output:".concat(logInfo.toJSONString()));
        return output;
    }
}

vim模式

平时写代码喜欢vim模式,并且有现成的npm包,就加上了,代码如下:

//配置加载
require.config({
    paths: {
        vs: 'https://7072-prd-ffx26-1255301037.tcb.qcloud.la/321zou.com/dsl/monaco-editor/0.32.1/min/vs',
        'monaco-vim': 'https://unpkg.com/monaco-vim/dist/monaco-vim',
    }
});

//和monaco一起加载
require(['vs/editor/editor.main', 'monaco-vim'], function(a, theMonacoVim) {
       MonacoVim = theMonacoVim; // 后面使用
}

// 编辑模式切换
function toggleVimMode() {

    if (MonacoVim) {
        if (isVimMode) {
            vimMode.dispose();
            $("#toggleVimModeBtn").text("切换VIM模式");
            $("#vimStatus").hide();
        } else {
            $("#toggleVimModeBtn").text("切换普通模式");
            var vimStatusNode = document.getElementById('vimStatus');
            vimMode = MonacoVim.initVimMode(dslCodeEditor, vimStatusNode);
            $("#vimStatus").show();
        }

        isVimMode = !isVimMode;
    }
}

文末有参考链接

代码格式化

代码格式化这部分是粗粒度处理,有很多细节地方需要优化,同时使用了一些hack的方式。

整体思路就是:解析代码成AST,然后用AST进行代码重新拼接,解析成AST时自动忽略了空格,于是整个拼接的过程就是加空格和缩进。

什么时候加空格比什么时候加缩进更难一些

缩进规则:左花括号后面加缩进,右花括号后面减缩进,进行递归处理。但是AviatorScript支持lambda表达式,于是缩进规则加上lambda部分。

空格规则:这部分比较多,运算符前后加空格,大中小括号不加空格,而且有冲突,函数调用时括号不加空格,表达式中的括号要加空格,直接看代码部分(这部分需要优化空间很大)

API部分

上面已实现了编辑器的功能,但是要以嵌入的方式集成到别的网页中,还需要提供API,目前使用iframe的方式嵌入,对iframe的嵌入场景提供API

API设计

在设计之初定了,在群里沟通交流,初步以下目标

  1. 稳固:长期有效,不受版本升级影响
  2. 易学:技能能够从其他编辑器上平滑迁移过来
  3. 易用:能高效便捷的实现编辑功能

如何达到稳固:

不稳固的就容易变动,变动就会带来升级成本,稳固成为第一要求。

怎么达到稳固啦?所谓万变不离其宗,需要把一个编辑器的“宗”给找出来,这个就是编辑器的核心本质。

从使用流程上看编辑器,打开编辑器、加载文本、编辑文本、存储文本。

再加上一些限制条件和忽略内容:编辑器已经打开、不考虑文件存取部分,剩下的就是:设置文本、编辑文本、获取文本,

其中编辑文本比较复杂稍后考虑,那么前两个API就确定下来了:setText,getText

复杂的编辑部分就交给monaco-editor去处理,API只关心编辑的开始和结束两部分,这样就能达到稳固效果。

如果取名为getText,那么后续扩展为图片时,那么要增加api,似乎monaco的getValue更通用,后面一想真的需要扩展为图片编辑器么?

在网上搜索其他编辑器的命名方式

  • monaco-editor : getValue、setValue
  • UE:getContent、setContent
  • KingEditor:html

哪种命名方式更好,不好说。这个时候需要回到初心,回到编辑器的定位,本质上是想封装一个代码编辑器,于是更好的命名方式是:getCode、setCode。

同时设计API时翻阅了知乎上面的关于API设计部分,学习了很多。

定了以下几个API:

  1. getCode
  2. setCode
  3. getAST
  4. executeCode
  5. formatCode
  6. getEditMode
  7. setEditMode

API实现

API实现包括两部分:API提供、API使用

API提供部分代码如下,在iframe里面把功能绑定到父页面中

if (parent && parent.bindDSLEditorAPI) {
    parent.bindDSLEditorAPI({
        getCode: function() {
            return getCode();
        },
        setCode: function(code) {
            setCode(code);
        },
        getAST: function() {
            return getAST(getCode());
        },
        executeCode: function(callback) {
            dslExecuteMap[urlParam.dsl](callback);
        },
        formatCode: function() {
            formatCode();
        },
        getEditMode: function() {
            return isVimMode ? "vim" : "default";
        },
        setEditMode: function(mode) {
            if ("vim" === mode && !isVimMode) {
                toggleVimMode();
            } else if ("default" === mode && isVimMode) {
                toggleVimMode();
            }
        },
    });
}

API调用部分,就接收绑定,然后直接调用编辑器的API功能服务,代码如下(目前只支持最新打开tab的绑定,存在切换tab后不生效的bug,绑定被替换)

var dslEditor = null;

function bindDSLEditorAPI(_dslEditor) {
    dslEditor = _dslEditor;
}

var buttonAction = {
    getCode: function() {
        var code = dslEditor.getCode();
        alert(code);
    },
    setCode: function() {
        var code = prompt("input code:");
        dslEditor.setCode(code);
    },
    getAST: function() {
        var ast = dslEditor.getAST();
        alert(JSON.stringify(ast));
    },
    executeCode: function() {
        dslEditor.executeCode(function(jsonResult) {
            alert(JSON.stringify(jsonResult));
        });
    },
    formatCode: function() {
        dslEditor.formatCode();
    },
    getEditMode: function() {
        var mode = dslEditor.getEditMode();
        alert(mode);
    },
    setEditMode: function() {
        var mode = prompt("mode: vim or default", "vim");
        dslEditor.setEditMode(mode);
    },
    showAPI: function() {
        var help = "=========API列表=========\n\n";
        help += "1, 获取代码: string getCode()\n";
        help += "2, 设置代码: void setCode(code)\n";
        help += "3, 获取AST: json getAST()\n";
        help += "4, 执行代码: json executeCode()\n";
        help += "5, 格式化代码: void formatCode()\n";
        help += "6, 获取编辑模式: string getEditMode()  default|vim\n";
        help += "7, 设置编辑模式: void setEditMode(mode) default|vim\n";
        alert(help);
    },
};

踩过几个坑

第一个坑

在交流群里面发现的一个BUG,当Monaco Editor使用Monarch时,括号后回车光标位置不正确,括号里面的第一行没有缩进,尝试了缩进规则都不生效,官网也能复现。

填第一个坑:捕捉Monaco Editor的回车事件,手工插入缩进的空格,带来一个副作用,代码补全不支持回车了,需要使用Tab来补全代码。

第二个坑

钉钉的浏览器不支持Monaco Editor,加vConsole居然不报错,最后只能加大量alert找到此原因。

填第二个坑:当代码不支持Monaco Editor时,使用textarea替代,不影响执行,其中要判断浏览器是否支持,使用了setTimeout进行多次判断monaco是否存在,但是在弱网环境下,有可能网速往monaco没有加载,出现误判的情况,于是需要N多次检测,才能达到相对平滑的效果。

第三个坑

修改了语法文件,但是一直不生效,重试了N次,修改了N次,Ctrl+刷新,最后发现新开窗口执行就正常了,看网络请求居然是304,一直使用服务端缓存。

第四个坑

css样式名和Monaco Editor样式名冲突,导致代码提示显示不全,部分被遮挡,此BUG花了大量时间才定位出来,代码提示部分是动态加载出来的,鼠标一动就消失了,chrome的开发者工具就使用不了,最后靠猜,代码提示对应的英文是suggest,于是搜索,居然找到了代码补全的dom位置,把整个suggest元素复制出来,粘贴到代码中直接静态调试,才发现样式名冲突了。

第五个坑

编辑器定位,潜意识和显意识不一致,想的是IDE,用词确是编辑器,实际是文本编辑器,本质是代码文本编辑器,这几个概念是有差异的,刚开始是混淆的,并没有意思到之间的细微差异,就出现了要不要支持图片编辑这个功能。

在线使用

访问网站:aviator.321zou.com/

后续规划

  • VSCode插件
  • React、Vue封装
  • 整合LSP
  • 其他小众编程语言拓展
  • 整理代码开源

参考资料

AviatorScript:1. 介绍 · 语雀

Monaco Editor和Antlr整合:使用Monaco和ANTLR编写基于浏览器的编辑器_danpu0978的博客-CSDN博客

Monaco Editor官网:Monaco Editor Monarch

Antlr官网:www.antlr.org/

Antlr JavaScript运行时:github.com/antlr/antlr…

monaco-vim : monaco-vim

API设计1:如何设计出一些优雅的API接口呢?

API设计2:阿里云云栖号:深度 | API 设计最佳实践的思考