相关文章推荐
爱看书的核桃  ·  javascript ...·  5 月前    · 
玩足球的筷子  ·  el-popover ...·  1 年前    · 
logo
登录 / 注册

技术干货 | 网易云商 DSL 领域编程实战

头像
网易智慧企业技术+
2022-09-01 · 网易智慧企业技术+


导读: 在阅读本文时,希望读者对于 编译原理 有一些基本的了解,以便理解本文提到的相关专业术语,从而更好的理解文章表达的思想。

文|亦心

网易云商



DSL 是什么?


DSL 即领域专用语言(domain specific language / DSL),其基本思想是“求专不求全”,不像通用编程语言那样目标范围涵盖一切软件问题,而是专门针对某一特定问题的计算机语言,非图灵完备的。


DSL 领域专用语言: SQL、JSON、正则表达式、TSX、VUE 等, 非图灵完备

GPL 通用编程语言: Java、JavaScript、C++、Python 等, 图灵完备


简单的理解,DSL 是一门便于人们理解的编程语言或规范语言,并且可以被计算机解释执行。相比于通用编程语言,只能表达有限的逻辑。因为受限的表达性,所以只会在某一些领域广泛应用。




DSL 在编程领域举例


举例: 定义一个时间,表示2周之前。

编程方式: 以 JavaScript 为例。


new Date(Date.now() - 1000 * 60 * 60 * 24 * 2)


上述代码对于开发人员来说还算简单,但是具体表示什么时间,也需要花点时间去分析代码,但是对于非开发人员,想要理解这段代码表示的含义就显的有些困难了。


所以,我们是不是可以用一种更加简洁的方式来表示两周之前呢?


// 为方便演示,本例中week以单数形式出现2 week ago


上述编写方式,相比前面一种,变得简洁易懂,可以问问身边 懂点英语的 非程序员。

在上述举例中,我们已经有了 DSL 领域语言的设计方向, 2 week ago 其实就是一种针对时间表示的领域特定语言。



设计一门表示时间的 DSL


上面 2 周之前 的 DSL 表示为 2 week ago ,对于时间,我们不能局限于只表达 2 周之前,可能还有以下场景:


  • 1 小时后

  • 3 天之前

  • 1 个月后

  • 10 年前

... ...


所以我们需要对表达式进行抽象,在这个简单的场景中,我们可以将语言抽象成如下表达式:


count dateType actionType


count :表示时间单位的数量。
dateType :表示时间单位。
actionType :表示
时间表达类型是之前 ago,还是之后 after。


据此,我们很快就能写出一个正则表达式,用来提取上述三个变量的值。



我们再写一个函数用来转换时间,简单起见,这里的月按照 30 天计算,年按照 365 天计算。


const getTime = (count, dateType, actionType) {   const timeUnitMap = {     second: 1000,     minute: 1000 * 60,     hour: 1000 * 60 * 60,     day: 1000 * 60 * 60 * 24,     week: 1000 * 60 * 60 * 24 * 7,     month: 1000 * 60 * 60 * 24 * 30,     year: 1000 * 60 * 60 * 24 * 365,   }   const timeUnit = timeUnitMap[dateType]   if (actionType === 'ago') {     return new Date(Date.now() - timeUnit * count)   }   if (actionType === 'after') {     return new Date(Date.now() + timeUnit * count)   }   return null}


如上,我们就能通过简单的 DSL 语法来表达时间:


  • 1 小时后:1 hour after

  • 3 天前:3 day ago

  • 1 月后:1 month after

  • 10 年前:10 year ago


本例中,我们实现了一门简单的 DSL 语言用以表示时间。在实际场景中,我们设计的 DSL 通常更加复杂,所以单纯的使用正则表达式来定义和解析 DSL 文法会变得异常困难,所以我们需要一个工具来高效的定义、解析、转换 DSL,本文中,我们选取了 PEG.js 来实现这些功能。




PEG.js 介绍


PEG.js 是一个简单的 JavaScript 解析器生成器,可生成具有出色错误报告的快速解析器。您可以使用它来处理复杂的数据或计算机语言,并轻松构建转换器、解释器、编译器和其他工具。


我们通过 PEG.js 来实现上述时间表达的文法,如下:

// 定义时间表达式Expression  = count:Integer _ dateType:DateType _ actionType:ActionType {  return [count, dateType, actionType]}
// 定义时间单位DateType = 'second' / 'minute' / 'hour' / 'day' / 'week' / 'month' / 'year' { return text()}
// 定义时间类型ActionType = 'ago' / 'after' { return text()}
// 整数类型Integer "integer" = _ [0-9]+ { return parseInt(text(), 10); }
// 空格_ "whitespace" = [ \t\n\r]*


代码使用,如下:


import grammarConfig from './dsl.pegjs';
const parser = peg.generate(grammarConfig);parser.parse("2 week ago"); // returns [2, "week", "ago"]


可以看到,通过 PEG.js 定义文法,可以拆分表达式,并通过 巴科斯范式去定义 DSL 语言的文法。这种方式让我们不必拘泥于原来复杂臃肿的正则表达式,通过这种方式,我们可以定义更加复杂的 DSL 语言。



DSL 在网易云商中的实践


在网易云商的业务中,有一项很重要的业务是 问卷调研 ,通常问卷的设计者会设置一些问卷逻辑来满足数据收集的需求,最初这些逻辑都是通过 GUI 界面操作实现的,如下:




然而随着业务的发展,越来越多的问题开始暴露。


GUI 逻辑设置存在的问题:

  • 配置操作繁琐,相对复杂的问卷,可能用户需要操作几百上千次界面配置。

  • 复杂的自定义逻辑无法通过 GUI 配置满足。

  • 配置分散在各个题型当中,逻辑查阅不方便。


由于 GUI 无法实现复杂逻辑的问题,所以我们通过源代码编程的方式,实现了个别客户的定制化需求,但也面临着一些问题。


源代码自定义编程:

  • 相对于 GUI 逻辑配置,这种方式理论上能满足客户的 任何需求

  • 一般运营人员无法操作,需要开发人员投入,实施成本较高。

  • 对外开放这种底层能力存在一定的技术风险(代码安全、兼容性)等。


所以我们迫切的需要一种方式,来满足日益增长的业务需求,基本诉求如下:

  • 提供类似于源代码编程能力,覆盖 90% 以上的业务场景。

  • 使用简单,普通的运营人员也能操作。

  • 保证系统安全稳定运行。


综上,我们设计了一门用来配置问卷特定领域的 DSL 语言,来实现编程能力下沉,在 DSL 开发过程中,主要有以下内容:

  • DSL 文法设计。

  • DSL 编辑器设计。

  • 内部 DSL 语言转 AST 设计。

  • 问卷系统解析执行 AST。


下面我会用一些简单的例子来讲解 DSL 在问卷业务中的落地场景。


// 当题目Q1选中选项A1时,隐藏题目Q2if Q1A1 then hide Q2
// 当题目Q3选中选项A1时,显示题目Q4if Q3A1 then show Q4


文法设计


首先对上述场景进行文法抽象分析

if 表达式: if condition then action ,对应例子的整个表达式。

条件表达式:if condition then action,对应例子中的 Q1A1 Q3A1

动作表达式:if condition then action ,对应例子中 hide Q2 show Q4

选项表达式: Q1A1 Q3A1

问题表达式: Q2 Q4


定义基础表达式


// 数字Number  = [0-9]+ {    return Number(text())  }
// 字符Chars = [a-zA-Z0-9]+ { return text() }
// 空格WhiteSpace "whitespace" = "\t" / "\v" / "\f" / " " / "\u00A0" / "\uFEFF" / Zs
Zs = [\u0020\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]
// 注释Comment = SingleLineComment
SourceCharacter = .
SingleLineComment = "#" (!LineTerminator SourceCharacter)*
LineTerminator = [\n\r\u2028\u2029]
// 换行符LineTerminatorSequence "end of line" = "\n" / "\r\n" / "\r" / "\u2028" / "\u2029"
// 注释 || 空格 || 换行符__ = (WhiteSpace / LineTerminatorSequence / Comment)*


定义问题和选项的表达式


QuestionPrefix  = 'Q'
Question = QuestionPrefix index:Number
OptionPrefix = q:Question 'A'
Option = q:OptionPrefix index:Number


这样配置我们的 pegjs 就可以解析 Q1A1 Q2 这种语法了。


定义条件表达式


Test = Option / Question


定义操作表达式


IfConsequent  = ShowCallExpression  / HideCallExpression  HideCallExpression  = 'hide' __ arg:HideArguments 
HideArguments = first:HideItemPrimary others:(__ ',' __ HideItemPrimary)*
HideItemPrimary = Options / Questions
ShowCallExpression = 'show' __ arg:HideArguments


这样我们的文法就能够识别 hide Q2 show Q1 这种语法了。


定义 if 表达式


IfStatement  = 'if' __ test:Test __ 'then' __ consequent:IfConsequent


完整的文法表达式


{  function extractList(list, index) {    return list.map(function(element) { return element[index]; });  }
function buildList(head, tail, index) { return [head].concat(extractList(tail, index)); }
function optionalList(value) { return value !== null ? value : []; }
function buildOption(info) { const { qNumber, sNumber, oNumber } = info const variableInfo = { type: 'Option', qNumber, sNumber, oNumber, } return variableInfo }
function buildQuestion(info) { const { qNumber } = info const variableInfo = { type: 'Question', qNumber, } return variableInfo }
function buildRangeExpression(startIndex, endIndex, builder) { const list = [] for (let i = endIndex; i >= startIndex; i--) { const item = builder(i) list.unshift(item) } return list }}
Start = __ program:Program __ { return program; }
Program = div:SourceElements? { return { type: "Program", div: optionalList(div) };}
SourceElements = head:SourceElement tail:(__ SourceElement)* { return buildList(head, tail, 1); }
SourceElement = IfStatement / ShowCallExpression / HideCallExpression
IfStatement = 'if' __ test:Test __ 'then' __ consequent:IfConsequent { return { type: "IfStatement", test, consequent, } }
IfConsequent = ShowCallExpression / HideCallExpression

Test = Option / Question

// ==== action start ====
HideCallExpression = Hide __ arg:HideArguments { return { type: 'CallExpression', callee: 'hide', arguments: arg, } }
Hide = 'hide'
HideArguments = first:HideItemPrimary others:(__ ',' __ HideItemPrimary)* { const list = [...first] others.forEach(o => { list.push(...o[3]) }) return list }
HideItemPrimary = Options / Questions
ShowCallExpression = Show __ arg:HideArguments { return { type: 'CallExpression', callee: 'show', arguments: arg, } }
Show = 'show'


// ==== action end ====

// 问题、子问题、选项相关RangeOperator = '~'
RangeExpression = start:Number RangeOperator end:Number { if (start >= end) { error('范围不正确') } return { start, end, } }
Options = q:OptionPrefix range:RangeExpression { return buildRangeExpression(range.start, range.end, (index) => { return buildOption({ qNumber: q.qNumber, sNumber: q.sNumber, oNumber: index, }) }) } / o:Option { return [o] }

Questions = QuestionPrefix range:RangeExpression { return buildRangeExpression(range.start, range.end, (index) => { return buildQuestion({ qNumber: index, }) }) } / q:Question { return [q] }
QuestionPrefix = 'Q'
Question = QuestionPrefix index:Number { return buildQuestion({ qNumber: index }) }

OptionPrefix = q:Question 'A' { return q }
Option = q:OptionPrefix index:Number { return buildOption({ qNumber: q.qNumber, sNumber: q.sNumber, oNumber: index, }) } // 基础字符 & 注释Number = [0-9]+ { return Number(text()) }
Chars = [a-zA-Z0-9]+ { return text() }
WhiteSpace "whitespace" = "\t" / "\v" / "\f" / " " / "\u00A0" / "\uFEFF" / Zs
Zs = [\u0020\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]
Comment = SingleLineComment
SourceCharacter = .
SingleLineComment = "#" (!LineTerminator SourceCharacter)*
LineTerminator = [\n\r\u2028\u2029]
LineTerminatorSequence "end of line" = "\n" / "\r\n" / "\r" / "\u2028" / "\u2029"__ = (WhiteSpace / LineTerminatorSequence / Comment)*


可以将上述文法复制到 http://pegjs.org/online 中体验,当我们输入:


if Q1A1 then hide Q2


可以得到下面的抽象语法树(AST):


{  "type": "Program",  "div": [    {      "type": "IfStatement",      "test": {        "type": "Option",        "qNumber": 1,        "sNumber": undefined,        "oNumber": 1      },      "consequent": {        "type": "CallExpression",        "callee": "hide",        "arguments": [          {            "type": "Question",            "qNumber": 2          }        ]      }    }  ]}


文法设计中的注意点


由于问卷题目配置是实时更新的,包括题目、选项的 增加 删除 移动 都会导致索引发生变化,所以我们将 DSL 自定义编程代码存储到后端的时候,会将对应的 题目/选项 的索引替换成 ID 编号 ,也就是 内部 DSL 语言


例如:

Q1 => `Q_q-4337494791297303`Q1A1 => `Q_q-4337494791297303::A_a-8179104888736048`


当然这一过程也是通过 PEG.js 实现的,当自定义 DSL 代码回显到编辑器时,也会根据当前问卷的实时数据反解析成索引序列,也就是 外部 DSL 语言


例如:

`Q_q-4337494791297303` => Q1`Q_q-4337494791297303::A_a-8179104888736048` => Q1A1


因此,问卷中 问题、选项 的索引顺序变动并不会影响到 DSL 自定义编程逻辑的执行。

当然上面的例子为了呈现实现原理逻辑比较简单,实际产品中支持许多复杂的逻辑配置,例如:


# 如果Q1的选项个数和Q2的选项个数加起来等于5,则不展示Q3if (len Q1 + len Q2) == 5 then hide Q3
# 如果Q1选中A1 或 Q2选中A1,那么Q3自动选中A1if Q1A1 or Q2A1 then select Q3A1
# 如果Q1没有选中A1,那么Q3的A1选项禁止勾选if not Q1A1 then disable Q3A1


更多复杂的例子可以点击【阅读原文】参考我们的指导文档。


编辑器设计


由于问卷的编程端环境是运行在浏览器的,所以我们需要一款能在浏览器端运行的代码编辑器,通过调研,我们选出了3个比较合适的网页编辑器, Monaco Editor CodeMirror ACE ,并进行了简单比较:


功能点 Monaco Editor CodeMirror ACE
代码高亮
主题
✓(VS/VS dark)
✓(内置40+) ✓(内置20+)
语言支持
语言扩展
代码提示/自动补全
多光标操作
自动缩进
代码拆行
撤销/恢复
代码检查
diff
-
行数显示


三者从功能上对比差异不大,都能 cover 到本次编辑器设计中涉及到的所有需求点。


综合评估,团队最终选择 Monaco Editor 作为技术选型,主要考虑的以下几点:

  • 优秀的性能表现。

  • Monaco Editor VS Code 的底层实现。

  • 插件开发与 VS Code 类似,团队有相关开发经验。

  • 能覆盖已知的所有编码场景,例如代码高亮、代码补全提示、代码 hover 提示、代码错误诊断等。


基于业务场景,确定编辑器由两部分组成 language editor + language service。

  • language editor :运行在主线程,初始化一些自定义语言的配置,主要负责 ui 层的交互、渲染。

  • language service :运行在 worker 线程,负责逻辑相关的计算。


这种渲染层与逻辑层分离的结构设计,能够保证编辑器的用户操作不被打断,从而有更好的使用体验。




编辑器配置


1. 指定 worker 线程代码资源地址

由于生产环境和开发环境的静态资源地址路径不同,在 webpack 打包时注入了 webpack_public_path ,拼接出正确的资源地址。


window.MonacoEnvironment = {  getWorkerUrl: function (moduleId, label) {    const basePath = process.env.__webpack_public_path__ || '';    if (label === languageID) {      return `${basePath}dwLang.worker.js`;    }    return `${basePath}editor.worker.js`;  },};


2. 注册语言

monaco.languages.register :注册自定义语言的 ID,后续初始化编辑器、注册主题等都会用到该 ID。


languages.register({  id: languageID});


3. 语言配置

monaco.languages.setLanguageConfiguration: 可以进行语言缩进、折叠等功能的配置。


languages.setLanguageConfiguration(languageID, {  wordPattern: /(-?\d*\.\d\w*)|([^\`\!\@\#\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g,})


其他功能不需要,这里我们只配置 wordPattern。


提示:

  • 补全、hover 等功能中,我们都需要通过编辑器的 model 对象提供的 getWordAtPosition 方法获取对应位置的 word,但是这个 word 的分词是受 wordPattern 影响的。

  • 默认的 wordPattern 遇到特殊的字符,如“~”等就会进行分词,而我们的语法中会用到“~”符号。所以在这里配置了 wordPattern,将“~”符号去除。


4. 分词配置

monaco.languages.setMonarchTokensProvider: 关键词、函数等分词配置。

我们在 tokenizer 中定义了一个 root 属性,root 是 tokenizer 中的一个 state,我们在这里编写解析规则。

每一条规则中,我们可以编写匹配文本的正则表达式,然后再给匹配到的文本设置一个 token 类型 ,我们可以根据这个 token 类型给其添加相应的配色。


monaco.languages.sss(languageID, {  // 定义默认 token,当其他 token 规则匹配失败时,就定义为 invalid  defaultToken: 'invalid',  // The main tokenizer for our languages  tokenizer: {    root: [      // token 解析规则      [/#(.*)?/, 'noUseSign'],      [/\s*(Q[0-9]*(S[0-9]*)?(A[0-9]*)?(~[0-9]+)?)\s*/, 'Question'],      [/\s*(if|then)\s*/, 'IfStatement'],      [/\s*(show|hide)\s*/, 'action'],      [/\s*(> |== |>= |<= |<)\s*/, 'compareSign'],      [/\s*(and|or|not|\(|\))\s*/, 'linkSign'],      [/\s*([0-9]\d*)\s*/, 'Number'],      [/\s*(,)\s*/, 'Gap'],    ],  },  keywords: [    'if',    'then',    'and',    'or',    'not',    'show',    'hide',  ],  operators: ['>', '==', '>=', '<=', '<', 'and', 'or', 'not', '(', ')'],  whitespace: [    [/[ \t\r\n]+/, 'white'],    [/#(.*)/, 'comment', '@comment'],  ],})


5. 定义主题配色

monaco.editor.defineTheme: 用来定义编辑器的主题。

官方主题示例:

http://microsoft.github.io/monaco-editor/playground.html#customizing-the-appearence-exposed-colors


editor.defineTheme(languageID, {  base: 'vs-dark',  inherit: true,  rules: [    // token 配色    {token: 'noUseSign', foreground: '#BDBDBD'},    {token: 'Question', foreground: '#9BD7CA'},    {token: 'IfStatement', foreground: '#F3CD5E'},    {token: 'action', foreground: '#D38AFF'},    {token: 'compareSign', foreground: '#F3CD5E'},    {token: 'linkSign', foreground: '#F3CD5E'},    {token: 'Number', foreground: '#FF9878'},    {token: 'Gap', foreground: '#F3CD5E'},    {token: 'invalid', foreground: '#FFFFFF'},  ],  colors: {    // 编辑器背景等配色    'editor.background': '#282B33',    'editorLineNumber.background': '#33363E',    'editor.lineHighlightBackground': '#333844',  },});


6. 启动 service

monaco 提供了一个API: monaco.editor.createWebWorker 来使用内置的 ES6 Proxies 创建代理 Web worker,创建 worker 线程之后,将 worker 线程的代理传递给后续的错误诊断、代码补全等类,让其能调用 service。


7. 错误诊断

实现 DiagnosticsAdapter 类。


class DiagnosticsAdapter {  constructor(private worker: WorkerAccessor) {    // 注册生命周期事件    // model.onDidChangeContent,当前内容修改的事件,触发时进行 validate    // editor.onDidCreateModel,模型被创建的事件,注册  model.onDidChangeContent    // editor.onWillDisposeModel,模型被销毁前的事件,销毁  model.onDidChangeContent 注册的事件  }    private async validate(model: editor.IModel): Promise<void> {    // 调用 validation service,利用返回的信息进行划线和问题提示  }    // 销毁方法  public dispose(): void {    this._disposables.forEach(d => d && d.dispose());    this._disposables.length = 0;  }}


8. 代码补全

monaco.languages.registerCompletionItemProvider
通过 registerCompletionItemProvider 注册代码补全,需要实现 CompletionItemProvider 类。


class DwLangCompletionItemProvider implements languages.CompletionItemProvider {  constructor(  private readonly _worker: WorkerAccessor,   private readonly _triggerCharacters: string[],) {  }    public get triggerCharacters(): string[] {    // 触发补全的字符    return this._triggerCharacters;  }    async provideCompletionItems(  model: editor.IReadOnlyModel,   position: Position,   context: languages.CompletionContext,   token: CancellationToken,  ): Promise<languages.CompletionList | undefined> {    // 调用 Completion service,返回补全信息  }}


9. 代码 hover 提示

monaco.languages.registerHoverProvider
通过 registerHoverProvider 注册 hover 提示,需要实现 HoverProvider 类。


class DwLangHoverProvider implements languages.HoverProvider {  constructor(private readonly _worker: WorkerAccessor) {  }    async provideHover(  model: editor.IReadOnlyModel,   position: Position,   token: CancellationToken,  ): Promise<languages.Hover | undefined> {    // 调用 Hover service,返回提示文案  }}


10. 完整的代码示例

查看 D emo 源码:

http://github.com/hangaoke1/dwlang


遇到的问题及解决方案


问题:

当前的业务场景,注入的初始化信息(问卷数据)是会改变的,但是 monaco 目前没有提供 update worker 的初始化数据的方法。


解决方案:

我们需要在实现 worker、DiagnosticsAdapter(错误诊断) 等类时暴露 dispose 方法,通过 setupLanguage 向外暴露统一的 dispose 方法。在组件卸载时销毁 worker 等实例,在下次渲染时传入新的问卷数据,保证补全信息等是最新的数据。


const Editor: React.FC<IEditorProps> = () => {  const editorNodeRef = React.useRef();  const editorInsRef = React.useRef<editor.IStandaloneCodeEditor>(null);  const languageInsRef = React.useRef<IDisposable>(null);    React.useEffect(() => {    languageInsRef.current = setupLanguage({})        const editorNode = editorNodeRef.current    if (editorNode) {      // 创建编辑器      editorInsRef.current = editor.create(editorNode, {        language: languageID,        theme: languageID,        // 滚动条的缩略图        minimap: {enabled: false},        // 显示行号        selectOnLineNumbers: true,        // 行号左侧的 margin 内容,可以展示错误 icon 等一些标记内容        glyphMargin: true,        // 右键菜单        contextmenu: false,        // 自适应布局        automaticLayout: true,        padding: {          top: 8,          bottom: 8,        },        fontSize: 16,        lineNumbersMinChars: 2,        lineDecorationsWidth: '0px',        // 补全信息是否展示 icon        suggest: {          showIcons: false,        },        value: '',      });    }        return () => {      // 销毁语言的补全、hover、校验、worker 等      languageInsRef.current.dispose();      // 销毁编辑器      editorInsRef.current.dispose();    }  }, [])    return <div ref={editorNodeRef} style={{height: '90vh'}}/>;}



其他


如果想体验完整的 DSL 编程功能,欢迎【点击原文】体验网易云商问卷调研。



参考资料


  • 基于 Monaco Editor & LSP 打造智能 IDE - 掘金:

    http://juejin.cn/post/7108913087613829151

  • Monaco Editor:

    http://microsoft.github.io/monaco-editor/

  • 如何创造一门上万人使用的语言-腾讯问卷DSL实践之路:

    http://www.doc88.com/p-27247070453743.html?r=1

  • Monaco Editor:

    http://microsoft.github.io/monaco-editor/

  • monaco.languages.register:

    http://microsoft.github.io/monaco-editor/api/modules/monaco.languages.html#register

  • monaco.languages.setLanguageConfiguration:

    http://microsoft.github.io/monaco-editor/api/modules/monaco.languages.html#setLanguageConfiguration

  • monaco.languages.setMonarchTokensProvider:

    http://microsoft.github.io/monaco-editor/api/modules/monaco.languages.html#setMonarchTokensProvider

  • monaco.editor.defineTheme:

    http://microsoft.github.io/monaco-editor/api/modules/monaco.editor.html#defineTheme

  • monaco.editor.createW ebWorker:

    http://microsoft.github.io/monaco-editor/api/modules/monaco.editor.html#createWebWorker

    monaco.languages.registerCompletionItemProvider:

    http://microsoft.github.io/monaco-editor/api/modules/monaco.languages.html#registerCompletionItemProvider

  • monaco.languages.registerHoverProvider

    http://microsoft.github.io/monaco-editor/api/modules/monaco.languages.html#registerHoverProvider



网易 腾讯 return dsl 抽象
阅读 7
声明:本文内容由脉脉用户自发贡献,部分内容可能整编自互联网,版权归原作者所有,脉脉不拥有其著作权,亦不承担相应法律责任。如果您发现有涉嫌抄袭的内容,请发邮件至maimai@taou.com,一经查实,将立刻删除涉嫌侵权内容。