monaco编辑器

824 阅读27分钟

monaco

依赖

  • "monaco-editor": "^0.45.0",

  • "antlr4":语法校验

  • sql

    • "sql-formatter": "^15.1.2",
    • "dt-sql-parser": "^4.0.0-beta.4.11",
  • vue3/vite

    • "vite-plugin-node-polyfills": "^0.19.0",

初始化

 <template>
     <div id="editor-box" :style="width: width, height: height"></div>
 </template>
 <script>
 import * as monaco from 'monaco-editor'
 export default {
   name: 'VMonacoEditor',
   props: {
     width: String, // 宽度
     height: String, // 高度
     editorOptions: Object // 编辑器初始化属性配置
   },
   mounted() {
     this.init()
   },
   methods: {
     init() {
       const editorOptions = {
         //编辑器初始显示代码
         value: this.code,
         language: 'javascript',
         // 语言支持自行查阅demo
         automaticLayout: true,//自动布局
         theme: 'vs-black',
         //官方白带三种主题vS, hc-btack, or vs-dark
         tabsize: 2,
         ...this.editorOptions
       }
       // 初始化编辑器,确保dom已经渲染
       this.editor = monaco.editor.create(document.getelementById('editor-box'), editorOptions)
     }
   }
 }
 </script>
 ​

不带元素创建

this.editorInstance = monaco.editor.createModel(this.codeInfo.oldCode);

周边配置

状态栏展示部分

常见的一些配置比如 缩进、空格、行列选择、语言、换行模式、文件大小等信息,比如缩进:

配置展示

在状态栏展示部分:

const IndentWidget = () => {
  let state = useNovus<TState>((models) => {
    const activeId = models.layout.state.layout.dealer.activeId;
    return {
      activeId: activeId,
      activeTab: models.writer.state.tabs[activeId],
    };
  }, ['workspace', 'writer'])

  const model = modelsHolder.models[state.activeId];
  if (!model || !state.activeTab || state.activeTab.type !== 'text') return null;
  const options = model.model.getOptions();
  const space = options.insertSpaces;
  const tabSize = options.tabSize;

  return <Tooltip title="修改缩进">
    <CommandBtn command="writer.changeIndent">
      {`${space ? '空格' : 'Tab'}: ${space ? tabSize : null}`}
    </CommandBtn>
  </Tooltip>
}

quickOpen

[状态栏点击修改

点击的命令,支持使用 monaco-editor 自带的 quickOpen 功能展示配置选项:

import { getModel } from '.';

const { QuickOpenModel, QuickOpenEntryGroup } = window.require('vs/base/parts/quickopen/browser/quickOpenModel');
const { BaseEditorQuickOpenAction } = window.require('vs/editor/standalone/browser/quickOpen/editorQuickOpen');
const { matchesFuzzy } = window.require('vs/base/common/filters');
const { Range } = window.require('vs/editor/common/core/range');

type TActionOption = {
  withBorder?: boolean
  group?: string
}

function getIndentationEditOperations(model: monaco.editor.ITextModel, tabSize: number, tabsToSpaces: boolean) {
  if (model.getLineCount() === 1 && model.getLineMaxColumn(1) === 1) { return; }
  let spaces = ' '.repeat(tabSize);
  let spacesRegExp = new RegExp(spaces, 'gi');
  let operations = [];

  for (let lineNumber = 1, lineCount = model.getLineCount(); lineNumber <= lineCount; lineNumber++) {
    let lastIndentationColumn = model.getLineFirstNonWhitespaceColumn(lineNumber) || model.getLineMaxColumn(lineNumber);
    if (lastIndentationColumn === 1) continue;

    const originalIndentationRange = new Range(lineNumber, 1, lineNumber, lastIndentationColumn);
    const originalIndentation = model.getValueInRange(originalIndentationRange);
    const newIndentation = (tabsToSpaces ? originalIndentation.replace(/\t/ig, spaces) : originalIndentation.replace(spacesRegExp, '\t'));
    operations.push({
      range: originalIndentationRange,
      text: newIndentation
    });
  }
  model.applyEdits(operations);
}

class DemoActionCommandEntry extends QuickOpenEntryGroup {
  constructor(highlights: any, editor: monaco.editor.IStandaloneCodeEditor, option: TActionOption) {
    super();
    this.editor = editor;
    this.option = option;
    this.withBorder = option.withBorder;
    return this;
  }
  getLabel =  () => this.option.label;
  getAriaLabel =  () => this.option.label;
  getDetail =  () => this.option.desc;
  getGroupLabel =  () => this.option.group || '';

  run = (mode: number) => {
    if (mode === 1 /* OPEN */) {
      var model = getModel(this.editor);

      if (this.option.operate[0] === 0) {
        if (this.option.operate[1] === 0) {
          model.updateOptions({
            tabSize: this.option.operate[2],
            insertSpaces: true
          });
        } else {
          model.updateOptions({
            insertSpaces: false
          });
        }
      } else {
        let modelOpts = model.getOptions();
        // 转换 model, builder, tabSize, tabsToSpaces
        getIndentationEditOperations(model, modelOpts.tabSize, this.option.operate[1] === 0)
      }

      return true;
    }
    return false;
  };
}

export class IndentCommandAction extends BaseEditorQuickOpenAction {
  constructor() {
    super("ubug: 修改缩进", {
      id: 'editor.ubug.changeIndent',
      label: "ubug: 修改缩进",
      alias: 'ubug: 修改缩进',
      // menuOpts: {}
      menuOpts: null
    })
    return this;
  }
  run = function (accessor: any, editor: monaco.editor.IStandaloneCodeEditor) {
    this._show(this.getController(editor), {
      getModel: function (value: string) {
        var entries = [
          {
            label: '使用 2 空格缩进',
            group: '视图',
            desc: 'Indent Using 2 Spaces',
            operate: [0, 0, 2] // insertSpaces, space nums
          },
          {
            label: '使用 4 空格缩进',
            desc: 'Indent Using 4 Spaces',
            operate: [0, 0, 4]
          },
          {
            label: '使用 Tab 缩进',
            desc: 'Indent Using Tab',
            operate: [0, 1, 2]
          },
          {
            label: '使用空格转换已有内容',
            group: '转换',
            desc: 'Indent Using 2 Spaces',
            operate: [1, 0], // insertSpaces, space nums
            withBorder: true
          },
          {
            label: '使用 Tab 转换已有内容',
            desc: 'Indent Using Tab',
            operate: [1, 1]
          },
        ];
        var models = entries.filter(e => (matchesFuzzy(value, e.label) || matchesFuzzy(value, e.desc))).map((e) => {
          return new DemoActionCommandEntry(value, editor, e);
        })
        return new QuickOpenModel(models);
      },
      getAutoFocus: function (searchValue: string) {
        return {
          autoFocusFirstEntry: searchValue.length > 0,
          autoFocusIndex: getModel(editor).getOptions().defaultEOL - 1
        };
      }
    });
  };
}

这个相当于是将怎么在 monaco-editor 的菜单中添加自定义的选项列表,不需要自己写具体的逻辑,功能的提供是 monaco-editor 自带的,非常有趣。

其他的比如 语言选取、编码模式都可以用这种方式扩展。

终端集成

ubug.io/blog/workpa…

command

import { getSpecialType } from "./specialEditors";
import Immutable from "seamless-immutable";
import { TFile, TFileRaw } from "../FileManager/model";
export const commands = {
  gotoLine: {
    trigger: () => {
      // ...
    }
  },
  changeIndent: {
    trigger: () => {
      // ...
    }
  },
  chooseLanguage: {
    trigger: () => {
      // ...
    }
  },
  showSourceCode: {
    label: "查看源代码",
    trigger: () => {
      // ...
    }
  },
  openFile: {
    trigger: async (
      fileRelative: string,
      cb?: Function,
      skipPreview?: boolean
    ) => {
        window._novus.models["writer"].actions.previewConfirm(fileId);
        // or ...
        await window._novus.models.layout.actions.activePanel(fileId);
        // or ...
        window._novus.models["writer"].actions.openTab(file, skipPreview);
      }
    }
  },
  createUntitledFile: {
    key: ["ctrl+n"],
    label: "新建文件",
    trigger: () => {
      // ...
    }
  }
};

DirtyDiff

ubug.io/blog/workpa…

API

示例事件

github.com/Microsoft/v…

生命周期

onDidChangeContent
onLanguage事件

当一种语言首次与文本模型相关联时发出的事件。以在语言配置就绪时调用该回调

monaco

setModelMarkers标记
  // 标记错误信息
  markMistake(range: any, type: string, message: any) {
    const { startLineNumber, endLineNumber, startColumn, endColumn }: any = range;
    console.log(1, monaco.editor, monaco.MarkerSeverity[type]);
    monaco.editor.setModelMarkers(
      this.editorInstance.getModel(),
      'eslint',
      [{
        startLineNumber,
        startColumn,
        endLineNumber,
        endColumn,
        // Hint = 1,
        // Info = 2,
        // Warning = 4,
        // Error = 8
        severity: monaco.MarkerSeverity[type], // type可以是Error,Warning,Info
        message: '123'
      }]
    )

    // const issues = monacoLinter.getEditorMarks(this.editorInstance);
  }
自定义token
monarchTokens: {
  tokenizer: {
    root: [
   	 [/[(.+?)]/, 'custom-point'],
    ]
  }
}


monaco.languages.setMonarchTokensProvider(languageConfig.name, languageConfig.monarchTokens);

state

撤销
editor.trigger("myapp", "undo");//触发撤销
editor.trigger("myapp", "redo");//触发重做
修改属性
editor.updateOptions({ fontSize: 20 }):修改属性
action
默认actions
以下是 Monaco Editor 的默认 actions 列表,每个 action 都标注了英文名称和中文解释:

editor.action.addCommentLine:添加行注释(Toggle Line Comment)
editor.action.addCursorsToBottom:在文件尾部添加光标(Add Cursors To Bottom)
editor.action.addCursorsToTop:在文件开头添加光标(Add Cursors To Top)
editor.action.addSelectionToNextFindMatch:选取当前匹配后的下一个匹配项(Add Selection To Next Find Match)
editor.action.addSelectionToPreviousFindMatch:选取当前匹配前的上一个匹配项(Add Selection To Previous Find Match)
editor.action.centerLineInViewport:将当前行垂直居中(Center Line In Viewport)
editor.action.clipboardCopyAction:复制当前选中内容到剪切板(Copy)
editor.action.clipboardCutAction:剪切当前选中内容到剪切板(Cut)
editor.action.clipboardPasteAction:从剪切板中粘贴内容(Paste)
editor.action.commentLine:对选中文本添加或取消行注释(Toggle Line Comment)
editor.action.copyLinesDownAction:复制当前行并粘贴到下一行(Copy Lines Down)
editor.action.copyLinesUpAction:复制当前行并粘贴到上一行(Copy Lines Up)
editor.action.createCursorUndo:撤销所有光标修改(Undo Cursor)
editor.action.cursorColumnSelectDown:向下纵向区域选择行(Cursor Column Select Down)
editor.action.cursorColumnSelectLeft:向左纵向区域选择该行字符(Cursor Column Select Left)
editor.action.cursorColumnSelectPageDown:向下以页面为单位进行纵向区域选择(Cursor Column Select Page Down)
editor.action.cursorColumnSelectPageUp:向上以页面为单位进行纵向区域选择(Cursor Column Select Page Up)
editor.action.cursorColumnSelectRight:向右纵向区域选择该行字符(Cursor Column Select Right)
editor.action.cursorColumnSelectUp:向上纵向区域选择行(Cursor Column Select Up)
editor.action.cursorDown:将光标向下移动(Cursor Down)
editor.action.cursorEnd:将光标定位到行末尾(Cursor End)
editor.action.cursorHalfPageDown:向下移动半个页面距离(Cursor Half Page Down)
editor.action.cursorHalfPageUp:向上移动半个页面距离(Cursor Half Page Up)
editor.action.cursorHome:将光标定位到行首(Cursor Home)
editor.action.cursorLeft:将光标向左移动(Cursor Left)
editor.action.cursorPageDown:向下翻页(Cursor Page Down)
editor.action.cursorPageUp:向上翻页(Cursor Page Up)
editor.action.cursorRight:将光标向右移动(Cursor Right)
editor.action.cursorTop:将光标定位到文件开头(Cursor Top)
editor.action.cursorUp:将光标向上移动(Cursor Up)
editor.action.deleteLines:删除当前行或选中的多行(Delete Lines)
editor.action.deleteAllLeft:删除光标到行首的所有内容(Delete All Left)
editor.action.deleteAllRight:删除光标到行尾的所有内容(Delete All Right)
editor.action.deleteLeft:删除光标前的字符(Delete Left)
editor.action.deleteRight:删除光标后的字符(Delete Right)
editor.action.duplicateSelection:复制并插入一份当前选中内容(Duplicate Selection)
editor.action.editor.action.insertSnippet:插入代码片段(Insert Snippet)
editor.action.filterActions:将行为过滤器或快捷键过滤器应用于列表(Filter Actions)
editor.action.goToDeclaration:跳转到变量声明处(Go to Declaration)
editor.action.goToImplementation:跳转到变量实现处(Go to Implementation)
editor.action.goToTypeDefinition:查找类型定义(Go to Type Definition)
editor.action.insertCursorAbove:插入一个向上的光标(Insert Cursor Above)
editor.action.insertCursorAtEndOfEachLineSelected:插入在每行末尾的光标(Insert Cursor at End of Each Line Selected)
editor.action.insertCursorBelow:插入一个向下的光标(Insert Cursor Below)
editor.action.insertLineAfter:在当前行下面插入一行(Insert Line After)
editor.action.insertLineBefore:在当前行上面插入一行(Insert Line Before)
editor.action.indentLines:将选中行缩进(Indent Lines)
editor.action.indentUsingSpaces:使用空格进行缩进(Indent Using Spaces)
editor.action.intersectSelections:保留所有光标的交集,取消其余光标(Intersect Selections)
editor.action.moveLinesDownAction:将选中的行向下移动一行(Move Lines Down)
editor.action.moveLinesUpAction:将选中的行向上移动一行(Move Lines Up)
editor.action.moveSelectionToNextFindMatch:将光标移到下一个匹配项处,并取消选择(Move Selection to Next Find Match)
editor.action.moveSelectionToPreviousFindMatch:将光标移到上一个匹配项处,并取消选择(Move Selection to Previous Find Match)
editor.action.moveToCenter:将光标定位到屏幕中央(Move To Center)
editor.action.navigateToNextError:跳转到下一个错误(Go to Next Error)
editor.action.navigateToPreviousError:跳转到上一个错误(Go to Previous Error)
editor.action.newLineAbove:在当前行上方新建一行(New Line Above)
editor.action.newLineBelow:在当前行下方新建一行(New Line Below)
editor.action.nextMatchFindAction:查找下一个匹配项(Next Match Find)
editor.action.outdentLines:将选中行的缩进减少(Outdent Lines)
editor.action.outdentUsingSpaces:使用空格减少缩进(Outdent Using Spaces)
editor.action.pasteSelection:粘贴文本并替换当前选中内容(Paste Selection)
editor.action.quickFix:快速修复错误(Quick Fix)
editor.action.quickOutline:打开当前文件的大纲视图(Quick Outline)
editor.action.redo:重做最近一次取消操作(Redo)
editor.action.referenceSearch.trigger:查找该变量的所有引用(Find All References)
editor.action.removeCommentLine:移除行注释(Toggle Line Comment)
editor.action.replaceAll:在全局范围内查找并替换所有匹配项;(Replace All);通常使用 Command+Shift+H 快捷键

model

获取语言
const languageID = this.editorInstance.getModel()?.getLanguageId();
//languageID === 'html'
设置语言
monaco.editor.setModelLanguage(this.editor.getModel(), val || 'javascript')

编程语言

扩展

LSP

ubug.io/blog/workpa…

lsp:microsoft.github.io/language-se…

内置语言

对已有的语言配置

JS/TS/CSS/HTML 是内置

// validation settings
monaco.languages.typescript.javascriptDefaults.setDiagnostics0ptions({
  noSemanticValidation: true,
  noSyntaValidation: true
})
// compiler options
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
  target: monaco.languages.typescript.ScriptTarget.ES2017,
  strict: true,
  allowNonTsExtensions: true
})

代码补全

monaco-editor 本身已经具备了常见的代码补全,比如 window 变量,dom,css 属性等。但是并未提供node_modules 中的代码补全,比如最常见的 react,没有提示,体验会差很多。

经过调研,monaco-editor 可以提供代码提示的入口至少有两个 api:

  1. registerCompletionItemProvider,需要自定义触发规则及内容
  2. addExtraLib,通过添加 index.d.ts,使得在自动输入的时候,提供由 index.d.ts 解析出来的变量进行自动补全。

第一种方案网上的文章较多,但是对于实际的需求,导入 react, react-dom,如果采用此种方案,就需要自行完成对 index.d.ts 的解析,同时输出类型定义方案,在实际使用时非常繁琐,不利于后期维护。

registerCompletionItemProvider
monaco.languages.registerCompletionItemProvider(languageConfig.name, {
  //   triggerCharacters: ['$'],
  //   replaceTriggerChar: true,
  provideCompletionItems: function () {
    // { label: any; kind: monaco.languages.CompletionItemKind; insertText: any }[]
    let newSuggestions: any = [];
    Object.keys(suggestions as any).forEach((item: any) => {
      newSuggestions.push(...suggestions[item]);
    })
    return {
      suggestions: newSuggestions.map((item: any) => {
        return ({
          label: item.label ? item.label : item,// 显示的提示内容
          kind: item?.kind ? item.kind : monaco.languages.CompletionItemKind.Function,// 用来显示提示内容后的不同的图标
          insertText: item.label ? item.label : item, // 选择后粘贴到编辑器中的文字
          //       detail: '123', // 提示内容后的说明
          //       insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
        });
      })
    };
  },
});
  • suggestion提示重复

    //monaco.languages.registerCompletionItemProvider赋值,调用dispose
    this.suggestInstance = monaco.languages.registerCompletionItemProvider(languageConfig.name, {});
    this.suggestInstance.dispose()
    
addExtraLib
//addExtraLib添加库
let libSource: any;
await fetch('./index.d.ts', {
  mode: 'no-cors',
})
  .then(response => response.text()).then(response => {
    libSource = response
  })
monaco.languages.typescript.javascriptDefaults.addExtraLib(libSource, 'index.d.ts');

monarch语法高亮

github.com/wanglin2/fr…

Monarch 是 Monaco Editor 自带的一个语法高亮库,通过它,我们可以用类似 Json 的语法来实现自定义语言语法高亮功能

tokenizer
monaco.languages.setMonarchTokensProvider('log', {
	tokenizer: {
		root:[
			[/\d+/,{token:"keyword"}],
			[/[a-z]+/,{token:"string"}]
		],
	}
});

在 root 中设置了两个 rule ,分别用来匹配数字和字母,匹配成功后就接着执行对应的 action 在 action 中,我们设置了匹配文本的 token class :keyword和string

img

[regex, action]

我们在 tokenizer 中定义了一个 root 属性,roottokenizer 中的一个 state , 这就是我们用来编写解析规则(rule)的地方,在 rule 中,我们可以编写匹配文本的正则表达式,然后再给匹配到的文本设置一个执行动作的 action ,在 action 中,我们可以给匹配到的文本设置 token class

本质上,token class 其实就是设置 css 中的 class

Monarch 中内置了以下几种 token class:

identifier         entity           constructor
operators          tag              namespace
keyword            info-token       type
string             warn-token       predefined
string.escape      error-token      invalid
comment            debug-token
comment.doc        regexp
constant           attribute

delimiter .[curly,square,parenthesis,angle,array,bracket]
number    .[hex,octal,binary,float]
variable  .[name,value]
meta      .[content]
Rules规则

每个状态定义为一个用于匹配输入的规则数组,规则可以有如下形式:

[regex, action]

{regex: regex, action: action}形式的简写。

[regex, action, next]

{ regex: regex, action: action{next: next} }形式的简写。

{regex: regex, action: action }

regex与当前输入匹配时,将引用action来设置token类,正则表达式regex可以是一个标准的正则表达式(使用/regex/),也可以是一个代表正则表达式的字符串。如果以^字符开头,那么该表达式只匹配源行的开头,$则反过来匹配行尾。注意,已经到达行尾时,tokenizer将不会被调用,因为,空模式/$/将永远不会匹配(但也请参阅@eos)。在正则表达式中,可以将名为attr的字符串属性引用为@attr,该属性会自动展开。这在标准示例中用于在字符和字符串的正则表达式中使用@escapes共享转义序列的正则表达式。

正则表达式入门:我们使用的常见的正则表达式转义是\d代表[0-9]\w代替[a-zA-z0-9_],以及\s代表[ \t\r\n]。符号regex{n}表示regex出现n次。同样,我们使用(?=regex)来表示非消费后面跟着regex(?!regex)表示后面跟着的不是regex,以及(?:regex)表示一个非捕获的组(也就是说,不能使用$n来引用它)。

{ include:state }

用于对规则进行良好的组织,并扩展到定义在state中的所有规则。这是预先展开的,对性能没有影响。比如许多的例子里都包括@whitespace状态。

Actions操作

一个action决定了生成的token类,一个action有以下形式:

string

{ token: string }的简写形式。

[action1,...,actionN]

多个action组成的数组。这仅在正则表达式恰好由N个组(即括号部分)组成时才允许。由于tokenizer的工作方式,你必须以所有的组都出现在顶层并包含整个输入的方式来定义组,举个例子,可以将ascii码转义序列定义为:

/(')(\(?:[abnfrt]|[xX][0-9]{2}))(')/, ['string','string.escape','string']]

注意我们是如何在内部组中使用一个(?:)来表示一个非捕获的组。

{ token: tokenclass }

定义用于css渲染的token类的对象。常见的token类有keywordcommentidentifier。你可以用一个.来使用分级的css名称,比如type.identifier或者string.escape。你还可以使用$模式,这些模式会被来自匹配的输入或者tokenizer状态的捕获组替换,本文档的guard section部分描述了这些模式。下面是一些特殊的token类:

"@brackets""@brackets.tokenclass"

表示括号被标记。csstoken类由括号属性中定义的token类确定(如果存在,则与tokenclass一起)。此外,设置括号属性使编辑器匹配大括号(并自动缩进)。举个例子:

[/[{}()[]]/, '@brackets']

"@rematch"

(高级)备份输入并重新调用tokenizer。这只在状态发生变化时才有效(或者我们进入了无限的递归),所以这个通常和next属性一起使用。例如,当你处于特定的tokenizer状态,并想要在看到某些结束标记时退出,但是不想在处于该状态时使用它们,就可以使用这个。另见nextEmbedded

一个action对象可以包含影响词法分析器状态的更多字段。请看下列属性:

next: state

(字符串)如果已定义,则将当前状态推入tokenizer栈,并使state成为当前状态。例如,这可以用于开始标记一个注释块:

['/\*', 'comment', '@comment' ]

请注意这是下面的简写:

{ regex: '/\*', action: { token: 'comment', next: '@comment' } }

这里匹配到的/*被赋予了commenttoken类,tokenizer使用@comment状态中的规则继续匹配输入。

这里有一些特殊的状态可以被next属性使用:

"@pop"

弹出tokenizer栈以返回到之前的状态。例如,这会用于在看到结束标记后从块注释标记返回:

['\*/', 'comment', '@pop']

"@push"

推入当前状态,并在当前状态中继续。很适合在看到注释开始标记时进行嵌套块注释,即在@comment状态时,我们可以执行以下操作:

['/\*', 'comment', '@push']

"@popall"

tokenizer栈中弹出所有状态,并回到顶部的状态。这可以在恢复期间从一个深度嵌套级别“跳”回初始状态。

switchTo: state

(高级)切换到state而不改变堆栈。

goBack: number

(高级)按number字符备份输入。

bracket: kind

(高级)kind可以是@open@close。这表示一个token是开括号还是闭括号。如果token类是@brackets会自动设置此属性。编辑器使用括号信息来显示匹配的括号(如果它们的token类相同,则左括号和右括号匹配)。当用户新开一行时,编辑器将在大括号上自动进行缩进。通常,如果你使用括号属性,则不需要设置此属性,它仅用于复杂的大括号匹配。这会在下一个章节advanced brace matching中讨论。

nextEmbedded: langId or '@pop'

(高级)向编辑器表示此token后面跟着由langId指定的另一种语言的代码。例如对于javascript,在内部,我们的语法高亮器继续标记源代码,直到它找到一个结束序列。此外,你可以使用nextEmbedded和一个@pop值再次弹出嵌入模式。nextEmbedded通常需要和next属性配合切换到可以标记外部代码的状态。作为一个例子,下面是如何在我们的语言中支持css片段:

root: [
  [/<style\s*>/,   { token: 'keyword', bracket: '@open'
                   , next: '@css_block', nextEmbedded: 'text/css' }],

  [/</style\s*>/, { token: 'keyword', bracket: '@close' }],
  ...
],

css_block: [
  [/[^"<]+/, ''],
  [/</style\s*>/, { token: '@rematch', next: '@pop', nextEmbedded: '@pop' }],
  [/"/, 'string', '@string' ],
  [/</, '']
],

注意我们是如何切换到css_block状态来标记css源代码的。同样,在css中我们使用@string状态来标记css字符串,这样当我们在字符串中发现</style>时我们不会停止css块。当我们发现结束标签时,我们也可以使用@pop去回到正常的标记过程。最后,我们需要使用@rematch这个token(在root状态),因为编辑器会忽略我们的token类直到我们真正退出嵌入模式。更多请阅读后面的complex dynamic embeddings部分内容。

log: message

用于调试。将message输出到浏览器的窗口控制台(按F12查看)。这个在查看某个action是否正在执行时非常有用。举个例子:

[/\d+/, { token: 'number', log: 'found number $0 in state $S0' } ]

{ cases: { guard1: action1, ..., guardN: actionN} }

最后一种action对象是case语句。一个case对象包含一个对象,其中每一个字段都充当一个守卫。每个守卫都应用于匹配的输入,只要其中一个匹配,就应用相应的action。注意,因为这些是action本身,case可以嵌套。case是为了提升效率:举个例子,我们匹配标识符,然后检测标识符是否可能是一个关键字或一个内置函数:

[/[a-z_$][a-zA-Z0-9_$]*/,
  { cases: { '@typeKeywords': 'keyword.type'
           , '@keywords': 'keyword'
           , '@default': 'identifier' }
  }
]

守卫包括:

  • "@keywords"

    keywords属性必须在语言对象中定义,并且是一个字符串数组。如果匹配的输入与任何一个字符串匹配,则守卫成功。(注意:所有case都是预编译的,列表使用的是高效的hash映射进行匹配)。(高级):如果属性引用的是一个单独的字符串(而不是数组),则会把它编译成一个正则表达式,并根据匹配的输入进行测试。

  • "@default"

    "@""")默认守卫,总是会守卫到。

  • "@eos"

    如果匹配的输入已经到达行尾,那么则守卫成功。

  • "regex"

    如果守卫不以@$字符开头,那么它会被解释为一个针对匹配的输入进行测试的正则表达式。注意:regex^开头和$结尾,所以它必须匹配整个被匹配的输入。例如,这可以用于测试特定的输入,这里有一个来自Koka语言的例子,它使用这个基于声明进入各种tokenizer状态:

    [/[a-z](\w|-[a-zA-Z])*/,
      { cases:{ '@keywords': {
                   cases: { 'alias'              : { token: 'keyword', next: '@alias-type' }
                          , 'struct'             : { token: 'keyword', next: '@struct-type' }
                          , 'type|cotype|rectype': { token: 'keyword', next: '@type' }
                          , 'module|as|import'   : { token: 'keyword', next: '@module' }
                          , '@default'           : 'keyword' }
                }
              , '@builtins': 'predefined'
              , '@default' : 'identifier' }
      }
    ]
    

请注意使用嵌套case来提升效率。此外,库还能识别上述简单的正则表达式,并高效的编译他们。举个例子,单词列表type|cotype|rectype会使用一个javascripthash映射或对象来测试。

(高级)一般来说,守卫的形式是[pat][op]match,有一个可选的模式,和操作符(默认为$#~)。模式pat可以是下面的一种:

$#

(默认)匹配的输入(或者当action是数组时匹配到的组)。

$n

匹配输入的第n组,或者是$0代表这个匹配的输入。

$Sn

状态的第n个部分,比如,$S2返回状态@tag.foo中的foo。使用$0代表整个状态名。

上述的模式实际上可以出现在许多属性中,并且可以自动展开。这些模式展开的属性有tokennextnextEmbeddedlog。此外,这些模式在守卫的match部分进行了扩展。

守卫的操作opmatch可以是:

~regex or !~regex

op默认为~)针对正则表达式或其否定项进行测试pat

@attribute or !@attribute

Tests whether *pat* is an element (@), or not an element (!@), of an array of strings defined by *attribute*.

测试pat是由attribute定义的字符串数组的元素是一个元素@,或者不是一个元素!@

==str or !=str

测试pat和给定的字符串str是否相等。

自定义log文本颜色

[error], [info], [warning] 作为一行的开头,从而代表日志的级别。

tokenizer: {
	root: [
		[/^[error]/, { token: "custom-error" }],
		[/^[info]/, { token: "custom-info" }],
		[/^[warning]/, { token: "custom-warning" }]
	]
}

//设置含有custom-error等token class的主题
monaco.editor.defineTheme('logTheme', {
    base: 'vs',
    inherit: true,
    rules: [
        { token: 'custom-info', foreground: '808080' },
        { token: 'custom-error', foreground: 'ff0000', fontStyle: 'bold' },
        { token: 'custom-warning', foreground: 'FFA500' }
    ]
});
monaco.editor.create(document.getElementById("container"), {
    theme: 'logTheme',
    value: getCode(),
    language: 'log'
});

img

我们写了三条 rule ,分别将 [error] 标记为 custom-error[info] 标记为 custom-info[warning] 标记为 custom-warning 。我们发现,这些 rule 都是类似的,所以,我们可以想办法把他们合在一起。

tokenizer: {
	root: [
		[/^[(\w+)]/, { token: "custom-$1" }]
	]
}

这里我们用到了一个美元符号 $,它代表取正则表达式第几个匹配项,$0代表取所有的匹配项(例:[error]),$1 代表取第一个匹配项(例:error)。上述代码将日志类型作为参数传入了 token class ,与 custom- 做拼接,从而组成了最终的 token class,例如 custom-error

自定义语言

betterprogramming.pub/create-a-cu…

编辑器的总体架构

img

从上面的架构中我们可以看到,一般来说,任何编辑器中都有两个线程。

  • 其中一个负责 UI 事务,例如等待用户输入一些代码或执行一些操作。
  • 另一个线程接受用户所做的更改并进行繁重的计算,其中包括代码解析和其他编译内容。

对于编辑器中的每一次更改(可以是用户键入的每一个字符,也可以是直到用户停止键入2秒为止),都会向语言服务工作线程发送一条消息以执行某些操作。进程将回复一条包含结果的消息。例如,当用户键入一些代码并想要格式化代码(单击 Shift + Alt + F)时,工作人员将收到一条包含操作“Format”和要格式化的代码的消息。这应该异步发生以获得良好的用户体验。

另一方面,语言服务负责解析代码,生成抽象语法树(AST),查找任何可能的语法或词汇错误,使用AST查找任何语义错误,格式化代码等。

img

Monaco 提供了一个 API monaco.editor.createWebWorker来使用内置ES6 Proxies创建代理 Web Worker 。使用getProxy方法获取代理对象(语言服务)。要访问语言服务工作者中的任何服务,我们将使用此代理对象来调用任何方法。所有方法都会返回一个 Promise 对象。

ANTLR

ANTLR(ANother Tool for Language Recognition)是一个强大的语法分析生成器,用于读取、处理、执行或翻译结构化文本或二进制文件。它被广泛用于构建语言、工具和框架。根据语法,ANTLR生成了一个可以构建和遍历解析树的解析器。

从语法角度来看,ANTLR生成了一个可以构建和遍历解析树的语法分析器。”ANTLR支持许多语言作为目标,这意味着它可以用Java、C#和其他语言生成解析器

npm add antlr4 -D
//ANTLR4TS是 ANTLR 的 Node.js 版本
  • Parser语法
  • Lexer词法
demo
  1. 依赖

    //package.json
    "scripts": {
    "test": "jest",
    "antlr4ts-mysql": "antlr4ts -Xexact-output-dir -o src/grammar-output/mysql -package parsers src/grammars/mysql/MySQLLexer.g4 src/grammars/mysql/MultiQueryMySQLParser.g4"
    },
    "dependencies": {
    	"antlr4ts": "^0.5.0-alpha.4"
    },
    "devDependencies": {
      "@types/jest": "^26.0.20",
      "antlr4ts-cli": "^0.5.0-alpha.4",
      "jest": "^26.6.3",
      "ts-jest": "^26.5.3",
      "typescript": "^4.2.3"
    }
    
  2. Antlr4 语法文件

    github.com/antlr/gramm… 编写的形式语法的集合

    我们使用 Antlr4 来实现一个基本的 SQL Parser。Antlr4 是一个强大的解析器生成器,它能根据用户自定义的语法文件来生成对应的解析器。Antlr4 的语法文件为 .g4文件,内部可以包含多条规则,规则可以分为词法规则和语法规则,词法规则用于生成词法分析器,语法规则用于生成语法解析器。

  3. 执行npm run antlr4ts-mysql 解析语法文件

    image-20240201173605612

    "antlr4ts-mysql": "antlr4ts -Xexact-output-dir -o src/grammar-output/mysql -package parsers src/grammars/mysql/MySQLLexer.g4 src/grammars/mysql/MultiQueryMySQLParser.g4"
    //TODO:这里没有生成base文件,具体原因不清楚
    
  4. jest执行测试: npm run test

    需要配置ts,babel。因为es6不能识别

    //jest.config.js
    module.exports = {
      preset: 'ts-jest',
      testEnvironment: 'node',
    };
    //tsconfig.json
    {
      "compilerOptions": {
        "module": "commonjs",
        "target": "es2019",
        "moduleResolution": "node",
        "esModuleInterop": true,
        "resolveJsonModule": true,
        "sourceMap": true,
        "allowJs": false,
        "incremental": true,
        "declaration": true,
        "outDir": "dist",
        "experimentalDecorators": true,
        "baseUrl": "./src",
        "paths": {
          "@/*": ["./*"]
        },
        "strictPropertyInitialization": false
      },
      "include": ["**/*.ts"],
      "exclude": ["node_modules", "dist", "**/*.d.ts", "test", "parse.ts"]
    }
    
    // ./test/mysql.test.ts
    import { CommonTokenStream, CharStreams, ParserErrorListener } from "antlr4ts";
    import { MySQLLexer } from "../src/grammar-output/mysql/MySQLLexer";
    import { MultiQueryMySQLParser } from "../src/grammar-output/mysql/MultiQueryMySQLParser";
    
    //使用 ParserErrorListener 收集错误信息
    class SelectErrorListener implements ParserErrorListener {
      private _parserErrorSet: Set<any> = 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);
      }
    }
    
    class SelectParser {
      private _errorListener = new SelectErrorListener();
    
      createLexer(input: string) {
        const inputStream = CharStreams.fromString(input);
        const lexer = new MySQLLexer(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 MultiQueryMySQLParser(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,
        };
      }
    }
    
    test("can parse and tokenize a query", () => {
      // // 试一下效果
      const selectParser = new SelectParser();
      // const parseTree = selectParser.parse("SELECT * FROM table1");
      const parseTree = selectParser.parse("SELECT ;");
      console.log(parseTree);
    });
    
worker

Monaco 提供了一个 API monaco.editor.createWebWorker来使用内置ES6 Proxies创建代理 Web Worker 。使用getProxy方法获取代理对象(语言服务)。要访问语言服务工作者中的任何服务,我们将使用此代理对象来调用任何方法。所有方法都会返回一个 Promise 对象。

this.worker = monaco.editor.createWebWorker<TodoLangWorker>({
  moduleId: 'vs/language/vue/TodoLangWorker',
  label: this.languageID,
  //通过postMessage传递给子线程,self.onmessage中monacoWorker.initialize中获取createData
  createData: {
    languageId: this.languageID,
  }
});
this.workerClientProxy = <Promise<TodoLangWorker>><any>this.worker.getProxy();
onmessage

主进程和worker进程通信

self.onmessage = (e) => {
  console.log(669, e, monacoWorker.initialize);
};
AviatorScript语言封装

参考:

image-20240408173951637

ANTLR生成parse
import { TodoLangGrammarParser, TodoExpressionsContext } from "../ANTLR/TodoLangGrammarParser";
import { TodoLangGrammarLexer } from "../ANTLR/TodoLangGrammarLexer";
import { ANTLRInputStream, CommonTokenStream } from "antlr4ts";

export default function parse(code: string): TodoExpressionsContext {
    const inputStream = new ANTLRInputStream(code);
    const lexer = new TodoLangGrammarLexer(inputStream);
    const tokenStream = new CommonTokenStream(lexer);
    const parser = new TodoLangGrammarParser(tokenStream);
    // Parse the input, where `compilationUnit` is whatever entry point you defined
    return parser.todoExpressions();
}

这里直接使用gitee.com/opendsl/ope…中的文件

image-20240227151552471

monaco类初始化
  • Worker创建

    import { WorkerManager } from './language-service/worker';
    import DiagnosticsAdapter from './language-service/DiagnosticsAdapter';
    import TodoLangWorker from './language-service/todoLangWorker.ts?worker';
    
    (window as any).MonacoEnvironment = {
      getWorker: function (moduleId, label) {
        console.log(moduleId, label === 'AviatorScript', label);
        if (label === 'AviatorScript') {
          const worker = new TodoLangWorker() //初始化TodoLangWorker,为worker提供代理方法
          return worker;
        }
        return new monacoWorker();
      }
    }
    
    monaco.languages.onLanguage(this.config?.languageConfig?.name, () => {
      const client = new WorkerManager(this.config?.languageConfig?.name);//初始化WorkerManager,创建worker
      const worker = (...uris: monaco.Uri[]) => {
        return client.getLanguageServiceWorker(...uris); // 获取代理方法
      };
      this.DiagnosticsAdapter = new DiagnosticsAdapter(worker);//初始化DiagnosticsAdapter,onDidChangeContent监听变化并调用worker代理方法doValidation
    });
    

    getWorkerUrl失效

    报错:EditorSimpleWorker.loadForeignModule

    github.com/microsoft/m…

    github.com/microsoft/m…

TodoLangWorker

创建TodoLangWorker,它是为web worker提供方法。用户通过monaco.editor.createWebWorker().getProxy()获取web worker中的代理方法

在web worker中内置方法即为TodoLangWorker的方法,使用web Workers内置方法只是在主线程内部调用代理的方法。

import type * as monaco from 'monaco-editor';
import TodoLangLanguageService from './LanguageService';
import type { ITodoLangError } from './TodoLangErrorListener';
import * as monacoWorker from 'monaco-editor/esm/vs/editor/editor.worker.js';
import IWorkerContext = monaco.worker.IWorkerContext;
//worker中代理方法需要去initialize先初始化TodoLangWorker
self.onmessage = () => {
    monacoWorker.initialize((ctx, CreateData) => {
        // console.log(ctx, CreateData);
        return new TodoLangWorker(ctx, CreateData)
    });
};
export class TodoLangWorker {
    private _ctx: IWorkerContext;
    private CreateData: any;
    private languageService: TodoLangLanguageService;
    constructor(ctx: IWorkerContext, CreateData: any) {
        // console.log('TodoLangWorker:ctx', ctx, CreateData);
        this._ctx = ctx;
        this.CreateData = CreateData;
        this.languageService = new TodoLangLanguageService();
    }
    doValidation(model: monaco.editor.IModel): Promise<ITodoLangError[]> {
        // console.log('TodoLangWorker:ctx', this._ctx, this.CreateData);
        const code = this.getTextDocument();
        // console.log(code)
        return Promise.resolve(this.languageService.validate(code, this.CreateData));
    }
    format(code: string): Promise<string> {
        return Promise.resolve(this.languageService.format(code));
    }
    private getTextDocument(): string {
        const model = this._ctx.getMirrorModels()[0];// When there are multiple files open, this will be an array
        return model.getValue();
    }

}

LanguageService

import { parseAndGetASTRoot, parseAndGetSyntaxErrors } from './parser';
import { parseAndGetASTRoot as parseAndGetASTRootSql, parseAndGetSyntaxErrors as parseAndGetSyntaxErrorsSql } from '../sql/service';
import type { ITodoLangError } from './TodoLangErrorListener';
export default class TodoLangLanguageService {
    validate(code: string, CreateData: any): ITodoLangError[] {
        let syntaxErrors: ITodoLangError[], ast: TodoExpressionsContext
        if (CreateData.languageId === 'sql') {
            syntaxErrors = parseAndGetSyntaxErrorsSql(code);
            ast = parseAndGetASTRootSql(code);
        } else {
            syntaxErrors = parseAndGetSyntaxErrors(code);
            ast = parseAndGetASTRoot(code);
        }
        console.log(ast);
        return syntaxErrors;
    }
    format(code: string): string {

    }
}

parser

import TodoLangErrorListener from './TodoLangErrorListener';
import { InputStream, CommonTokenStream } from 'antlr4';
import GrammarParser from '../aviatorscript/grammar/GrammarParser.mjs'; 

function parse(code: string): { ast: TodoExpressionsContext, errors: ITodoLangError[] } {
    let grammarParser = new GrammarParser();
    let astJson = grammarParser.parse(code);
    return astJson
}

export function parseAndGetASTRoot(code: string): TodoExpressionsContext {
    const { ast } = parse(code);
    return ast;
}
export function parseAndGetSyntaxErrors(code: string): any[] {
    const { errors } = parse(code);
    return errors;
}
WorkerManager

作用

  • 创建worker
  • 返回代理方法
import * as monaco from 'monaco-editor';

import Uri = monaco.Uri;
import type { TodoLangWorker } from './todoLangWorker';
// import { languageID } from './config';
// const languageID = 'AviatorScript'
export class WorkerManager {

  private worker: monaco.editor.MonacoWebWorker<TodoLangWorker>;
  private workerClientProxy: Promise<TodoLangWorker>;

  constructor(private languageID) {
    this.worker = null;
  }

  private getClientproxy(): Promise<TodoLangWorker> {
    // console.log(!this.workerClientProxy, this.languageID, '!this.workerClientProxy');
    //判断是否存在worker代理
    if (!this.workerClientProxy) {

      this.worker = monaco.editor.createWebWorker<TodoLangWorker>({
        // module that exports the create() method and returns a `JSONWorker` instance
        moduleId: 'vs/language/vue/TodoLangWorker',
        label: this.languageID,
        //通过postMessage传递给子线程,self.onmessage中monacoWorker.initialize中获取createData
        createData: {
          languageId: this.languageID,
          // parse: service.parse
        }
      });
      this.workerClientProxy = <Promise<TodoLangWorker>><any>this.worker.getProxy();//使用getProxy方法获取代理对象
    }
    return this.workerClientProxy;
  }

  async getLanguageServiceWorker(...resources: Uri[]): Promise<TodoLangWorker> {
    // console.log('WorkerManager:resources', resources)
    const _client: TodoLangWorker = await this.getClientproxy();
    console.log('WorkerManager:_client', _client);
    await this.worker.withSyncedResources(resources)
    return _client;
  }
}
DiagnosticsAdapter

作用

  • 初始化onDidChangeContent,并通过获取代理方法的函数参数拿到代理方法并执行validate
import * as monaco from 'monaco-editor';
// import { WorkerAccessor } from "./setup";
// import { languageID } from './config';
// import { WorkerAccessor } from "./setup";
// import { languageID } from './config';
import type { ITodoLangError } from './TodoLangErrorListener';

export default class DiagnosticsAdapter {
    codeInfo: any;
    constructor(private worker: WorkerAccessor) {
        const onModelAdd = (model: monaco.editor.IModel): void => {
            let handle: any;
            model.onDidChangeContent(() => {
                // otherwise if the user is still typing, we cancel the
                clearTimeout(handle);
                handle = setTimeout(() => this.validate(model.uri), 500);
            });

            this.validate(model.uri);
        };
        monaco.editor.onDidCreateModel(onModelAdd);
        monaco.editor.getModels().forEach(onModelAdd);
    }
    private async validate(resource: monaco.Uri): Promise<void> {
        console.log(this.worker, 1122);
        // get the worker proxy
        const worker = await this.worker(resource)
        // get the current model(editor or file) which is only one
        const model = monaco.editor.getModel(resource);
        // console.log(worker);
        // call the validate methode proxy from the langaueg service and get errors
        const errorMarkers = await worker.doValidation(resource);
        // console.log(errorMarkers);
        // add the error markers and underline them with severity of Error
        monaco.editor.setModelMarkers(model, model.getLanguageId(), errorMarkers);
        // monaco.editor.setModelMarkers(model, model.getLanguageId(), errorMarkers.map(toDiagnostics));
    }
}
function toDiagnostics(error: ITodoLangError): monaco.editor.IMarkerData {
    return {
        ...error,
        severity: monaco.MarkerSeverity.Error,
    };
}

lint

仓库

github.com/arnaudpfu/m…

github.com/yuzai/eslin…

概念

代码风格的检查:

ESLint 的原理,是遍历语法树然后检验,其核心的 Linter,是不依赖 node 环境的,并且官方也进行了单独的打包输出,具体可以通过 clone官方代码 后,执行 npm run webpack 拿到核心的打包后的 ESLint.js。其本质是对 linter.js 文件的打包。

同时官方也基于该打包产物,提供了 ESLint 的官方 demo

import { Linter } from 'path/to/bundled/ESLint.js';

const linter = new Linter();

// 定义新增的规则,比如react/hooks, react特殊的一些规则
// linter中已经定义了包含了ESLint的所有基本规则,此处更多的是一些插件的规则的定义。
linter.defineRule(ruleName, ruleImpl);

linter.verify(text, {
    rules: {
        'some rules you want': 'off or warn',
    },
    settings: {},
    parserOptions: {},
    env: {},
})

如果只使用上述 linter 提供的方法,存在几个问题:

  1. 规则太多,一一编写太累且不一定符合团队规范
  2. 一些插件的规则无法使用,比如 react 项目强依赖的 ESLint-plugin-react, react-hooks的规则。

故还需要进行一些针对性的定制。

在日常的 react 项目中,基本上团队都是基于 ESLint-config-airbnb 规则配置好大部分的 rules,然后再对部分规则根据团队进行适配。

通过阅读 ESLint-config-airbnd 的代码,其做了两部分的工作:

  1. 对 ESLint 的自带的大部分规则进行了配置
  2. 对 ESLint 的插件,ESLint-plugin-react, ESLint-plugin-react-hooks 的规则,也进行了配置。

而 ESLint-plugin-react, ESLint-plugin-react-hooks,核心是新增了一些针对 react 及 hooks 的规则。

那么其实解决方案如下: 1. 使用打包后的 ESLint.js 导出的 linter 类 2. 借助其 defineRule 的方法,对 react, react/hooks 的规则进行增加 3. 合并 airbnb 的规则,作为各种规则的 config 合集备用 4. 调用 linter.verify 方法,配合3生成的 airbnb 规则,即可实现完整的 ESLint 验证。

通过上述方法,可以生成一个满足日常使用的 linter 及满足 react 项目使用的 ruleConfig。这一部分由于相对独立,我将其单独放在了一个 github 仓库 yuzai/ESLint-browser,可以酌情参考使用,也可根据团队现状修改使用。

下一步就是调用的时机,在每次代码变更时,频繁同步执行ESLint的verify可能会带来ui的卡顿,在此,我采取方案是:

  1. 通过 webworker 执行 linter.verify
  2. model.onDidChangeContent 中通知 worker 进行执行。并通过消抖来减少执行频率
  3. 通过 model.getVersionId,拿到当前 id,来避免延迟过久导致结果对不上的问题

主进程核心的代码如下:

// 监听ESLint web worker 的返回
worker.onmessage = function (event) {
    const { markers, version } = event.data;
    const model = editor.getModel();
    // 判断当前model的versionId与请求时是否一致
    if (model && model.getVersionId() === version) {
        window.monaco.editor.setModelMarkers(model, 'ESLint', markers);
    }
};

let timer = null;
// model内容变更时通知ESLint worker
model.onDidChangeContent(() => {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
        timer = null;
        worker.postMessage({
            code: model.getValue(),
            // 发起通知时携带versionId
            version: model.getVersionId(),
            path,
        });
    }, 500);
});

worker 内核心代码如下:

// 引入ESLint,内部结构如下:
/*
{
    esLinter, // 已经实例化,并且补充了react, react/hooks规则定义的实例
    // 合并了airbnb-config的规则配置
    config: {
        rules,
        parserOptions: {
            ecmaVersion: 'latest',
            sourceType: 'module',
            ecmaFeatures: {
                jsx: true
            }
        },
        env: {
            browser: true
        },
    }
}
*/
importScripts('path/to/bundled/ESLint/and/ESLint-airbnbconfig.js');

// 更详细的config, 参考ESLint linter源码中关于config的定义: https://github.com/ESLint/ESLint/blob/main/lib/linter/linter.js#L1441
const config = {
    ...self.linter.config,
    rules: {
        ...self.linter.config.rules,
        // 可以自定义覆盖原本的rules
    },
    settings: {},
}

// monaco的定义可以参考:https://microsoft.github.io/monaco-editor/api/enums/monaco.MarkerSeverity.html
const severityMap = {
    2: 8, // 2 for ESLint is error
    1: 4, // 1 for ESLint is warning
}

self.addEventListener('message', function (e) {
    const { code, version, path } = e.data;
    const extName = getExtName(path);
    // 对于非js, jsx代码,不做校验
    if (['js', 'jsx'].indexOf(extName) === -1) {
        self.postMessage({ markers: [], version });
        return;
    }
    const errs = self.linter.esLinter.verify(code, config);
    const markers = errs.map(err => ({
        code: {
            value: err.ruleId,
            target: ruleDefines.get(err.ruleId).meta.docs.url,
        },
        startLineNumber: err.line,
        endLineNumber: err.endLine,
        startColumn: err.column,
        endColumn: err.endColumn,
        message: err.message,
        // 设置错误的等级,此处ESLint与monaco的存在差异,做一层映射
        severity: severityMap[err.severity],
        source: 'ESLint',
    }));
    // 发回主进程
    self.postMessage({ markers, version });
});

主进程监听文本变化,消抖后传递给 worker 进行 linter,同时携带 versionId 作为返回的比对标记,linter 验证后将 markers 返回给主进程,主进程设置 markers。

以上,便是整个 ESLint 的完整流程。

当然,由于时间关系,目前只处理了 js,jsx,并未对ts,tsx文件进行处理。支持 ts 需要调用 linter 的 defineParser 修改语法树的解析器,相对来讲稍微麻烦,目前还未做尝试,后续有动静会在 github 仓库 yuzai/ESLint-browser 进行修改同步。

sql格式化

blog.csdn.net/m0_37378152…

  let astJson = parser.validate(code);
  console.log(astJson);
  let markers = [];

  let isError = false;
  for (let i = 0; i < astJson.length; i++) {
    let error = astJson[i];
    console.log(error);
    markers.push({
      startLineNumber: error.startLine,
      startColumn: error.startColumn,
      endLineNumber: error.endLine,
      endColumn: error.endColumn,
      severity: monaco.MarkerSeverity.Error,
      message: error.message
    });
    isError = true;
  }

prettier

zhuanlan.zhihu.com/p/471476313

Monaco Editor 提供自定义格式化代码的能力,通过registerDocumentFormattingEditProvider这个 API 来注册你的格式化规则,当触发格式化时就会调用。

image.png

详细步骤如下:

安装 prettier

yarn add prettier

注册 格式化规则

// ...
import prettier from 'prettier'
import parserBabel from 'prettier/parser-babel'
// ...

// 格式化逻辑
function spliceSemiAndDoubleQoute(val) {
  return prettier.format(val, {
    // 指定文件路径。用来来告知 Prettier 当前是哪种文件,需要调用什么解析器进行处理。
    filepath: model.uri.path,
    parser: 'babel',//也可以自行根据文件后缀计算后使用 parser 字段指定用哪个解析器。
    // parser集合
    plugins: PrettierPlugins,
    // 更多的options见:https://Prettier.io/docs/en/options.html
    //不保留行尾分号去掉,开发规范统一
    semi: false,
    //字符串用单引号包,裹,开发规范统一
    singleQuote: true,
    // 代码每行宇符数
    printWidth: 500,
    //jsx中'>'保持在一行
    bracketSameLine: true,
    //对象空格
    bracketSpacing: true,
    //行尾逗号
    trailingComma: 'none',
    // (x) => {}
    arrowParens: 'avoid',
    //函数后不带空格
    spaceBeforeFunctionParen: false,
  })
}

const formatProvider = {
  provideDocumentFormattingEdits(model, options, token) {
    return [{
      text: spliceSemiAndDoubleQoute(model.getValue()),
      range: model.getFullModelRange()
    }]
  }
}

monaco.languages.registerDocumentFormattingEditProvider('javascript', formatProvider)

由于很多需求都需要在 monaco 这个对象上去设置,并且要先设置,再去初始化编辑器,所以笔者将以上逻辑提取出来单独一个文件 decorateMonacoEditor.js,最后将 monaco export 出去,然后在初始化的文件中直接import monaco from 'decorateMonacoEditor.js' 即可。

触发格式化规则

编辑器右键是有 格式化文件 的选项的,但有些时候我们需要自动的触发格式化逻辑:

this.editor.getAction(['editor.action.formatDocument' ])._run()
/**
 * Format the current selected code
 */
public autoFormatSelection() {
    this.monacoEditor.trigger("external", "editor.action.formatSelection",undefined);
}

/**
 * Format the current selected code
 */
public autoFormatDocument() {
    this.monacoEditor.trigger("external", "editor.action.formatDocument", undefined);
}

多文件支持

在 IDE 概念中不可能每次编辑文件都创建一个 editor,所以提供了 model 和 state 的功能来储存文件的模型和状态。

model 表示一个编辑文件的快照,包含文件信息、语言标记、配置、装饰信息等,可以使用 getModel 和 setModel 切换不同的文件。state 表示视图上的状态,比如光标状态、滚动位置等信息。

let model = this.editor.getModel();
let state = this.editor.saveViewState();
modelsManager.updateModeler({ id: currentTabId, model, state})
// 恢复
let targetModel = modelsManager.models[tabId];
this.editor.setModel(targetModel.model);
this.editor.restoreViewState(targetModel.state);

创建多文件并切换的一般的伪代码如下:

const files = {
    '/test.js': 'xxx',
    '/app/test.js': 'xxx2',
}

const editor = monaco.editor.create(domNode, {
    ...options,
    model: null, // 此处model设为null,是阻止默认创建的空model
});

Object.keys(files).forEach((path) =>
    monaco.editor.createModel(
        files[path],
        'javascript',
        new monaco.Uri().with({ path })
    )
);

function openFile(path) {
    const model = monaco.editor.getModels().find(model => model.uri.path === path);
    editor.setModel(model);
}

openFile('/test.js');

保留切换之前状态

通过上述方法,可以实现多文件切换,但是在文件切换前后,会发现鼠标滚动的位置,文字的选中态均发生丢失的问题。

此时可以通过创建一个 map 来存储不同文件在切换前的状态,核心代码如下:

const editorStatus = new Map();
const preFilePath = '';

const editor = monaco.editor.create(domNode, {
    ...options,
    model: null,
});

function openFile(path) {
    const model = monaco.editor
        .getModels()
        .find(model => model.uri.path === path);

    if (path !== preFilePath) {
        // 储存上一个path的编辑器的状态
        editorStatus.set(preFilePath, editor.saveViewState());
    }
    // 切换到新的model
    editor.setModel(model);
    const editorState = editorStates.get(path);
    if (editorState) {
        // 恢复编辑器的状态
        editor.restoreViewState(editorState);
    }
    // 聚焦编辑器
    editor.focus();
    preFilePath = path;
}

核心便是借助editor实例的 saveViewState 方法实现编辑器状态的存储,通过 restoreViewState 方法进行恢复。

go to definition

monaco-editor 作为一款优秀的编辑器,其本身是能够感知到其他model的存在,并进行相关代码补全的提示。但是我们最常用的 cmd + 点击,默认是不能够跳转的。虽然 hover 上去能看到相关信息,但是默认不能跳转。

这一条也算是比较常见的问题了,详细的原因及解决方案可以查看此 issue

简单来说,库本身没有实现这个打开,是因为如果允许跳转,那么用户没有很明显的方法可以再跳转回去。

实际中,可以通过覆盖 openCodeEditor 的方式来解决,在没有找到跳转结果的情况下,自己实现 model 切换

const editorService = editor._codeEditorService;
    const openEditorBase = editorService.openCodeEditor.bind(editorService);
    editorService.openCodeEditor = async (input, source) =>  {
        const result = await openEditorBase(input, source);
        if (result === null) {
            const fullPath = input.resource.path;
            // 跳转到对应的model
            source.setModel(monaco.editor.getModel(input.resource));
            // 此处还可以自行添加文件选中态等处理

            // 设置选中区以及聚焦的行数
            source.setSelection(input.options.selection);
            source.revealLine(input.options.selection.startLineNumber);
        }
        return result; // always return the base result
    };

受控

在实际编写 react 组件中,往往还需要对文件内容进行受控的操作,这就需要编辑器在内容变化时通知外界,同时也允许外界直接修改文本内容。

先说内容变化的监听,monaco-editor 的每个 model 都提供了 onDidChangeContent 这样的方法来监听文件改变,可以继续改造我们的 openFile 函数。

let listener = null;

function openFile(path) {
    const model = monaco.editor
        .getModels()
        .find(model => model.uri.path === path);

    if (path !== preFilePath) {
        // 储存上一个path的编辑器的状态
        editorStatus.set(preFilePath, editor.saveViewState());
    }
    // 切换到新的model
    editor.setModel(model);
    const editorState = editorStates.get(path);
    if (editorState) {
        // 恢复编辑器的状态
        editor.restoreViewState(editorState);
    }
    // 聚焦编辑器
    editor.focus();
    preFilePath = path;

    if (listener) {
        // 取消上一次的监听
        listener.dispose();
    }

    // 监听文件的变更
    listener = model.onDidChangeContent(() => {
        const v = model.getValue();
        if (props.onChange) {
            props.onChange({
                value: v,
                path,
            })
        }
    })
}

解决了内部改动对外界的通知,外界想要直接修改文件的值,可以直接通过 model.setValue 进行修改,但是这样直接操作,就会丢失编辑器 undo 的堆栈,想要保留 undo,可以通过 model.pushEditOperations 来实现替换,具体代码如下:

function updateModel(path, value) {
    let model = monaco.editor.getModels().find(model => model.uri.path === path);

    if (model && model.getValue() !== value) {
        // 通过该方法,可以实现undo堆栈的保留
        model.pushEditOperations(
            [],
            [
                {
                    range: model.getFullModelRange(),
                    text: value
                }
            ],
            () => {},
        )
    }
}

主题/样式

deltaDecorations修饰字符

microsoft.github.io/monaco-edit…

通过 createDecorationsCollection 方法来设置高亮代码,这里也需要一个range对象,来代表高亮的位置。注意,这里高亮是会跟随文字输入移动的,比如说你在高亮范围内换行了,新的一行也会继续高亮。

问题:输入中文过程样式会清空

let decorations = editor.createDecorationsCollection([
	{
    range: new monaco.Range(startLine, 1, endLine, 1),
    options: {
      isWholeLine: true,
      className: className,
    },
  },
]);

// 移除高亮
decorations.clear()

// 获得高亮的范围
decorations.getRanges()

if (this.decorations?.clear) {
  this.decorations.clear();
}
matches.forEach((match): void => {
                if (matches.length > 0) {
  obj.push({
    range: match.range,
    options: {
      isWholeLine: false,
      inlineClassName: 'myInlineDecoration'
    }
  })
}
});

代码

设置代码

this.editor.setValue(code)

获取编辑器代码

this.editor.getValue()

获取编辑器中被选中文案的 range

获取编辑器中被选中文案的 range ,返回一个对象,如下:

{
    startLineNumber: 0,
    startColumnNumber: 0,
    endLineNumber: 0,
    endColumnNumber: 0,
}=editor.getSelection()

// 获取编辑器中选中的sql
const selectSql = this.editor.getModel().getValueInRange(this.editor.getSelection())

删除

editor.getModel()

获取编辑器当前的 textmodel,一般不会直接使用,通过 textmodel 可以对文本各种操作。

editor.getModel().findMatches(str|regexp)

功能和 ⌘ + F 一致,通过字符串或正则表达式查找编辑器内匹配的文本,并返回匹配文本 range 的集合。

         * Search the model.
         * @param searchString The string used to search. If it is a regular expression, set `isRegex` to true.
         * @param searchOnlyEditableRange Limit the searching to only search inside the editable range of the model.
         * @param isRegex Used to indicate that `searchString` is a regular expression.
         * @param matchCase Force the matching to match lower/upper case exactly.
         * @param wordSeparators Force the matching to match entire words only. Pass null otherwise.
         * @param captureMatches The result will contain the captured groups.
         * @param limitResultCount Limit the number of results
         * @return The ranges where the matches are. It is empty if not matches have been found.
         */
         
         findMatches(searchString: string, searchOnlyEditableRange: boolean, isRegex: boolean, matchCase: boolean, wordSeparators: string | null, captureMatches: boolean, limitResultCount?: number): FindMatch[];
editor.getModel().getValueInRange(range)

通过 range 获取范围内的文本,返回一个字符串。

model.getValueInRange(new monaco.Range(position.lineNumber, model.getWordAtPosition(position).startColumn - 1, position.lineNumber, model.getWordAtPosition(position).endColumn + 1))
editor.getModel().getLinesContent(lineNumber)

如果传入 lineNumber,则返回对应行的文本字符串,不传参则返回所有行的文本字符串的集合。

光标

editor.setValue() 不同,可以用 ⌘ + Z 撤销输入

// monacoEditor 为 实例对象
// 1 获取光标位置
const position = monacoEditor.getPosition()
const insertText = '要插入的内容'

// 3 设置新的光标位置
monacoEditor.setPosition(position.lineNumber, position.column + insertText.length)
// 4 聚焦
monacoEditor.focus()
onDidFocusEditor

onDidChangeCursorPosition(e=>{
//e.source 光标移动类型
})

插入/删除

// 2 插入
monacoEditor.executeEdits('', [
    {
        range: new monaco.Range(position.lineNumber, 
                position.column, 
                position.lineNumber, 
                position.column),
        text: insertText
    }
])

line

获取行数model.getLineCount()
获取长度getLineLength(i)
获取行内容getLineContent(i)

禁用指定行

    console.log(this.config.readOnlyArr);
    if (this.config.readOnlyArr && this.config.readOnlyArr.length) {
      const { doc, lineNumber, start, end } = this.config.readOnlyArr[0];
      const readonlyRange = new monaco.Range(lineNumber, start, lineNumber, end);
      const range = new monaco.Range(lineNumber, start, lineNumber, end);
      this.editorInstance.executeEdits('需要插入的代码/string', [
        {
          range: range,
          text: doc
          // text: e1
        }
      ]);
      this.editorInstance.onKeyDown(e => {
        // console.log(e.code);
        if (e.code === 'Enter') {
          return;
        } else {
          const contains = this.editorInstance.getSelections().findIndex(range => readonlyRange.intersectRanges(range));
          if (contains !== -1) {
            e.stopPropagation()
            e.preventDefault() // for Ctrl+C, Ctrl+V
          }
        }
      })
    }

键盘

    // this.editorInstance.addCommand(monaco.KeyMod.Space | monaco.KeyCode.KeyS, function () {
    //   console.log('Ctrl + S 保存')
    // })

hover

stackoverflow.com/questions/7…

右键菜单

addRMenu({ id, label, keybindings, MenuGroup, MenuOrder }, fn) {
        let disposable = this.editor.addAction({
            id,
            label,
            keybindings: keybindings || [],
            contextMenuGroupId: MenuGroup === undefined ? 'navigation' : MenuGroup,
            contextMenuOrder: MenuOrder || 1,
            run: fn
        })
        this.collectDispose(disposable);
    }
this.editor.addRMenu(
        {
          id: ActionsIds.javaOrganizeImports,
          keybindings: [monaco.KeyMod.CtrlCmd|monaco.KeyMod.Alt|monaco.KeyCode.KeyF],
          label: "重新组织导入",
        },
        this.organizeImports
      );

可以在右键面板中增加自定义功能: id:该选项的唯一标识。 label:选项显示的名称。 keybindings:绑定的快捷键,多个快捷键用竖线分割。每个按键要使用monaco的内置枚举类型来设定。 contextMenuGroupId:选项所属组的id,内置了三个组idnavigation:该组是右键的第一栏,1_modification:修改组,9_cutcopypaste:剪切复制粘贴组)。 run:选择该选项之后的回调函数,第一个参数为editor实例,第二个参数为一个剩余参数的数组,注意如果通过快捷键触发那么第二个参数不存在。

diff

    /**
     * Initialize the diff editor triggered by ctrl+k
     */
    private initializeDiffEditor(): void {
        let self = this;

        let diffEditor: monaco.editor.IStandaloneDiffEditor;

        let modal = new tingle.modal({
            cssClass: ["tingle-popup-container"],
            onOpen: function () {
                let originalModel = monaco.editor.createModel(
                    self._instanceSettings.code,
                    self._instanceSettings.language
                );
                let modifiedModel = self.monacoEditor.getModel();

                const editorSettings = Object.assign(
                    {},
                    self._currentEditorSettings.editor,
                    self._currentEditorSettings.diffEditor
                );
                // create the diff editor
                const contentElement = this.modalBoxContent.getElementsByClassName("content")[0];
                diffEditor = monaco.editor.createDiffEditor(contentElement, editorSettings);
                contentElement.onkeydown =
                    contentElement.onkeypress =
                    contentElement.onkeyup =
                        (e) => e.stopPropagation();

                diffEditor.setModel({
                    original: originalModel,
                    modified: modifiedModel,
                });
                diffEditor.focus();
            },
            onClose: function () {
                diffEditor.dispose();
            },
        });
        this.modals.push(modal);
        // action triggered by CTRL+K
        // shows a popup with a diff editor with the initial state of the editor
        // reuse the current model, so changes can be made directly in the diff editor
        this.monacoEditor.addAction({
            id: "viewDiffAction",
            label: "View Diff",
            contextMenuGroupId: "service",
            contextMenuOrder: 1.4,
            keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK],
            keybindingContext: null,
            run: (ed) => {
                modal.setContent(`<h2>Diff Editor</h2>
                            <div class="content" style="height: 30vw"/>`);
                modal.open();
            },
        });
    }

折叠代码块

/**
* Fold all the current folding indicators
*/
public foldAll() {
	this.monacoEditor.trigger("external", "editor.foldAll", undefined);
}

/**
* Unfold all the folding indicators
*/
public unfoldAll() {
	this.monacoEditor.trigger("external", "editor.unfoldAll", undefined);
}

// 收起
this.editor.getAction(['editor.foldAll'])._run()
// 展开
this.editor.getAction(['editor.unfoldAll'])._run()

只读

    /**
     * Set the editor as read-only or not
     */
     public setReadOnlyStatus(readOnly: boolean) {
       this.monacoEditor.updateOptions({
       readOnly: readOnly,
       });
     }

销毁

    public dispose() {
        if (this.monacoEditor.getModel()) {
            this.monacoEditor.getModel().dispose();
        }
        for (const disposable of this.disposables) {
            disposable.dispose();
        }
        for (const modal of this.modals) {
            modal.destroy();
        }
        this.monacoEditor.dispose();
    }

焦点

this.editor.onDidBlurEditorWidget(() => {
   // 你的逻辑
})

scroll

    /**
     * Scroll vertically or horizontally as necessary and reveal a position centered vertically.
     */
    public scrollCodeTo(lineNumber: number, column: number) {
        const position = {
            lineNumber: lineNumber || 0,
            column: column || 0,
        };
        this.monacoEditor.revealPositionInCenter(position);
        this.monacoEditor.setPosition(position);
    }

设置编辑区域滚动高度

this.editor.setScrollTop(srollTop)

代码区域滚动事件监听

this.editor.onDidScrollChange (() = {
    // 你的逻辑
})

切换语言

     public changeLanguage(language: string, code: string) {
         if (this._instanceSettings.language != language) {
             this.monacoEditor.getModel().dispose();
             let model = monaco.editor.createModel(
                 code,
                 language,
                 monaco.Uri.parse("twx://privateModel/" + this._instanceSettings.modelName)
             );
             this.monacoEditor.setModel(model);
             this._instanceSettings.language = language;
         }
     }

撤销

     public undo() {
         this.monacoEditor.trigger("external", "undo", undefined);
     }
 ​
     public redo() {
         this.monacoEditor.trigger("external", "redo", undefined);
     }