我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式資料中臺產品。我們始終保持工匠精神,探索前端道路,為社群積累並傳播經驗價值。
本文作者:奇銘
前言
目前數棧的多個產品中都支援線上編輯 SQL 來生成對應的任務。比如離線開發產品和實時開發產品。在使用 MonacoEditor 為編輯器的基礎上,我們還支援瞭如下幾個重要功能:
- 多種 SQL 的語法高亮
- 多種 SQL 的報錯提示(錯誤位置飄紅)
- 多種 SQL 的自動補全(智慧提示)
本文旨在講解上述功能的實現思路,對於技術細節,由於篇幅原因不會闡述的太詳細。
Monaco Languages
Monaco Editor 內建的 languages
Monaco Editor 內建了相當多的 languages,比如 javaScript
、CSS
、Shell
等。
Monaco Editor 依賴包的 ESM 入口檔案為 ./esm/vs/editor/editor.main.ts
而在這個檔案中,Monaco Editor 引入了所有內建的 Languages。
這裡 languages 檔案可以分為兩類,一類是../language
資料夾下的,支援自動補全和飄紅提示功能;另一類則是../basic-languages
資料夾下的,不支援自動補全功能和飄紅提示功能。
使用內建的 Language 功能
以使用 typescript
為例
import { editor } from 'monaco-editor';
const container = document.getElementById('container');
editor.create(container, {
language: 'typescript'
})
此時我們會發現,我們的編輯器已經有語法高亮的功能了,但是瀏覽器控制檯會拋異常,另外也沒有自動補全功能和飄紅提示功能,
這其實是因為,Monaco Editor 無法載入到 language 對應的 worker,對應的解決辦法看這裡: Monaco integrate-esm。
這裡我們使用 Using plain webpack
的方式,首先將對應的 worker 檔案設定為 webpack entry
module.exports = {
entry: {
index: path.resolve( __dirname, './src/index.ts'),
'editor.worker': 'monaco-editor/esm/vs/editor/editor.worker.js',
'ts.worker': 'monaco-editor/esm/vs/language/typescript/ts.worker.js'
},
}
另外還需要設定 Monaco Editor 的全域性環境變數,這主要是為了告訴 Monaco Editor 對應的 worker 檔案的路徑
import { editor } from 'monaco-editor';
(window as any).MonacoEnvironment = {
getWorkerUrl: function (_moduleId, label) {
switch (label) {
case 'flink': {
return './flink.worker.js';
}
case 'typescript': {
return './ts.worker.js'
}
default: {
return './editor.worker.js';
}
}
}
};
const container = document.getElementById('container');
editor.create(container, {
language: 'typescript'
})
這樣一個具有語法高亮
、自動補全
、飄紅提示
功能的 typescript 編輯器就設定好了
小結分析
首先上文中提到了當我們直接從 Monaco Editor 的入口檔案中匯入時,會自動的引入所有內建的 Languages,但是實際上這其中絕大都是我們不需要的,而由於其匯入方式,很顯然我們不需要的 languages 也無法被 treeShaking。要解決這個問題我們可以選擇從 monaco-editor/esm/vs/editor/editor.api
檔案中匯入Monaco Editor 核心 API,然後透過 monaco-editor-webpack-plugin 來按需匯入所需要的功能。另外這個外掛也可以自動處理Monaco Editor 內建的 worker 檔案的打包問題,以及自動注入 MonacoEnvironment
全域性環境變數。
自定義 Language
註冊Language
Monaco Editor 提供了 monaco.languages.register
方法,用來自定義 language
/**
* Register information about a new language.
*/
export function register(language: ILanguageExtensionPoint): void;
export interface ILanguageExtensionPoint {
id: string;
extensions?: string[];
filenames?: string[];
filenamePatterns?: string[];
firstLine?: string;
aliases?: string[];
mimetypes?: string[];
configuration?: Uri;
}
第一步,我們需要註冊一個 language, 配置項中 id 對應的就是語言名稱(其他配置項可以暫時不填),這裡自定義的 language 名為 myLang
import { editor, languages } from 'monaco-editor';
languages.register({
id: "myLang"
});
const container = document.getElementById('container');
editor.create(container, {
language: 'myLang'
})
此時可以發現,頁面上的編輯器沒有任何其他附加功能,就是普通的文字編輯器。
設定 Language
透過 monaco.languages.setLanguageConfiguration
,可以對 language 進行配置
/**
* Set the editing configuration for a language.
*/
export function setLanguageConfiguration(
languageId: string,
configuration: LanguageConfiguration
): IDisposable;
/**
* The language configuration interface defines the contract between extensions and
* various editor features, like automatic bracket insertion, automatic indentation etc.
*/
export interface LanguageConfiguration {
comments?: CommentRule;
brackets?: CharacterPair[];
wordPattern?: RegExp;
indentationRules?: IndentationRule;
onEnterRules?: OnEnterRule[];
autoClosingPairs?: IAutoClosingPairConditional[];
surroundingPairs?: IAutoClosingPair[];
colorizedBracketPairs?: CharacterPair[];
autoCloseBefore?: string;
folding?: FoldingRules;
}
這些配置會影響 Monaco Editor 的一些預設行為,比如設定 autoClosingPairs
中有一項為一對圓括號,那麼當輸入左圓括號後,會自動補全右圓括號。
import { languages } from "monaco-editor";
const conf: languages.LanguageConfiguration = {
comments: {
lineComment: "--",
blockComment: ["/*", "*/"],
},
brackets: [
["(", ")"],
],
autoClosingPairs: [
{ open: "(", close: ")" },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
],
surroundingPairs: [
{ open: "(", close: ")" },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
],
};
languages.setLanguageConfiguration('myLang', conf)
高亮功能
Monarch
Moanco Editor 內建了 Monarch,用於實現語法高亮功能,它本質上是一個有限狀態機,我們可以透過JSON的形式來配置其狀態流轉邏輯,並透過monaco.languages.setMonarchTokensProvider
API 應用該配置。關於Monarch 的具體用法可以看一下這篇文章 以及 Monarch Document。
配置中最重要的是 tokenizer
屬性,意思是分詞器,分詞器會自動對編輯器內部的文字進行分詞處理,每個分詞器都有一個 root state,在 root state 中可以有多條規則,規則內部可以引用其他 state。
下面是一個簡單的配置示例
import { languages } from "monaco-editor";
export const language: languages.IMonarchLanguage = {
ignoreCase: true,
tokenizer: {
root: [
{ include: '@comments' }, // 引用下面的 comments 規則
{ include: '@whitespace' }, // 引用下面的 whiteSpace 規則
{ include: '@strings' },// 引用下面的 strings 規則
],
whitespace: [[/\s+/, 'white']],
comments: [
[/--+.*/, 'comment'],
[/\/\*/, { token: 'comment.quote', next: '@comment' }]
],
comment: [
[/[^*/]+/, 'comment'],
[/\*\//, { token: 'comment.quote', next: '@pop' }],
[/./, 'comment']
],
strings: [
[/'/, { token: 'string', next: '@string' }]
],
string: [
[/[^']+/, 'string'],
[/''/, 'string'],
[/'/, { token: 'string', next: '@pop' }]
],
}
};
languages.setMonarchTokensProvider("myLang", language);
上面的配置中 root 下面有三條規則分別匹配 註釋(comments)
、字串(strings)
以及空白字元(whiteSpace)
, 每條規則可以大體分為兩部分:
- 匹配方式,比如說正則
- 對應的 token 型別(任意字串)
比如上述配置中 tokenizer.comments
規則
comments: [
[/--+.*/, 'comment'], // 左邊是正規表示式用來匹配文字,右邊是該規則對應的 token 名稱
[/\/\*/, { token: 'comment.quote', next: '@comment' }] // 左邊是正規表示式用來匹配文字,右邊顯示宣告對應的 token 名稱
],
配置瞭如上 Monarch 之後,在編輯器內部輸入註釋或者字串,那麼Monaco editor 就會根據輸入的內容進行分詞處理
可以看到目前字串和註釋已經被高亮了。這裡有一個新的問題,不同型別的分詞的顏色是怎麼設定的?
Monaco Theme
從上圖中右側的 Elements 皮膚中可以看到,不同型別的分詞,對應的標籤的 className 不同,它們是由 Monarch 配置中的 token 對映而來的。MonacoEditor 內建了一些 Theme,預設的 Theme 是 vs
,而預設的 theme 中已經設定了上述 Monarch 中的 token 對應的顏色,所以我們應用上述配置後,對應的分詞直接就有了高亮顏色。
我們可以透過 monaco.editor.defineTheme
來定義一種新的 theme,如下例所示:
editor.defineTheme('myTheme', {
base: 'vs',
inherit: true,
rules: [
{ token: 'comment', foreground: 'ff4400' },
{ token: 'string', foreground: '0000ff' }
],
colors: {
},
});
// xxxx
editor.create(container, {
language: "myLang",
theme: "myTheme"
});
這裡將註釋設定為紅色,字串設定為藍色,顯示效果如下圖所示
飄紅提示
飄紅提示的功能就是在程式碼錯誤的位置打上標記(一般是紅色波浪線),可以透過 monaco.editor.setModelMarkers
API 來實現。比如我們想為 第1行的第1個字元到第2行的第2個字元 之間打上錯誤標記:
const editorIns = editor.create(container, {
language: "myLang",
theme: "myTheme",
value:
`hello
world`
});
const model = editorIns.getModel();
editor.setModelMarkers(model, 'myLang', [
{
startLineNumber: 1,
startColumn: 1,
endLineNumber: 2,
endColumn: 2,
message: "語法錯誤",
severity: MarkerSeverity.Error
}
])
severity 是標記型別,message 是提示資訊,效果如下所示。
到此為止,實現了飄紅的功能,但是沒有實現在語法錯誤處飄紅的功能,這需要額外的語法解析器支援,會在下文中講到。
自動補全功能
Monaco Editor 提供了 monaco.languages.registerCompletionItemProvider
API 來實現自動補全功能
import { editor, languages, MarkerSeverity, Position, CancellationToken, Range } from "monaco-editor";
languages.registerCompletionItemProvider('myLang', {
triggerCharacters: ['.', '*'],
provideCompletionItems(
model: editor.IReadOnlyModel,
position: Position,
context: languages.CompletionContext,
token: CancellationToken
){
const wordInfo = model.getWordUntilPosition(position);
const wordRange = new Range(
position.lineNumber,
wordInfo.startColumn,
position.lineNumber,
wordInfo.endColumn
);
return new Promise((resolve) => {
resolve({
suggestions: [
{
label: "SELECT",
kind: languages.CompletionItemKind.Keyword,
insertText: "SELECT",
range: wordRange,
detail: '關鍵字',
},
{
label: "SET",
kind: languages.CompletionItemKind.Keyword,
insertText: "SET",
range: wordRange,
detail: '關鍵字',
},
{
label: "SHOW",
kind: languages.CompletionItemKind.Keyword,
insertText: "SHOW",
range: wordRange,
detail: '關鍵字',
},
]
})
})
}
})
registerCompletionItemProvider
接受兩個引數,第一個引數是 languageId 也就是 language 名稱,
第二個引數是一個 CompletionItemProvider
,CompletionItemProvider
中 triggerCharacters
用來配置觸發自動補全的字元有哪些,而 provideCompletionItems
則是一個函式,它接收 Monaco Editor 提供的當前的上下文資訊,返回自動補全項列表。如上例中返回了三個自動補全項,那麼當我們在編輯器中輸入 S
時,就會出現配置的自動補全項候選選單。
透過這個 API 我們可以實現一種語言的關鍵字自動補全,只需要在CompletionItemProvider
中返回該語言所有的關鍵字對應的自動補全項即可。
但是registerCompletionItemProvider
目前做不到根據語義進行自動補全。
比如使用者寫一段 flinkSQL,當使用者輸入完 CREATE
關鍵字並按下空格後,應該出現的自動補全項應該是隻有TABLE
、CATALOG
、DATABASE
、FUNCTION
、 VIEW
。
再比如當使用者輸入 SELECT * FROM
時,後面應該提示表名而不是其他無關的關鍵字。與上文中的飄紅提示一樣,這些語義資訊需要單獨的語法解析器來分析。
小結分析
到此為止,在**自定義 language **這一節中,我們已經瞭解了,在 Monaco Editor 中如何實現自定義語言的 語法高亮
、錯誤處飄紅提示
、自動補全
。
在數棧產品中,本節講到的功能都透過引入 monaco-sql-languages 依賴來實現,這是我們數棧 UED 團隊自研的開源專案,目前已經支援多種 SQL Languages。
由於目前為止沒有實現自定義 language 的語義分析功能,導致目前實現的編輯器不夠智慧。 另外,對於第一節中提到的 web worker ,在第二節中也沒有有提到,實際上 Monaco Editor 自帶的 web worker,也都是為了實現 language 的語義分析功能,下一節將闡述這一部分內容。
SQL Parser
要實現語義分析功能,很顯然我們需要一個語法解析器。除了基本的語法解析的基礎功能以外,我們還需要
- 語法錯誤收集,收集編輯器中文字的語法錯誤資訊,用於錯誤飄紅提示功能。
- 推斷文字中指定位置的候選項列表,對於編輯器來說,指定位置一般就是游標所在位置。候選項是指在游標所在的位置應該要寫什麼。比如 SQL 中
SELECT
關鍵字後面可以跟欄位或者函式,那麼我們所要實現的 sql parser 就應該提示出在SELECT
關鍵字後面的候選項應該是欄位或者函式。
實現基礎的 SQL Parser
Antlr4 語法檔案
我們使用 Antlr4 來實現一個基本的 SQL Parser。Antlr4 是一個強大的解析器生成器,它能根據使用者自定義的語法檔案來生成對應的解析器。Antlr4 的語法檔案為 .g4
檔案,內部可以包含多條規則,規則可以分為詞法規則和語法規則,詞法規則用於生成詞法分析器,語法規則用於生成語法解析器。
例,我們現在寫一份語法規則,匹配最簡單的 SELECT 語句(不包括子查詢、別名等規則),比如
SELECT * FROM table1; -- eg1
SELECT table2.name, age FROM schema2.table2; -- eg2
那麼在antlr4中這份語法檔案應該這樣寫:
grammar SelectStatement;
/** 語法規則 begin */
program: selectStatement? EOF;
// 宣告 語句的匹配規則
selectStatement: KW_SELECT columnGroup KW_FROM tablePath SEMICOLON?;
// 宣告 語句中欄位部分的匹配規則,欄位部分可能為 col1, col2 的形式
columnGroup: columnPath (COMMA columnPath)*;
// 宣告 欄位名匹配規則,欄位名有可能為 db.table.col 或者 * 的形式
columnPath: dot_id | OP_STAR;
// 宣告 表名匹配規則,表名有可能為 db.table 的形式
tablePath: dot_id;
// 匹配 id.id 形式的識別符號號
dot_id: IDENTIFIER_LITERAL (DOT IDENTIFIER_LITERAL)*;
/** 語法規則 end */
/** 詞法規則 begin */
KW_SELECT: 'SELECT'; // 匹配 SELECT 關鍵字
KW_FROM: 'FROM'; // 匹配 FROM 關鍵字
OP_STAR: '*'; // 匹配 *
DOT: '.'; // 匹配 .
COMMA: ','; // 匹配 ,
SEMICOLON: ';'; // 匹配 ;
IDENTIFIER_LITERAL: [A-Z_a-z][A-Z_0-9a-z]*; // 匹配識別符號
WS: [ \t\n\r]+ -> skip ; // 忽略空格換行等空白字元
/** 詞法規則 end */
語法規則的編寫格式類似於 EBNF。
然後執行 antlr4 命令,根據所寫的語法檔案生成對應的解析器。可以直接使用官方文件中提供的方式 antlr4 typescript-target doc ,或者直接使用社群提供的 antlr4ts 包,這裡以使用 antlr4ts 為例。
生成的檔案結果如下所示:
使用 Antlr4 生成的 Parser
在使用Antlr4 的生成的 Parser 之前我們需要安裝,Antlr4 的執行時包。你可以將 Antlr4 的執行時包與透過語法檔案生成的parser檔案之間的關係,類比為 react 和 react-dom之間的關係。這裡以使用 antlr4ts 為執行時
import { CommonTokenStream, CharStreams } from 'antlr4ts';
import { SelectStatementLexer } from '../lib/selectStatement/SelectStatementLexer';
import { SelectStatementParser } from '../lib/selectStatement/SelectStatementParser';
class SelectParser {
private createLexer(input: string) {
const inputStream = CharStreams.fromString(input);
const lexer = new SelectStatementLexer(inputStream);
return lexer
}
private createParser (input: string) {
const lexer = this.createLexer(input);
const tokens = new CommonTokenStream(lexer);
const parser = new SelectStatementParser(tokens);
return parser
}
parse (sql: string) {
const parser = this.createParser(sql)
const parseTree = parser.selectStatement();
return parseTree;
}
}
// 試一下效果
const selectParser = new SelectParser();
const parseTree = selectParser.parse('SELECT * FROM table1');
獲取文字中的錯誤資訊
當解析一個含有錯誤的文字時,Antlr4 會輸出錯誤資訊,例如輸入
selectParser.parse('SELECT id FRO');
控制檯列印
可以看到錯誤資訊中包含了文字中的錯誤所處的位置,我們可以透過使用 Antlr4 ParserErrorListener 來獲取錯誤資訊。
宣告一個 ParserErrorListener
import { ParserErrorListener } from 'antlr4ts';
export 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)
}
}
使用 ParserErrorListener 收集錯誤資訊
import { CommonTokenStream, CharStreams } from 'antlr4ts';
import { SelectStatementLexer } from '../lib/selectStatement/SelectStatementLexer';
import { SelectStatementParser } from '../lib/selectStatement/SelectStatementParser';
class SelectParser {
private _errorListener = new SelectErrorListener();
createLexer(input: string) {
const inputStream = CharStreams.fromString(input);
const lexer = new SelectStatementLexer(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 SelectStatementParser(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,
};
}
}
// 試一下效果
const selectParser = new SelectParser();
const { errors } = selectParser.parse('SELECT id FRO');
console.log(errors);
列印結果
這樣我們就獲取到了文字中的語法錯誤出現的位置,以及錯誤資訊。
到此為止上文中遺留的第一個問題就已經差不多解決了,我們只需要在合適的時機將編輯器的內容進行解析,拿到錯誤資訊並且透過 editor.setModelMarkers
這個 API 讓錯誤的位置飄紅就大功告成了。
自動補全功能
對於自動補全功能,Antlr4 並沒有直接提供,但是社群已經有了比較優秀的解決方案 - antlr-c3 。它的作用是根據Antlr4 Parser 的解析結果,分析指定位置填哪些詞法/語法規則是合法的。
antlr4-c3 的使用方式比較簡單。
import { CodeCompletionCore } from "antlr4-c3";
// 這裡 parser 是 parser 例項
let core = new CodeCompletionCore(parser);
// tokenIndex 是想要自動補全的位置,對應由編輯器的游標位置轉換而來
// parserContext 則是解析完之後的返回的 ParserTree 或者 ParserTree 的子節點(傳入子節點可以更高效)
let candidates = core.collectCandidates(tokenIndex, parserContext);
那麼結合上文中寫的 SelectParser,程式碼應該是這樣
import { CodeCompletionCore } from "antlr4-c3";
import { SelectParser } from "./selectParser";
/**
* input 源文字
* caretPosition 編輯器游標位置
*/
function getSuggestions(input: string, caretPosition) {
const selectParser = new SelectParser();
const parserIns = selectParser.createParser(input)
let core = new CodeCompletionCore(parserIns);
const parserContext = parserIns.selectStatement();
// 虛擬碼
const tokenIndex = caretPosition2TokenIndex(caretPosition)
let candidates = core.collectCandidates(tokenIndex, parserContext);
}
core.collectCandidates
的返回值的資料型別如下
interface CandidatesCollection {
tokens: Map<number, TokenList>;
rules: Map<number, CandidateRule>;
}
tokens 對應的是詞法規則提示,比如關鍵字等,rules 對應的是語法規則,比如上述語法檔案中的 columnPath
和 tablePath
等。
需要注意的是,antlr4-c3 預設不收集語法規則,需要我們手動設定需要收集的語法規則
import { SelectStatementParser } from '../lib/selectStatement/SelectStatementParser';
let core = new CodeCompletionCore(parserIns);
core.preferredRules= new Set([
SelectStatementParser.RULE_tablePath,
SelectStatementParser.RULE_columnPath
])
// 設定需要收集 tablePath 和 columnPath
這樣我們就收集到了在指定位置的可以填什麼。接下來我們需要將結果進行轉換成我們需要的資料結果
import { CodeCompletionCore } from "antlr4-c3";
import { SelectParser } from "./selectParser";
import { SelectStatementParser } from '../lib/selectStatement/SelectStatementParser';
/**
* input 源文字
* caretPosition 編輯器游標位置
*/
export function getSuggestions(input: string, caretPosition?: any) {
const selectParser = new SelectParser();
const parserIns = selectParser.createParser(input)
let core = new CodeCompletionCore(parserIns);
core.preferredRules= new Set([
SelectStatementParser.RULE_tablePath,
SelectStatementParser.RULE_columnPath
])
const parserContext = parserIns.selectStatement();
const tokenIndex = caretPosition2TokenIndex(caretPosition);
let candidates = core.collectCandidates(tokenIndex, parserContext);
const rule = [];
const keywords = []
for (let candidate of candidates.rules) {
const [ruleType] = candidate;
let syntaxContextType;
switch (ruleType) {
case SelectStatementParser.RULE_tablePath: {
syntaxContextType = 'table';
break;
}
case SelectStatementParser.RULE_columnPath: {
syntaxContextType = 'column';
break;
}
default:
break;
}
if (syntaxContextType) {
rule.push(syntaxContextType)
}
}
for (let candidate of candidates.tokens) {
const symbolicName = parserIns.vocabulary.getSymbolicName(candidate[0]);
const displayName = parserIns.vocabulary.getDisplayName(candidate[0]);
if(symbolicName && symbolicName.startsWith('KW_')) {
const keyword = displayName.startsWith("'") && displayName.endsWith("'")
? displayName.slice(1, -1)
: displayName
keywords.push(keyword);
}
}
console.log('===== suggest keywords: ',keywords);
console.log('===== suggest rules:', rule);
}
這樣我們就拿到了要提示的關鍵字和語法規則。關鍵字可以直接用於生成自動補全項,語法規則可以用於提示表名、欄位名等。
小結分析
在這一節中,我們已經瞭解了,如何使用 Antlr4 和 antlr4-c3 來實現更加智慧的飄紅提示以及自動補全功能。
這一部分功能,在 monaco-sql-languages 中透過引入數棧前端團隊自研的開源專案 dt-sql-parser 實現。
前文中提到的 worker 檔案也正是用於執行 sql parser,因為dt-sql-parser 的解析可能會比較耗時,為了避免用項使用者互動,將 sql parser 放到 web worker 中執行顯然是更明智的選擇。
總結
總的來說
- 多種 SQL 的語法高亮
- 多種 SQL 的報錯提示(錯誤位置飄紅)
- 多種 SQL 的自動補全(智慧提示)
三個功能大部分都可以透過 MonacoEditor 內建的 API 來實現,只是關鍵的語法解析功能需要使用 Antlr4 實現。整體上來說大部分的工作在編寫 Antlr4 的語法檔案以及方案整合上面。
Github 連結
最後
歡迎關注【袋鼠雲數棧UED團隊】~
袋鼠雲數棧UED團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎star