이 포스트에서는 새로운 Language를 정의하고 그 것의 문법을 지원하는 Custom Editor를 만드는 방법을 단계적으로 설명하고자 한다. 본 포스트는 Create a Custom Web Editor Using TypeScript, React, ANTLR, and Monaco Editor 으로 부터 발췌 했으며, 원문에서 사용된 패키지가 업데이트 되어 에러가 발생하는 부분을 최신 소스에 맞게 수정했다. (Tutorial 따라하기를 멈춰야 할 정도의 심각한 버그가 webpack.config.js
안에 숨어있었다. 😥)
만들고자하는 새로운 언어의 형식
우리가 만들고자하는 새로운 언어는 매우 간단하다. 몇 개의 정해진 명령어predefined instructions를 이용하여 TODO 리스트 선언하기 위한 것이다. 우리는 이 것을 TodoLang
이라고 부르기로 한다. 그 명령어들의 예는 다음과 같다.
ADD TODO "Make the world a better place"
ADD TODO "read daily"
ADD TODO "Exercise"
COMPLETE TODO "Learn & share"
우리는 ADD TODO "some text"
를 이용해서 TODO 하나를 목록에 추가할 수 있으며 COMPLETE TODO "some text"
를 이용해서 TODO 하나를 끝낼 수 있다. 그리고 이 코드를 해석interpreting한 결과를 이용하여, 아직 남은 목록과 현재까지 끝낸 목록을 제공할 수 있다. 우리는 에디터가 다음 기능들을 지원하도록 만들 것이다.
- 자동 포맷팅 Auto formatting
- 자동 완성 Auto completion
- 구문 강조 Syntax highlighting
- 문법적, 의미적 검증 Syntax and semantic validation
TodoLang의 규칙(Semantic Rules)
우리가 의미적 검증Semantic validation을 하고자 하는 몇가지 규칙을 정의한다.
- 만약
ADD TODO
를 이용해서 하나의 TODO가 이미 추가 되어 있으면 다시 추가할 수 없다. ADD TODO
를 통해서 추가된 TODO가 아니면COMPLETE
명령어를 사용할 수 없다.
Language Service 작동 방식
Visual Studio의 온라인 버전, CodeSandbox, 또는 Snack 이 어떻게 만들어져 있는지 궁금한적이 있는 사람은 이 섹션이 도움이 될 것이다. 다음은 일반적인 웹에디터 혹은 다른 어떤 종류의 에디터들이 가지고 있는 일반적인 구조이다.

위의 구조를 통해 알 수 있듯이 일반적으로 두개의 쓰레드thread를 사용한다. 하나는 사용자의 입력을 기다리거나 화면을 업데이트 하는 역할을 하는 UI thread, 다른 하나는 사용자가 변경한 코드를 가져와서 코드 파싱code parsing과 컴파일compilation과 같은 무거운 작업들을 한다.
에디터에서 발생하는 모든 변경에 대해서(사용자가 입력을 멈춘지 2초가 지난 후), 메세지들은 Language Service Worker로 보내진다. Worker는 그 것에 대한 결과를 담은 메세지로 응답한다. 예를 들어, 사용자가 코드를 입력(작성)하고 그 코드를 포맷팅format하기를 원한다면, worker는 Format
액션과 포맷 전의 코드를 포함한 메세지를 받을 것이다. 이 과정은 사용자 경험user experience을 해치지 않기 위해서 비동기적으로 일어나야 한다.
반면 Language service는 코드를 파싱하고 Abstract syntax tree를 만들고, 문법적/어휘적 에러를 찾고, 의미적인 에러를 찾아내기 위해서 AST를 이용하며, code를 포맷팅하는 역할을 한다.
우리는 LSP Protocol를 이용해서 더 좋은 방법으로 Language service를 만들 수 있다. 하지만 이 포스트의 예제에서는 에디터와 Language Service가 같은 웹브라우저의 같은 프로세스 안에 있고, 백엔드 서비스를 이용하지 않기 때문에 LSP를 사용하지 않는다. 만약 VS Code, Sublime, Eclipse와 같은 다른 에디터에서 새로운 Language 지원되길 원한다면, Language Service와 Worker를 분리하는게 나을 수 있다. LSP
를 구현하는 것은 그 에디터들에서 사용가능한 플러그인들을 만들 수 있게 해준다. LSP에 대해서 조금 더 알고 싶다면 링크를 통해서 확인하자.

에디터는 인터페이스를 제공한다. 그 인터페이스는 사용자가 코드를 입력하고 어떤 액션을 할 수 있게 해 준다. 사용자가 입력을 하면, 에디터는 코드의 토큰들tokens(keywords, types, etc.)을 강조highlight하기 위해서 설정a list of configurations을 조회한다. 이 것은 Language Service에 의해서도 가능하지만, 여기의 예제에서는 에디터에서 Syntax highlighting 작업을 수행한다. 이 포스트의 마지막 섹션에서 확인할 수 있다.
Monaco 는 monaco.editor.createWebWorker() API를 제공하는데 그 것은 브라우저 내장 ES6 Proxy를 이용하여 하나의 proxy web worker를 만든다. MonacoWebWorker.getProxy()로 Language Service의 proxy object를 가져올 수 있다. Language service worker의 어떤 서비스에든 접근을 하려면 이 proxied object를 이용할 것이다. 모든 메서드는 Promise
객체를 리턴한다.
Comlink를 확인해 보자. 구글이 만든 이 작은 라이브러리는 ES6 Proxy를 이용해서 web worker와의 작업을 즐겁게(?)enjoyable하게 만들어 준다고 한다.
거두절미하고 이제 코딩을 시작할 시간이다.
프로젝트 준비 단계
필요한 모듈
1. React
UI를 위해서 사용한다. 너무나도 유명하므로 설명은 생략.
2. ANTLR
ANTLR 웹사이트에 나온 설명이다.
ANTLR (ANother Tool for Language Recognition, 발음은 앤틀러가 가깝다.)은 구조화된 텍스트 혹은 바이너리 파일을 읽고, 처리하고, 실행하고, 번역하기 위한 강력한 파서 생성기parser generator이다. 그것은 각종 언어, 도구들, 프레임워크들을 만들 때 널리 사용되고 있다. 문법 정의로 부터, ANTLR는 parse tree를 만들고 순회할 수 있는 파서를 만든다.
ANTLR는 target 언어로 많은 종류의 언어를 지원한다. 그 것 Java나 C# 등과 같은 언의 파서를 만들 수 있다는 것을 의미한다. 이 포스트에서 나는 antlr4ts를 이용할 예정이다. 그 것은 Node.js 버전의 ANTLR이며 TypeScript로 작성된 lexer와 parser를 생성한다.
ANTLR는 언어 문법을 정의 하기 위한 특별한 문법을 사용한다. 그 것은 일반적으로 *.g4
파일에 작성된다. 이 것을 통해서 렉서lexer와 파서parser 규칙을 하나의 문법 파일에 정의할 수 있다. 이 repository에서 유명한 language들에 대한 많은 문법 정의를 찾을 수 있다.
이 문법 구문grammar syntax은 BNF(Backus normal form)로 알려진 표기법을 사용하여 이 언어들의 구문구문syntax을 나타낸다.
3. TodoLang 문법
여기서 TodoLang 언어의 간단한 문법을 정의한다. root rule인 todoExpressions
은 표현식들expressions의 목록을 가진다. 표현식들은 addExpression
or completeExpression
가 될 수 있다. Asterisk *
는 정규식에서 표현식들이 0회 혹은 그 이상의 횟수로 나올 수 있다는 것을 의미한다.
각각의 표현식은 terminal keyword (add
, todo
, complete
) 로 시작하고, 할일(TODO)를 식별할 수 있는 string
을 가진다.
TodoLangGrammar.g4
grammar TodoLangGrammar;
todoExpressions : (addExpression)* (completeExpression)*;
addExpression : ADD TODO STRING EOL;
completeExpression : COMPLETE TODO STRING EOL;
ADD : 'ADD';
TODO : 'TODO';
COMPLETE: 'COMPLETE';
STRING: '"' ~ ["]* '"';
EOL: [\r\n] +;
WS: [ \t] -> skip;
4. Monaco Editor
VS Code는 Monaco Editor로 만들어졌다. Syntax highlighting과 auto completion 등을 위한 API를 제공하는 JavaScript 라이브러리이다.
5.TypeScript (ts-loader)와 Webpack (dev-server, cli, HtmlWebpackPlugin)
TypeScript와 Webpack을 사용할 것이며, 그에 필요한 package들을 설치할 것이다. (Webpack에 대한 내용은 방대하므로 천천히 이해해 나가는 것이 좋다.)
새로운 TypeScript 프로젝트 생성
프로젝트를 초기화 한다.
npm init
간단한 tsconfig.json
파일을 추가한다. 에러를 방지 하기 위해서 exclude
와 include
를 추가 했다.
tsconfig.json
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"allowJs": true,
"jsx": "react"
},
"exclude": [
"node_modules",
],
"include": [
"./src/",
]
}
React 와 Webpack의 설치
먼저 react를 설치한다.
npm add react react-dom
그 외 --save-dev
옵션으로 typescript와 webpack 등 개발에 필요한 패키지를 설치한다.
npm add --save-dev typescript @types/react @types/react-dom ts-loader html-webpack-plugin webpack webpack-cli webpack-dev-server
파일 로더 정보들과 entry
페이지 정보 등을 담고 있는 간단한 webpack.config.js
파일을 추가한다.
webpack.config.js
const path = require("path");
const htmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
mode: "development",
entry: {
app: "./src/index.tsx",
},
output: {
filename: "bundle.[hash].js",
path: path.resolve(__dirname, "dist"),
},
resolve: {
extensions: [".ts", ".tsx", ".js", ".jsx"],
},
module: {
rules: [
{
test: /\.tsx?/,
loader: "ts-loader",
},
],
},
plugins: [
new htmlWebpackPlugin({
template: "./src/index.html",
}),
],
};
Monaco Editor 설치
Monaco Editor의 코어 버전을 설치한다.
npm add monaco-editor-core
Monaco Editor가 사용하는 stylesheet를 로드하기 위한 로더들loaders을 설치한다.
npm add -D style-loader css-loader
Monaco가 내부적으로 사용하는 stylesheet들을 로드하기 위해서 webpack.config.js
의 extensions
에 .css
를 추가한다. 물론 우리가 간단한 stylesheet을 추가하기 위해서도 꼭 필요하다. module.rules
에는 .css
파일을 처리하기 위한 룰을 추가한다. (⛔ 경고! 원문에서는 tsx의 룰 정의에 사용된 정규식이 잘못 되어 .css
가 제대로 처리 되지 못하는 심각한 버그가 있었는데 원인을 겨우 찾았다. 주의할 것.)
- webpack.config.js
resolve: {
extensions: [".ts", ".tsx", ".js", ".jsx", ".css"], },
// ...
module: {
rules: [
{
test: /\.tsx?/,
loader: "ts-loader",
},
{ test: /\.css$/, use: ["style-loader", "css-loader"], }, ],
},
템플릿 페이지 추가
React
컴포넌트가 렌더링될 html
템플릿 페이지를 추가한다. container
라는 ID를 가진 <div>
에 렌더링할 예정이다.
- src/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>TodoLang Editor</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div id="container"></div>
</body>
</html>
webpack.config.js
에 entry point로 등록한 index.tsx
파일을 작성한다. App
이라는 루트 컴퍼넌트를 추가한다.
- src/index.tsx
import * as React from 'react';
import * as ReactDOM from 'react-dom';
const App = () => <div className="title">TodoLang Editor</div>;
ReactDOM.render(<App/>, document.getElementById('container'));
현재까지의 결과물 확인
다음의 명령어로 webpack-dev-server
를 실행하여 현재까지 만든 결과를 확인할 수 있다.
npm run start
TodoLang Editor 라는 제목이 화면 상단에 보인다면 지금까지는 성공이다.
Monaco Editor 인스턴스 생성하기
Monaco Editor 인스턴스를 생성하기 위해서는 monaco.editor.create()를 호출해야 한다. 함수의 인자로 렌더링에 사용될 DOM Element
와 Editor의 옵션을 객체를 넘긴다. 다음과 같이 Editor component를 생성한다.
- src/components/Editor/index.tsx
import * as React from "react";
import * as monaco from "monaco-editor-core";
interface IEditorProps {
language: string;
}
const Editor: React.FC<IEditorProps> = (props: IEditorProps) => {
let divNode;
const assignRef = React.useCallback((node) => {
// On mount get the ref of the div and assign it the divNode
divNode = node;
}, []);
React.useEffect(() => {
if (divNode) {
monaco.editor.create(divNode, {
language: props.language,
minimap: { enabled: true },
autoIndent: "full",
theme: "vs-dark",
mouseWheelZoom: true,
fontSize: 25,
value: `ADD TODO "Make the world a better place"\nADD TODO "read daily"\nADD TODO "Exercise"\nCOMPLETE TODO "Learn & share"`,
});
}
}, [assignRef]);
return <div ref={assignRef} className="editor-container"></div>;
};
export { Editor };
만들었던 index.tsx
에 Editor
컴퍼넌트를 추가한다.
- src/index.tsx
import * as React from "react";
import * as ReactDOM from "react-dom";
import { Editor } from "./components/Editor";
import "./style.css";
const App = () => (
<>
<div className="title">TodoLang Editor</div>
<Editor language={null}></Editor>
</>
);
ReactDOM.render(<App />, document.getElementById("container"));
간단한 스타일도 추가한다.
- src/style.css
html,
body,
#container {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
}
.title {
height: 50px;
}
.editor-container {
height: calc(100% - 50px);
overflow: hidden;
width: 100%;
}
여기까지 진행 후, 다시 npm run start
를 이용해서 확인해 보면 다음과 같은 화면을 볼 수 있다. 그러나 Syntax가 highlight되지 않은 상태이다.

구문 강조(Syntax highlighting) 기능 추가
새로운 Language로 등록
Monaco에서 지원하는 Language는 JavaScript
, Java
와 같이 고유의 이름을 가져야 한다. 우리가 만든 새로운 Language 이름을 상수로 저장한다.
- src/todo-lang/config.ts
import * as monaco from "monaco-editor-core";
export const languageID = "todoLang";
export const languageExtensionPoint: monaco.languages.ILanguageExtensionPoint =
{
id: languageID,
};
새로운 Language를 등록하기 위해서 setupLanguage()
함수를 가진 모듈을 생성한다.
- src/todo-lang/setup.ts
import * as monaco from "monaco-editor-core";
import { languageExtensionPoint, languageID } from "./config";
import { richLanguageConfiguration, monarchLanguage } from "./TodoLang";
export function setupLanguage() {
monaco.languages.register(languageExtensionPoint);
monaco.languages.onLanguage(languageID, () => {
});
}
App
컴퍼넌트를 생성하기 전에 setupLanguage()
를 호출하여 새로운 Language를 등록하도록 한다.
- src/index.ts
// ...
import { setupLanguage } from "./todo-lang/setup";
setupLanguage();const App = () => (/*...*/);
// ...
Language Definition 생성
이 섹션에서는 특정 키워드에 색상을 입히는 구문 강조 기능syntax highlight을 추가한다. Monaco Editor는 구문 강조를 위해서 Monarch Library를 사용한다. Monarch는 어떤 키워드에 어떤 색상을 입힐지에 대한 지시를 선언적declarative인 방식으로 정의할 수 있게 해 준다. 이 때 사용되는 형식은 JSON
(정확한 표현은 Javascript Object형식) 이다. 문법 강조를 위한 자세한 문법 설명은 링크의 문서를 참조한다.
구문 강조를 위해서 monaco.languages.IMonarchLanguage
인터페이스에 맞는 Javascript Object를 정의한다. monaco.languages.setMonarchTokensProvider()를 호출하여 TodoLang의 highlighter와 tokenizer를 설정할 수 있다. 이 함수는 두개의 파라미터를 받는데 하나는 Language의 고유한 ID, 다른 하나는 IMonarchLanguage
인터페이스에 맞게 내용이 작성된 Language definition object이다.
- src/todo-lang/TodoLang.ts
import * as monaco from "monaco-editor-core";
import IRichLanguageConfiguration = monaco.languages.LanguageConfiguration;
import ILanguage = monaco.languages.IMonarchLanguage;
export const monarchLanguage:ILanguage = {
// Set defaultToken to invalid to see what you do not tokenize yet
defaultToken: 'invalid',
keywords: [
'COMPLETE', 'ADD',
],
typeKeywords: ['TODO'],
escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,
// The main tokenizer for our languages
tokenizer: {
root: [
// identifiers and keywords
[/[a-zA-Z_$][\w$]*/, {
cases: {
'@keywords': { token: 'keyword' },
'@typeKeywords': { token: 'type' },
'@default': 'identifier'
}
}],
// whitespace
{ include: '@whitespace' },
// strings for todos
[/"([^"\\]|\\.)*$/, 'string.invalid'], // non-teminated string
[/"/, 'string', '@string'],
],
whitespace: [
[/[ \t\r\n]+/, ''],
],
string: [
[/[^\\"]+/, 'string'],
[/@escapes/, 'string.escape'],
[/\\./, 'string.escape.invalid'],
[/"/, 'string', '@pop']
]
},
}
COMPELTE
와 ADD
키워드의 경우 keyword
클래스를. TODO
키워드의 경우 type
클래스를 부여한다. 그리고 string
에 대해서는 string
클래스를 부여한다. 이 클래스들은 이미 Monaco에 정의되어 있는 것들이지만 defileTheme() API를 통해서 오버라이드할 수 있다. 마지막으로 위에서 언급된 setMonarchTokensProvider()
를 이용해서 Monaco Editor에 새로운 Language Definition 을 등록한다. 이 API를 onLanguage
콜백 안에서 호출되도록 구현한다.
- src/todo-lang/setup.ts
import * as monaco from "monaco-editor-core";
import { languageExtensionPoint, languageID } from "./config";
import { richLanguageConfiguration, monarchLanguage } from "./TodoLang";
export function setupLanguage() {
monaco.languages.register(languageExtensionPoint);
monaco.languages.onLanguage(languageID, () => {
monaco.languages.setMonarchTokensProvider(languageID, monarchLanguage); });
}

이것으로 Syntax highlighting 까지 구현이 되었다. 다음 포스트에서는 language service를 추가할 것이다. TodoLang의 렉서와 파서를 생성하기 위해서 ANTLR를 사용할 것이고, 파서가 제공하는 AST를 이용하여 에디터에 필요한 대부분의 기능을 구현할 것이다. 또한 우리는 language service에 auto-completion 기능을 제공하기 한 web worker가 어떻게 만들어지는지 파악할 수 있을 것이다.
끝!