Harry Park's Blog

TypeScript, React, ANTLR, Monaco Editor를 이용하여 Custom Language Editor 만들기 (2/2)

이 포스트는 TypeScript, React, ANTLR, Monaco Editor를 이용해서 새로운 Language를 하나 정의하고, 그 것을 지원하는 Custom Editor를 만드는 방법을 단계적으로 설명하는 두번째 파트이다. 아직 첫번째 파트를 진행하지 않았다면 TypeScript, React, ANTLR, Monaco Editor를 이용하여 Custom Language Editor 만들기 (1/2)에서 확인할 수 있다.

이 포스트에서는 텍스트를 파싱하는 무거운 작업을 어떻게 Language Service로 처리하는지 보여줄 것이다. 우리는 파서parser로부터 얻은 Abstract Syntax Tree (AST) 를 이용해서 문법 또는 구문에러를 찾고, 작성된 코드를 포맷팅하고, 사용자가 입력을 시작할 때 이미 정의된 TODO를 통해서 suggestion을 제공할 것이다. (자동 완성은 구현하지 않는다.) 기본적으로 이 language service는 다음 3개의 함수를 외부에 노출expose할 것이다.

  • format(code: string): string
  • validate(code: string): Errors[]
  • autoComplete(code: string, currentPosition: Position): string[]

ANTLR 설치 및 Lexer/Parser 생성

이 섹션에서는 ANTLR 라이브러리를 설치하고 TOdoLang.g4 문법 정의 파일로부터 parser와 lexer를 만들어 낼 것이다.

가장 처음으로 해야할 일은 antlr4tsantlr4ts-cli를 설치하는 것이다. antlr4ts는 ANTLR를 위한 런타임 라이브러리로 TypeScript로 만들어져있다. antlr4ts-cli는 특정 언어들에 대한 parser와 lexer를 만들기 위한 명령어를 제공한다.

npm add antlr4ts
npm add -D antlr4ts-cli

그리고 package.jsonantlr-cli로 parser와 lexer를 생성하기 위한 script를 추가한다.

  • package.json
"scripts": {
  "start": "webpack-dev-server --hot --open",
  "antlr4ts": "antlr4ts ./TodoLangGrammar.g4 -o ./src/ANTLR",  "test": "echo \"Error: no test specified\" && exit 1"
},

그리고 antlr4ts script를 실행시켜 생성된 파일을 확인해 본다.

npm run antlr4ts

> antlr4-monaco@1.0.0 antlr4ts
> antlr4ts ./TodoLangGrammar.g4 -o ./src/ANTLR

Generating file 'C:\SAPDevelop\antlr4-monaco\.\src\ANTLR\.\TodoLangGrammarLexer.interp' for grammar './TodoLangGrammar.g4'
Generating file 'C:\SAPDevelop\antlr4-monaco\.\src\ANTLR\.\TodoLangGrammarLexer.ts' for grammar './TodoLangGrammar.g4'
Generating file 'C:\SAPDevelop\antlr4-monaco\.\src\ANTLR\.\TodoLangGrammarLexer.tokens' for grammar './TodoLangGrammar.g4'     
Generating file 'C:\SAPDevelop\antlr4-monaco\.\src\ANTLR\.\TodoLangGrammar.interp' for grammar './TodoLangGrammar.g4'
Generating file 'C:\SAPDevelop\antlr4-monaco\.\src\ANTLR\.\TodoLangGrammarParser.ts' for grammar './TodoLangGrammar.g4'        
Generating file 'C:\SAPDevelop\antlr4-monaco\.\src\ANTLR\.\TodoLangGrammarListener.ts' for grammar './TodoLangGrammar.g4'      
Generating file 'C:\SAPDevelop\antlr4-monaco\.\src\ANTLR\.\TodoLangGrammar.tokens' for grammar './TodoLangGrammar.g4'

src/ANTLR에 만들어진 파일들을 확인해 보면 lexer와 parser가 있다. TodoLangGrammarParser 클래스의 생성자contructorTokenStream 형태의 인자를 받는다. 이 TokenStreamtheTodoLangGrammarLexer에 의해서 만들어 진다. theTodoLangGrammarLexer의 생성자는 CharStream 형태로 사용자가 작성한 코드를 인자로 받는다.

파서는 또한 public todoExpressions() 메서드를 가지는데 이 메서드는 TodoExpressions 의 모든 context를 TodoExpressionsContext 타입으로 리턴한다. TodoExpressions 은 문법 정의 파일로부터 왔음을 추측할 수 있을 것이다.

todoExpressions : (addExpression)* (completeExpression)*;

TodoExpressionsContext 는 AST의 root이다. 각 노드는 또다른 context와 rule을 가진다. Terminal와 Node Context가 있는데, Terminal은 ADD토큰, TODO토큰, "TODO에 대한 설명 문자열"과 같이 최종 토큰final token을 가진다.

TodoExpressionsContextaddExpressionscompleteExpressions를 포함하며, 다음 3개의 룰에 의해서 정의된다.

todoExpressions : (addExpression)* (completeExpression)*; 
addExpression : ADD TODO STRING;
completeExpression : COMPLETE TODO STRING;
  • 생성된 src/ANTLR/TodoLangGrammarParser.ts 의 TodoExpressionsContext 클래스
export class TodoExpressionsContext extends ParserRuleContext {
	public addExpression(): AddExpressionContext[];
	public addExpression(i: number): AddExpressionContext;
	public addExpression(i?: number): AddExpressionContext | AddExpressionContext[] {
		if (i === undefined) {
			return this.getRuleContexts(AddExpressionContext);
		} else {
			return this.getRuleContext(i, AddExpressionContext);
		}
	}
	public completeExpression(): CompleteExpressionContext[];
	public completeExpression(i: number): CompleteExpressionContext;
	public completeExpression(i?: number): CompleteExpressionContext | CompleteExpressionContext[] {
		if (i === undefined) {
			return this.getRuleContexts(CompleteExpressionContext);
		} else {
			return this.getRuleContext(i, CompleteExpressionContext);
		}
	}
	constructor(parent: ParserRuleContext | undefined, invokingState: number) {
		super(parent, invokingState);
	}
	// @Override
	public get ruleIndex(): number { return TodoLangGrammarParser.RULE_todoExpressions; }
	// @Override
	public enterRule(listener: TodoLangGrammarListener): void {
		if (listener.enterTodoExpressions) {
			listener.enterTodoExpressions(this);
		}
	}
  // ...
}

반면 Terminal Node 들을 포함한 context 클래스들은 기본적으로 텍스트를 가진다. (ADD토큰, TODO토큰, "TODO에 대한 설명 문자열") 다음의 TodoExpressionsContext 클래스에서 확인할 수 있듯이 ADD, TODO, STRING terminal 노드들을 가지며, 다음 문법 정의와 관련이 있다.

addExpression : ADD TODO STRING;
export class TodoExpressionsContext extends ParserRuleContext {
	public addExpression(): AddExpressionContext[];
	public addExpression(i: number): AddExpressionContext;
	public addExpression(i?: number): AddExpressionContext | AddExpressionContext[] {
		if (i === undefined) {
			return this.getRuleContexts(AddExpressionContext);
		} else {
			return this.getRuleContext(i, AddExpressionContext);
		}
	}
	public completeExpression(): CompleteExpressionContext[];
	public completeExpression(i: number): CompleteExpressionContext;
	public completeExpression(i?: number): CompleteExpressionContext | CompleteExpressionContext[] {
		if (i === undefined) {
			return this.getRuleContexts(CompleteExpressionContext);
		} else {
			return this.getRuleContext(i, CompleteExpressionContext);
		}
	}
	constructor(parent: ParserRuleContext | undefined, invokingState: number) {
		super(parent, invokingState);
	}
  // ...
}

이제 간단한 TodoLang 코드를 파싱해보고 어떤 AST가 생성되는지 확인한다.

AST의 생성

다음의 디렉토리와 파일을 하나 만든다.

  • src/language-service/parser.ts
import { TodoLangGrammarParser, TodoExpressionsContext } from "../ANTLR/TodoLangGrammarParser";
import { TodoLangGrammarLexer } from "../ANTLR/TodoLangGrammarLexer";
import { CharStreams, CommonTokenStream } from "antlr4ts";

export default function parseAndGetASTRoot(code: string): TodoExpressionsContext {
    const inputStream = CharStreams.fromString(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();
}

아 모듈이 하는 일은 parseAndGetASTRoot()를 export하는 것이 전부이며, 이 함수는 TodoLang 코드를 입력받고 AST를 생성하여 리턴한다. 다음 코드를 파싱해서 만들어진 AST를 확인해 보자. (원문에서 사용된 ANTLRInputStream는 deprecated 되어서 CharStreams.fromString()으로 변경했다.)

ADD TODO "Create an editor"
COMPLETE TODO "Create an editor"

현재 소스 기준으로 AST를 확인 하는 방법은 components/Editor/index.tsxReact.useEffect에 전달하는 콜백 함수의 가장 마지막 부분에 parseAndGetASTRoot()를 호출하고 console.log()로 결과를 보는 것이다.

  • components/Editor/index.tsx
const Editor: React.FC<IEditorProps> = (props: IEditorProps) => {
  // ...
  React.useEffect(() => {
    // ...
    const ast = parseAndGetASTRoot(`ADD TODO "Create an editor"\nCOMPLETE TODO "Create an editor"`);    console.log(ast);  }, [assignRef]);

  return <div ref={assignRef} className="editor-container"></div>;
};
// ...
웹브라우저 콘솔에서 확인한 AST
웹브라우저 콘솔에서 확인한 AST

참고: Webpack으로 인한 에러 2종 세트

위 과정 중 에러가 발생했다면 주목하길 바란다. 이 섹션이 바로 이 영어로된 포스트를 원문으로 부터 다시 작성해야겠다고 결심하게된 이유이다. 그대로 진행하면 npm run start에서는 다음과 같은 에러가 발생한다.

ERROR in ./node_modules/antlr4ts/dfa/DFAState.js 22:15-32
Module not found: Error: Can't resolve 'assert' in 'C:\SAPDevelop\antlr4-monaco\node_modules\antlr4ts\dfa'

또한 assert 모듈에 대한 문제를 어떻게 해결 했다고 하더라도 브라우저에서 다음과 같은 런타임 에러를 보게 된다.

Uncaught ReferenceError: process is not defined
    at eval (webpack://antlr4-monaco/./node_modules/util/util.js?:109)
    at Object../node_modules/util/util.js (bundle.5c6a9cec040cfb30eb78.js:9825)
...

원인은 node.js 모듈을 브라우저에서 사용하려고 하기 때문이다. 이전 버전까지의 webpack은 browser에서 node.js용 모듈을 사용해도 알아서 resolve해줬지만 Webpack 5는 더 이상 node.js에 대한 polyfills을 기본 제공하지 않는다.

Webpack 5 no longer polyfills Node.js core modules automatically which means if you use them in your code running in browsers or alike, you will have to install compatible modules from npm and include them yourself. Here is a list of polyfills webpack has used before webpack 5:

따라서 일반적인 해결 방법은 필요한 polyfiils 모듈을 알아서 설치하고 webpack.config.js에 다음과 같이 등록하는 것이다.

module.exports = {
  //...
  resolve: {
    fallback: {
      assert: require.resolve('assert'),
      buffer: require.resolve('buffer'),
      console: require.resolve('console-browserify'),
      constants: require.resolve('constants-browserify'),
      crypto: require.resolve('crypto-browserify'),
      domain: require.resolve('domain-browser'),
      events: require.resolve('events'),
      http: require.resolve('stream-http'),
      https: require.resolve('https-browserify'),
      os: require.resolve('os-browserify/browser'),
      // 이 많은걸??

하지만 열심히 구글링 한 결과 이 문제를 한방에 해결해 줄 수 있는 웹팩 플러그인이 있었다. 설치하고 한방에 해결한다.

참고: ANTLR4 VS Code Extension 설치

ANTLR4 grammar syntax support extension은 문법 파일(.g4)을 syntax highlighting해주고 문법 정의 파일을 수정할 경우 자동으로 렉서/파서를 재생성하게 할 수 있다.

VS Code의 settings.json에 다음을 추가한다.

{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "antlr4.generation": {    "outputDir": "src/ANTLR",    "mode": "external",    "language": "TypeScript",    "listeners": true,    "visitors": true  },
  "[antlr]": {    "editor.defaultFormatter": "mike-lischke.vscode-antlr4"  }}

editor.defaultFormatter 를 추가하여 .g4 파일도 자동으로 포맷팅할 수 있게 하면 더 편리하다.

어휘/구문 오류 검증 기능 구현

이 섹션에서는 현재까지 만들어진 에디터에 구문 검증syntax validation 기능을 구현한다. 우리는 ANTLRErrorListner 을 구현한 뒤 lexer와 parser에게 그것을 제공하고 ANTLR가 파싱할 때 에러를 수집하도록 해야 한다.

ANTLRErrorListener를 구현implements하여 ANTLRErrorListener을 만든다.

  • src/language-service/TodoLangErrorListener.ts
import { ANTLRErrorListener, RecognitionException, Recognizer } from "antlr4ts";

export interface ITodoLangError {
  startLineNumber: number;
  startColumn: number;
  endLineNumber: number;
  endColumn: number;
  message: string;
  code: string;
}

export default class TodoLangErrorListener implements ANTLRErrorListener<any> {
  private errors: ITodoLangError[] = [];
  syntaxError(
    recognizer: Recognizer<any, any>,
    offendingSymbol: any,
    line: number,
    charPositionInLine: number,
    message: string,
    e: RecognitionException | undefined
  ): void {
    this.errors.push({
      startLineNumber: line,
      endLineNumber: line,
      startColumn: charPositionInLine,
      endColumn: charPositionInLine + 1, //Let's suppose the length of the error is only 1 char for simplicity
      message,
      code: "1", // This the error code you can customize them as you want
    });
  }

  getErrors(): ITodoLangError[] {
    return this.errors;
  }
}

ANTLR parser가 코드를 파싱하던 중 에러를 만날때 마다 이 리스너를 에러에 대한 정보와 함께 호출할 것이다. 이 함수는 에러들에 대한 자세한 정보를 가진 목록과 에러 메세지를 리턴한다. parser.ts를 다음과 같이 수정한다.

  • src/language-service/parser.ts
import { CharStreams, CommonTokenStream } from "antlr4ts";
import { TodoLangGrammarLexer } from "../ANTLR/TodoLangGrammarLexer";
import {
  TodoExpressionsContext,
  TodoLangGrammarParser,
} from "../ANTLR/TodoLangGrammarParser";
import TodoLangErrorListener, { ITodoLangError } from "./TodoLangErrorListener";

function parse(code: string): {
  ast: TodoExpressionsContext;
  errors: ITodoLangError[];
} {
  const inputStream = CharStreams.fromString(code);
  const lexer = new TodoLangGrammarLexer(inputStream);
  lexer.removeErrorListeners();  const todoLangErrorsListener = new TodoLangErrorListener();  lexer.addErrorListener(todoLangErrorsListener);  const tokenStream = new CommonTokenStream(lexer);
  const parser = new TodoLangGrammarParser(tokenStream);
  parser.removeErrorListeners();  parser.addErrorListener(todoLangErrorsListener);  const ast = parser.todoExpressions();
  const errors: ITodoLangError[] = todoLangErrorsListener.getErrors();
  return { ast, errors };
}

export function parseAndGetASTRoot(code: string): TodoExpressionsContext {
  const { ast } = parse(code);
  return ast;
}

export function parseAndGetSyntaxErrors(code: string): ITodoLangError[] {
  const { errors } = parse(code);
  return errors;
}

이제 LanguageService.ts를 다음과 같이 추가 한다.

  • src/language-service/LanguageService.ts
import { parseAndGetSyntaxErrors } from "./Parser";
import { ITodoLangError } from "./TodoLangErrorListener";

export default class TodoLangLanguageService {
  validate(code: string): ITodoLangError[] {
    const syntaxErrors: ITodoLangError[] = parseAndGetSyntaxErrors(code);
    //Later we will append semantic errors
    return syntaxErrors;
  }
}

에디터에 에러를 보여줄 준비가 됐다. 이것을 위해서 우리의 language service를 동작하게 할 web worker와 worker service proxy를 만들 것이다.

Web Worker 만들기

우선 TodoLangWorker 클래스를 만든다. 이 것이 monaco에 의해서 proxied번역불가될 worker이다. TodoLangWorker는 에디터의 기능들을 수행하기 위해서 language service의 메서드들을 호출할 것이다. 그 메서드들은 web worker 안에서 실행되며 monaco에 의해서 proxied 된다. 그래서 web worker 안쪽에 있는 메서드를 호출하기 위해서는 main thread에서 proxied된 메서드를 호출하면 된다.

  • src/todo-lang/TodoLangWorker.ts
import * as monaco from "monaco-editor-core";

import IWorkerContext = monaco.worker.IWorkerContext;
import TodoLangLanguageService from "../language-service/LanguageService";
import { ITodoLangError } from "../language-service/TodoLangErrorListener";

export class TodoLangWorker {
  private _ctx: IWorkerContext;
  private languageService: TodoLangLanguageService;

  constructor(ctx: IWorkerContext) {
    this._ctx = ctx;
    this.languageService = new TodoLangLanguageService();
  }

  doValidation(): Promise<ITodoLangError[]> {
    const code = this.getTextDocument();
    return Promise.resolve(this.languageService.validate(code));
  }

  private getTextDocument(): string {
    const model = this._ctx.getMirrorModels()[0]; // When there are multiple files open, this will be an array
    return model.getValue();
  }
}

우리는 language service의 인스턴스를 하나 생성하고, doValidation() 메서드를 호출하여 language service에 validation을 요청한다. getTextDocument() 함수는 에디터로부터 코드를 가져오기 위한 것이다. _ctx: IWorkerContext 은 에디터의 context이며 model을 가지고 있다. 여러 개의 파일을 지원하기 위해서는 더 많은 것들을 구현해야 하기 때문에 본 예제에서는 하나의 코드만 다루도록 했다.

web worker인 todoLang.worker.ts를 추가한다.

  • src/todo-lang/todoLang.worker.ts
import * as worker from "monaco-editor-core/esm/vs/editor/editor.worker";
import { TodoLangWorker } from "./todoLangWorker";

self.onmessage = () => {
  worker.initialize((ctx) => {
    return new TodoLangWorker(ctx);
  });
};

우리는 작성한 worker를 초기화하기 위해서 내장된 worker.initialize()를 사용했고, TodoLangWorker 으로부터 필요한 메서드 프락시들을 생성했다.

Web Worker 코드 번들링

이제 webpack에게 이 파일이 자기 자신만의 파일로 번들링 해야 된다고 알려줘야 한다. (즉, 다른 파일과 같이 섞여서 번들링 되면 안된다는 뜻) webpack 설정파일에 추가 한다.

  • webpack.config.js
module.exports = {
  // ...
  entry: {
    app: "./src/index.tsx",
    "editor.worker": "monaco-editor-core/esm/vs/editor/editor.worker.js",    todoLangWorker: "./src/todo-lang/todoLang.worker.ts",,  },
  output: {
    globalObject: "self",
    filename: (chunkData) => {,      switch (chunkData.chunk.name) {,        case "editor.worker":,          return "editor.worker.js";,        case "todoLangWorker":,          return "todoLangWorker.js";,        default:,          return "bundle.[hash].js";,      },    },,    path: path.resolve(__dirname, "dist"),
  },
  resolve: {
    extensions: [".ts", ".tsx", ".js", ".jsx", ".css"],
  },
  // ...  
};

globalObjectself로 설정하는 것은 Monaco 에디터 때문이라고 한다. (왜 인지는 모른다. 그리고 webpack의 문서를 보면 기본 값이 self인 것으로 보인다.) 이 설정을 적용한 후, npm run start를 실행하면 어떤 파일들이 생성되는지 알 수 있다. todoLang.worker.tstodoLangWorker.js로 만들어지고, Monaco가 기본적으로 사용하고 있는 editor.worker.js 파일 또한 그대로 만들어진다.

assets by path *.js 13.9 MiB
  asset bundle.ce3865ad4f978c8918c1.js 11 MiB [emitted] [immutable] (name: app)
  asset todoLangWorker.js 2.03 MiB [emitted] (name: todoLangWorker)
  asset editor.worker.js 922 KiB [emitted] (name: editor.worker)

이 설정을 추가하기 전까지 브라우저의 콘솔 창에서 다음과 같은 에러를 출력하고 있었지만, 추가 이후에는 사라진다.

Could not create web worker(s). Falling back to loading web worker code in main thread, which might cause UI freezes. Please see https://github.com/Microsoft/monaco-editor#faq
You must define a function MonacoEnvironment.getWorkerUrl or MonacoEnvironment.getWorker

Web Worker URL을 monaco에게 알리기

다시 setup.ts를 수정한다.

  • src/todo-lang/setup.ts
export function setupLanguage() {
  (window as any).MonacoEnvironment = {
    getWorkerUrl: function (_moduleId, label) {
      if (label === languageID) {
        return "./todoLangWorker.js";
      }
      return "./editor.worker.js";
    },
  };
  // ...
}

이렇게 함으로써 monaco가 web worker의 URL을 얻을 수 있게 된다. LanguageIDTodoLang의 ID와 같은 경우, 우리가 webpack에 정의한 파일의 URL을 리턴한다. 빌드를 하거나 webpack dev server를 실행 한 이후라면 chrome devtools를 통해서, ANTLR 모듈들과 함께 번들링된 todoLangWorker.js를 확인할 수 있다.

Web Worker Manager 작성

이제 WorkerManager 클래스를 만든다. 이 클래스는 worker의 생성을 관리하고 client 사이드의 proxied worker를 가져올 수 있도록 해서 나중에 호출할 수 있도록 도와준다.

  • src/todo-lang/WorkerManager.ts
import * as monaco from "monaco-editor-core";

import Uri = monaco.Uri;
import { TodoLangWorker } from "./todoLangWorker";
import { languageID } from "./config";

export class WorkerManager {
  private worker: monaco.editor.MonacoWebWorker<TodoLangWorker>;
  private workerClientProxy: Promise<TodoLangWorker>;

  constructor() {
    this.worker = null;
  }

  private getClientProxy(): Promise<TodoLangWorker> {
    if (!this.workerClientProxy) {
      this.worker = monaco.editor.createWebWorker<TodoLangWorker>({
        // module that exports the create() method and returns a `JSONWorker` instance
        moduleId: "TodoLangWorker",
        label: languageID,
        // passed in to the create() method
        createData: {
          languageId: languageID,
        },
      });

      this.workerClientProxy = <Promise<TodoLangWorker>>(
        (<any>this.worker.getProxy())
      );
    }

    return this.workerClientProxy;
  }

  async getLanguageServiceWorker(...resources: Uri[]): Promise<TodoLangWorker> {
    const _client: TodoLangWorker = await this.getClientProxy();
    await this.worker.withSyncedResources(resources);
    return _client;
  }
}

createWebWorker() 로 web worker를 만들고 client proxy를 리턴 한다. 이제 Proxied 메서드들을 호출하기 위해서 workerClientProxy를 사용할 수 있다.

에러 마커 표시를 위한 Adapter 클래스 작성

DiagnosticsAdapter 클래스를 만들어 language service를 통해서 리턴 받은 에러들을 monaco 에디터가 에러로 표시할 수 있는 형태로 수정adapt한다.

  • src/todo-lang/DiagnosticsAdapter.ts
import * as monaco from "monaco-editor-core";
import { WorkerAccessor } from "./setup";
import { languageID } from "./config";
import { ITodoLangError } from "../language-service/TodoLangErrorListener";

export default class DiagnosticsAdapter {
  constructor(private worker: WorkerAccessor) {
    const onModelAdd = (model: monaco.editor.IModel): void => {
      let handle: any;
      model.onDidChangeContent(() => {
        // here we are Debouncing the user changes, so every time a new change is done, we wait 500ms before validating
        // 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> {
    // get the worker proxy
    const worker = await this.worker(resource);
    // call the validate method proxy from the language service and get errors
    const errorMarkers = await worker.doValidation();
    // get the current model(editor or file) which is only one
    const model = monaco.editor.getModel(resource);
    // add the error markers and underline them with severity of Error
    monaco.editor.setModelMarkers(
      model,
      languageID,
      errorMarkers.map(toDiagnostics)
    );
  }
}

function toDiagnostics(error: ITodoLangError): monaco.editor.IMarkerData {
  return {
    ...error,
    severity: monaco.MarkerSeverity.Error,
  };
}

사용자에 의한 모든 변경 사항을 처리하기 위한 onDidChangeContent 리스너를 추가했다. 500 ms 동안 이 변경을 디바운스debounce한 뒤, 코드 검증을 위해 worker를 호출하고 에러 마커들을 추가한다. onDidCreateModel 에 등록한 콜백 함수는 file(model)이 만들어질 때 호출되기 때문에 그와 같은 시점에 onDidChangeContent 리스너가 등록된다. setModelMarkersmonaco에게 에러 마커(에러 관련 코드에 밑줄을 긋는 것)를 추가한다.

이 검증 기능을 적용하기 위해서 setup 함수에서 호출을 해야 한다. 그리고 proxied worker를 가져오기 위해서 WorkerManager를 사용한다.

  • src/todo-lang/setup.ts
export function setupLanguage() {
  /// ...
  monaco.languages.onLanguage(languageID, () => {
    monaco.languages.setMonarchTokensProvider(languageID, monarchLanguage);
    const client = new WorkerManager();    const worker: WorkerAccessor = (      ...uris: monaco.Uri[]    ): Promise<TodoLangWorker> => {      return client.getLanguageServiceWorker(...uris);    };    //Call the errors provider 
    new DiagnosticsAdapter(worker);  });
}

export type WorkerAccessor = (...uris: monaco.Uri[]) => Promise<TodoLangWorker>;

구문 에러 검증 기능 테스트

이제 모든 것이 잘 동작해야 한다. npm run start로 프로젝트를 실행하고 문법에 맞지 않는 코드를 입력한다. 그러면 다음과 같은 밑줄 에러를 볼 수 있을 것이다.

구문 에러가 있는 곳에 에러 마크가 표시된다.
구문 에러가 있는 곳에 에러 마크가 표시된다.

시멘틱(Semantic) 검증 기능 구현

시멘틱 검증 기능을 추가해보고자 한다. TodoLang에는 두 개의 시멘틱 룰이 있다.

  • 이미 추가된 TODO (같은 설명 문자열울 가진 TODO) 는 다시 추가할 수 없다.
  • COMPLETE TODO 명령어는 ADD TODO를 통해서 이미 입력된 TODO가 아니면 실행될 수 없다.

하나의 TODO가 이미 등록되어 있는지 체크하려면 AST를 순회하여 모든 ADD expression을 가져와서 그 목록을 저장해야 한다. 그 후 그 목록에 TODO가 들어 있는지 체크한다. 만약 존재한다면 그 것은 시멘틱 에러이기 때문에 ADD expression의 context로 부터 에러의 위치를 가져와서 그 에러들을 배열에 저장한다. 두 번째 룰에 대해서도 똑같이 진행한다.

  • src/language-service/LanguageService.ts
export default class TodoLangLanguageService {
    validate(code: string): ITodoLangError[] {
        const syntaxErrors: ITodoLangError[] = parseAndGetSyntaxErrors(code);
        const ast: TodoExpressionsContext = parseAndGetASTRoot(code);        return syntaxErrors.concat(checkSemanticRules(ast));    }
    // ...
}

function checkSemanticRules(ast: TodoExpressionsContext): ITodoLangError[] { 
    const errors: ITodoLangError[] = [];
    const definedTodos: string[] = [];
    ast.children.forEach(node => {
        if (node instanceof AddExpressionContext) {
            // if a Add expression : ADD TODO "STRING"
            const todo = node.STRING().text;
            // If a TODO is defined using ADD TODO instruction, we can re-add it.
            if (definedTodos.some(todo_ => todo_ === todo)) {
                // node has everything to know the position of this expression is in the code
                errors.push({
                    code: "2",
                    endColumn: node.stop.charPositionInLine + node.stop.stopIndex - node.stop.stopIndex,
                    endLineNumber: node.stop.line,
                    message: `Todo ${todo} already defined`,
                    startColumn: node.stop.charPositionInLine,
                    startLineNumber: node.stop.line
                });
            } else {
                definedTodos.push(todo);
            }
        }else if(node instanceof CompleteExpressionContext) {
            const todoToComplete = node.STRING().text;
            if(definedTodos.every(todo_ => todo_ !== todoToComplete)){
                // if the the todo is not yet defined, here we are only checking the predefined todo until this expression
                // which means the order is important
                errors.push({
                    code: "2",
                    endColumn: node.stop.charPositionInLine + node.stop.stopIndex - node.stop.stopIndex,
                    endLineNumber: node.stop.line,
                    message: `Todo ${todoToComplete} is not defined`,
                    startColumn: node.stop.charPositionInLine,
                    startLineNumber: node.stop.line
                });
            }
        }

    })
    return errors;
}

존재 하지 않는 TODO에 대해서 COMPLETE TODO 명령어를 입력하면 다음과 같이 에러를 표시한다.

시멘틱 검증 에러
시멘틱 검증 에러

자동 포맷팅(Auto-Formatting)의 구현

자동 포맷팅을 구현하기 위해서는 registerDocumentFormattingEditProvider()` 를 이용해서 formatting provider를 등록해야 한다. 자세한 것은 링크를 통해 문서를 확인한다. AST를 순회하여 코드를 예쁜 형식pretty format으로 다시 작성하는데 필요한 모든 정보를 얻을 수 있다.

LanguageService에 다음과 같이 format method를 추가 한다. validation을 먼저 수행한 뒤, 에러가 있으면 포맷팅을 진행하지 않는다.

export default class TodoLangLanguageService {
  // ...
  format(code: string): string {
    // if the code contains errors, no need to format, because this way of formating the code, will remove some of the code
    // to make things simple, we only allow formatting a valide code
    if (this.validate(code).length > 0) return code;
    let formattedCode = "";
    const ast: TodoExpressionsContext = parseAndGetASTRoot(code);
    ast.children.forEach((node) => {
      if (node instanceof AddExpressionContext) {
        // if a Add expression : ADD TODO "STRING"
        const todo = node.STRING().text;
        formattedCode += `ADD TODO ${todo}\n`;
      } else if (node instanceof CompleteExpressionContext) {
        // If a Complete expression: COMPLETE TODO "STRING"
        const todoToComplete = node.STRING().text;
        formattedCode += `COMPLETE TODO ${todoToComplete}\n`;
      }
    });
    return formattedCode;
  }
}
// ...

TodoLangWorker 클래스에도 format 메서드를 추가한다.

  • src/todo-lang/TodoLangWorker.ts
export class TodoLangWorker {
  // ...
  format(code: string): Promise<string>{
    return Promise.resolve(this.languageService.format(code));
  }
}

DocumentFormattingEditProvider 인터페이스를 구현하는 TodoLangFormattingProvider 클래스를 만든다.

  • src/todo-lang/TodoLangFormattingProvider.ts
import * as monaco from "monaco-editor-core";
import { WorkerAccessor } from "./setup";

export default class TodoLangFormattingProvider
  implements monaco.languages.DocumentFormattingEditProvider
{
  constructor(private worker: WorkerAccessor) {}

  provideDocumentFormattingEdits(
    model: monaco.editor.ITextModel,
    options: monaco.languages.FormattingOptions,
    token: monaco.CancellationToken
  ): monaco.languages.ProviderResult<monaco.languages.TextEdit[]> {
    return this.format(model.uri, model.getValue());
  }

  private async format(
    resource: monaco.Uri,
    code: string
  ): Promise<monaco.languages.TextEdit[]> {
    // get the worker proxy
    const worker = await this.worker(resource);
    // call the validate method proxy from the language service and get errors
    const formattedCode = await worker.format(code);
    const endLineNumber = code.split("\n").length + 1;
    const endColumn =
      code
        .split("\n")
        .map((line) => line.length)
        .sort((a, b) => a - b)[0] + 1;
    console.log({ endColumn, endLineNumber, formattedCode, code });
    return [
      {
        text: formattedCode,
        range: {
          endColumn,
          endLineNumber,
          startColumn: 0,
          startLineNumber: 0,
        },
      },
    ];
  }
}

code를 가져오고 worker를 이용해서 코드를 포맷팅한다. 그 후, monaco에게 포맷된 코드와 교체될 코의 범위를 제공provides한다. 코드를 수정하면 부분적인 포맷팅 또한 구현이 가능하다. 마지막으로 registerDocumentFormattingEditProvider API를 이용해서 formatting provider를 등록한다.

  • src/todo-lang/setup.ts
export function setupLanguage() {
  // ...
  monaco.languages.onLanguage(languageID, () => {
    // ...
    monaco.languages.registerDocumentFormattingEditProvider(
      languageID,
      new TodoLangFormattingProvider(worker)
    );
  });
}
// ...

프로젝트를 실행한 후 자동 포맷팅이 실행되는 것을 확인한다.

포맷팅 전의 코드
포맷팅 전의 코드

마우스 오른쪽 버튼을 눌러서 컨텍스트 메뉴에서 Format document를 클릭하거나 "Shift + ALT + F" 단축키를 입력하면 다음과 같은 결과를 볼 수 있다.

포맷팅 이후의 코드
포맷팅 이후의 코드

끝.