Всем привет! Продолжаем делиться тем как мы разрабатываем наш инструмент моделирования. К предыдущей статье было много вопросов о том можно ли модели хранить и редактировать в текстовом виде, как насчёт architecture as code, code first и т. д. Мне хотелось бы написать, что мы добавили эту фичу в текущем релизе, но нет, пока есть только такая демка с исходниками. И в этой статье я расскажу как она сделана, как запилить свой предметно‑ориентированный язык (DSL) и текстовый редактор для него.
Будем использовать:
Monaco Editor — текстовый редактор, который используется в VS Code,
Langium — фреймворк, который позволяет реализовать LSP‑сервер для предметно‑ориентированного языка с поддержкой автодополнения, навигации по коду и т. д.,
Monaco Language Client — фреймворк, который позволяет подключать Monaco Editor к LSP‑серверу.

Метамодель
Первое что необходимо сделать при разработке DSL — это определить что мы с помощью него будем описывать: структуру данных, процессы, архитектуру предприятия, архитектуру программного обеспечения, ИТ инфраструктуру, API, цели, риски, требования, рецепты приготовления супа, отношения между сусликами, ...
Остановимся на максимально простой задаче — моделирование данных. Хотя даже для такой, казалось бы очевидной вещи, есть множество подходов и нотаций: ER, IDEF1X, Anchor, объектно‑ролевые модели, модели классов, или например, XML Schema, JSON Schema, DBML, какие‑нибудь более замороченные подходы типа ISO/IEC 11179, ISO 20022, NIEM, UN/CEFACT CCTS. Не будем проваливаться в эту кроличью нору, ограничимся моделями классов причём даже без методов и сложных видов связей.
Что вообще такое модель классов? Вместо строгого определения просто опишу из чего такая модель обычно состоит. Она содержит описание классов объектов в вашей предметной области. У классов могут быть атрибуты, для атрибутов может задаваться область допустимых значений (тип данных). Между классами могут быть связи, они могут быть одно‑ или двунаправленными, могут быть разных типов (ассоциация, композиция, агрегация). Для атрибутов и связей задаётся обязательность и множественность. Классы могут образовывать иерархию, могут быть связаны отношением обобщения. Если планируется генерироваться по этим моделям код, то есть смысл различать обычные классы, абстрактные классы и интерфейсы.
Вот, пример модели классов:

Чуть выше мы фактически описали метамодель для моделей классов:
объекты каких типов такие модели могут содержать (классы, атрибуты, связи, типы данных),
с помощью каких типов связей эти объекты могут быть связаны между собой (атрибуты связаны с классами, которым они принадлежат, и с типами данных, ...),
какие атрибуты у этих объектов и связей могут быть (название, множественность, ...).
Для человека или для языковой модели такого описания моделей классов на русском языке достаточно. Но для инструмента моделирования нужно более формальное описание, например, на языке OMG Meta Object Facility (MOF). Это специальный язык моделирования для описания языков моделирования, на котором, например, описаны BPMN, UML, SysML, ...
Наш язык моделирования классов более формально можно описать так:

Не знаю как вы, но лично я балдею с этой прикормки платформо‑независимой системы типы. Из модели потом можно генерировать что угодно: Java‑код, SQL‑код для разных СУБД, XML‑схемы, документацию, ...
Благодаря этой схеме:
мы лучше понимаем что вообще за язык моделирования или DSL мы создаём, в каких терминах мы будем описывать нашу предметную область,
в нашем инструменте моделирования появляется возможность работать с этими моделями,
мы можем переиспользовать и метамодель, и модели в любых других инструментах моделирования, реализующих стандарт OMG MOF.
Грамматика
Возможно вы уже пишите комментарий к статье: «Модели, модели, метамодели, модели для метамоделей, ... Чел, где обещанный код, где DSL? Сколько можно смотреть на эти картинки? У статьи недостаточный технический уровень для хабра, почему было бы просто не написать статью про зарплаты, выгорание или хотя бы ИИ?!» Понимаю ваше негодование, и одними метамоделями действительно сыт не будешь. Они описывают только структуру моделей, только их смысловую составляющую. Метамодель не описывает никакие формы представления моделей (диаграмма, таблица, текст, ...).
Чтобы у наших моделей классов появились текстовые представления необходимо определить как все эти классы, атрибуты, связи, типы данных и т. д. могут быть описаны в текстовом виде. Для этого опишем грамматику нашего DSL. Есть разные языки, которые позволяют это сделать, например, EBNF, PEG и инструменты ANTLR, EMFText, Xtext, Chevrotain, Langium, в Isabelle очень клёвые возможности для описания синтаксиса.
Мы остановимся на Langium, потому что в нашем инструменте моделирования пользователи могут сами создавать новые языки моделирования и соответственно в будущем определять для них текстовую нотацию, мы не будем ограничиваться только моделями классов. Поэтому ANTLR или Xtext отпадают, потому что мы не можем для парсера генерировать пользовательский Java‑код, публиковать его на сервере. Хотя и Langium в значительной степени заточен на кодогенерацию, но он генерирует хотя бы TypeScript‑код, плюс может работать и без кодогенерации, чем мы и воспользуемся в этой статье.
Но так сходу описать грамматику для языка сложно, для начала можно написать пример кода на нашем будущем DSL (полный пример):
@name('ru-RU', 'модель данных интернет-магазина')
classModel OnlineStore
@name('ru-RU', 'пользователь')
@description('ru-RU', 'пользователь системы, который может делать заказы')
class User {
@name('ru-RU', 'имя')
@name('fr-FR', 'prénom')
attribute firstName String[0..1]
attribute lastName String[0..1]
attribute birthDate Date[0..1]
attribute email String
}
Полный пример модели классов
@name('ru-RU', 'модель данных интернет-магазина')
classModel OnlineStore
@name('ru-RU', 'пользователь')
@description('ru-RU', 'пользователь системы, который может делать заказы')
class User {
@name('ru-RU', 'имя')
@name('fr-FR', 'prénom')
attribute firstName String[0..1]
@name('ru-RU', 'фамилия')
attribute lastName String[0..1]
@name('ru-RU', 'дата рождения')
attribute birthDate Date[0..1]
@name('ru-RU', 'электронная почта')
attribute email String
}
@name('ru-RU', 'заказ')
class Order {
@name('ru-RU', 'адресс доставки')
attribute deliveryAddress String
@name('ru-RU', 'пользователь')
reference user User
@name('ru-RU', 'элементы заказа')
composition items OrderItem[0..*] opposite order
}
@name('ru-RU', 'элемент заказа')
class OrderItem {
@name('ru-RU', 'заказ')
reference order Order opposite items
@name('ru-RU', 'продукт')
reference product Product
@name('ru-RU', 'количество')
attribute quantity UnsignedInt
@name('ru-RU', 'стоимость')
attribute price Money
}
@name('ru-RU', 'продукт')
abstract class Product {
@name('ru-RU', 'название')
attribute name String
}
@name('ru-RU', 'книга')
class Book extends Product {
@name('ru-RU', 'автор')
attribute author String
}
@name('ru-RU', 'ручка')
class Pen extends Product {
@name('ru-RU', 'цвет')
attribute color Color
}
@name('ru-RU', 'строка')
string String {
}
@name('ru-RU', 'целое беззнаковое число')
numeric UnsignedInt {
fractionDigits 0
minInclusive 0
}
@name('ru-RU', 'дата')
time Date {
instantUnits year month day
}
@name('ru-RU', 'денежный тип')
numeric Money {
totalDigits 19
fractionDigits 4
minInclusive 0
}
@name('ru-RU', 'цвет')
enumerated Color {
@name('ru-RU', 'красный')
Red
@name('ru-RU', 'зелёный')
Green
@name('ru-RU', 'синий')
Blue
@name('ru-RU', 'чёрный')
Black
}
Т. е. мы хотим получить DSL, который позволяет описывать модель классов, у которой есть техническое название (OnlineStore — оно может использоваться при генерации кода или в качестве названия базы данных), есть локализованное название (может использоваться в документации или в пользовательском интерфейсе). Модель классов состоит из описания классов, у которых так же есть техническое название, локализованное название, описание. Классы состоят из атрибутов, у которых есть тип данных, множественность (например, firstName — опциональный атрибут, а email — обязательный).
Почему синтаксис именно такой, почему нельзя было взять готовый DBML? Потому что DBML — это совершенно другой язык, он описывает предметную область не в терминах классов, атрибутов, связей и т. д., а в терминах таблиц, столбцов, ключей и т. д. А мне хочется наследование классов, локализацию, чего в DBML нет, и совершенно не хочется описывать в модели первичные и внешние ключи, потому что это детали реализации. Это просто два разных языка с двумя разными метамоделями. Как я уже упоминал выше, есть множество разных языков для моделирования данных и ни один из них не является более правильным или универсальным, они могут использоваться для решения разных задач. Мы вернёмся к этому в будущих статьях, но вообще ничего не мешает реализовать в нашем инструменте моделирования оба языка (и модели классов, и DBML) и потом просто преобразовывать одни модели в другие.
Та же история с PlantUML, да, в нём можно описать модель классов, но, например, мне не хватает локализации. Вы не обязаны воспринимать существующие языки моделирования или DSL как данность. Если они не соответствуют вашим требованиям, то ничего не мешает вам создать свой язык. А потом просто реализовать преобразование с одного языка на другой. Поэтому я так много внимания уделяю метамоделям. Если использование существующего языка моделирования, предметно‑ориентированного языка или языка программирования общего назначения приносит вам боль и страдание, то вы вольны создавать свои языки. Ссылка на наш мотивирующий телеграм канал и кошелек для донатов будет в конце статьи.
Теперь мы представляем что за DSL мы хотим получить, и можно приступить к описанию его грамматики. По ссылке доступна полная грамматика:
Грамматика для моделей классов
grammar ClassModel
entry ClassModel:
Localization*
'classModel' name=ID
(classes+=Class | dataTypes+=DataType)*;
Class:
Localization*
kind=ClassKind name=ID ('extends' generals+=[Class:ID] (',' generals+=[Class:ID])*)? ('{'
properties+=Property*
'}')?;
ClassKind:
{infer ClassKind__Regular} 'class' |
{infer ClassKind__Abstract} 'abstract' 'class' |
{infer ClassKind__Interface} 'interface';
Property:
Attribute | Reference;
Attribute:
Localization*
'attribute' name=ID dataType=[DataType:ID] Multiplicity?;
Reference:
Localization*
kind=ReferenceKind name=ID target=[Class:ID] Multiplicity? ('opposite' opposite=[Reference:ID])?;
ReferenceKind:
{infer ReferenceKind__Association} 'reference' |
{infer ReferenceKind__Composition} 'composition' |
{infer ReferenceKind__Aggregation} 'aggregation';
fragment Multiplicity:
'[' lower=Natural ('..' upper=UnlimitedNatural)? ']';
DataType:
StringType | NumericType | BooleanType | TimeType | UuidType | EnumeratedType;
StringType:
Localization*
'string' name=ID ('{'
('length' length=Natural)?
('minLength' minLength=Natural)?
('maxLength' maxLength=Natural)?
('pattern' pattern=STRING)?
'}')?;
NumericType:
Localization*
'numeric' name=ID ('{'
('size' size=Natural)?
('totalDigits' totalDigits=Natural)?
('fractionDigits' fractionDigits=Natural)?
('minInclusive' minInclusive=Numeric)?
('minExclusive' minExclusive=Numeric)?
('maxInclusive' maxInclusive=Numeric)?
('maxExclusive' maxExclusive=Numeric)?
('measurementUnit' pattern=STRING)?
'}')?;
BooleanType:
Localization*
'boolean' name=ID ('{' '}')?;
TimeType:
Localization*
'time' name=ID ('{'
('instantUnits' instantUnits+=TimeUnit+)?
('instantFractionDigits' instantFractionDigits=Natural)?
('durationUnits' durationUnits+=TimeUnit+)?
('durationFractionDigits' durationFractionDigits=Natural)?
('recurrence' recurrence=TimeUnit)?
'}')?;
TimeUnit:
{infer TimeUnit__Year} 'year' |
{infer TimeUnit__Quarter} 'quarter' |
{infer TimeUnit__Month} 'month' |
{infer TimeUnit__Week} 'week' |
{infer TimeUnit__Day} 'day' |
{infer TimeUnit__Hour} 'hour' |
{infer TimeUnit__Minute} 'minute' |
{infer TimeUnit__Second} 'second';
UuidType:
Localization*
'uuid' name=ID ('{' '}')?;
EnumeratedType:
Localization*
'enumerated' name=ID ('{'
literals+=EnumeratedTypeLiteral*
'}')?;
EnumeratedTypeLiteral:
Localization*
name=ID;
fragment Localization:
'@name' '(' localizedName+=Ecore_EStringToStringMapEntry ')' |
'@description' '(' localizedDescription+=Ecore_EStringToStringMapEntry ')';
Ecore_EStringToStringMapEntry:
key=STRING ',' value=STRING;
Numeric returns number:
'-'? (INT | DECIMAL);
Natural returns number:
INT;
UnlimitedNatural returns number:
INT | '*';
terminal ID: /[_a-zA-Z][\w_]*/;
terminal STRING: /'(\\.|[^'])*'/;
terminal INT returns number: /\d+/;
terminal DECIMAL returns number: /(\d*\.\d+|\d+\.\d*)/;
hidden terminal WS: /\s+/;
hidden terminal SL_COMMENT: /\/\/[^\n\r]*/;
hidden terminal ML_COMMENT: /\/\*[\s\S]*?\*\//;
А здесь опишу некоторые фрагменты:
grammar ClassModel
entry ClassModel:
Localization*
'classModel' name=ID
(classes+=Class | dataTypes+=DataType)*;
Class:
Localization*
kind=ClassKind name=ID
('extends' generals+=[Class:ID] (',' generals+=[Class:ID])*)?
('{'
properties+=Property*
'}')?;
ClassKind:
{infer ClassKind__Regular} 'class' |
{infer ClassKind__Abstract} 'abstract' 'class' |
{infer ClassKind__Interface} 'interface';
Строка 1 — объявление грамматики.
Строки 3–6 — стартовое правило для парсера. Правило означает, что код на нашем DSL описывает модель классов (строка 3), в коде для модели классов сначала указывается произвольное количество различных локализованных атрибутов (название или описание модели классов на разных языках — строка 4), затем в коде должно быть указано ключевое слово classModel
и после него — техническое название модели классов (строка 5), а затем в произвольном порядке описываются классы и типы данных (строка 6).
Строки 8–14 — описание синтаксиса для классов. Сначала, так же как и для всей модели, для класса опционально указываются локализованные названия и описания (строка 9). Затем указывается тип класса (обычный класс, абстрактный класс или интерфейс) и техническое название класса (строка 10). Затем опционально пишется ключевое слово extends
и через запятую — список расширяемых классов (строка 11). Наконец в фигурных скобках опционально перечисляются свойства класса (строки 12–14).
Строки 16–19 — правило для парсинга типа класса, оно могло бы быть проще, если бы Langium как, например, Xtext поддерживал перечисления, но пришлось воспользоваться магией с infer
. В следующей статье мы будем преобразовывать абстрактные синтаксические деревья в модели, и такая формулировка этого правила упростит нам эту задачу.
Property:
Attribute | Reference;
Attribute:
Localization*
'attribute' name=ID dataType=[DataType:ID] Multiplicity?;
Reference:
Localization*
kind=ReferenceKind name=ID target=[Class:ID] Multiplicity?
('opposite' opposite=[Reference:ID])?;
ReferenceKind:
{infer ReferenceKind__Association} 'reference' |
{infer ReferenceKind__Composition} 'composition' |
{infer ReferenceKind__Aggregation} 'aggregation';
fragment Multiplicity:
'[' lower=Natural ('..' upper=UnlimitedNatural)? ']';
Строки 1–2 — у классов может быть два типа свойств: атрибуты и ссылки на другие классы.
Строки 4–6 — описание синтаксиса для атрибутов. Для атрибутов может опционально задаваться множественность в виде [N..M]
, что означает, что атрибут должен иметь не менее N
и не более M
значений. Например, [0..1]
означает, что свойство опциональное (может не иметь значение или иметь одно значение), [1]
или [1..1]
— свойство обязательное, [0..*]
— свойство может иметь произвольное количество значений, [1..*]
— свойство может иметь произвольное количество значений, но не менее одного и т. д.
Строки 8–11 — аналогичное описание синтаксиса для ссылок. Ссылки могут быть трех типов: ассоциация, композиция, агрегация. Для двунаправленных ссылок можно использовать ключевое слово opposite
.
Строки 13–16 — правило для типов ссылок.
Строки 18–19 — фрагмент правила для множественности атрибутов и ссылок. Фрагменты отличаются от обычных правил тем, что при парсинге для них не создаётся узел в абстрактном синтаксическом дереве.
Ещё немного правил для типов данных:
DataType:
StringType | NumericType | BooleanType |
TimeType | UuidType | EnumeratedType;
StringType:
Localization*
'string' name=ID ('{'
('length' length=Natural)?
('minLength' minLength=Natural)?
('maxLength' maxLength=Natural)?
('pattern' pattern=STRING)?
'}')?;
Строки 1–3 — в нашем DSL будет 6 базовых типов данных (строка, число, логический тип данных, временной тип данных, UUID и перечислимый тип данных).
Строки 5–12 — грамматическое правило для строковых типов данных. Для строк можно указывать фиксированную допустимую длину, минимально допустимую длину, максимально допустимую длину, регулярное выражение. Аналогичные по смыслу правила для других типов данных.
И ещё несколько правил:
fragment Localization:
'@name' '(' localizedName+=Ecore_EStringToStringMapEntry ')' |
'@description' '(' localizedDescription+=Ecore_EStringToStringMapEntry ')';
Ecore_EStringToStringMapEntry:
key=STRING ',' value=STRING;
Numeric returns number:
'-'? (INT | DECIMAL);
UnlimitedNatural returns number:
INT | '*';
terminal ID: /[_a-zA-Z][\w_]*/;
terminal STRING: /'(\\.|[^'])*'/;
terminal INT returns number: /\d+/;
terminal DECIMAL returns number: /(\d*\.\d+|\d+\.\d*)/;
hidden terminal WS: /\s+/;
hidden terminal SL_COMMENT: /\/\/[^\n\r]*/;
hidden terminal ML_COMMENT: /\/\*[\s\S]*?\*\//;
Строки 1–6 — правила для локализованных названий и описаний модели, классов, атрибутов, связей.
Строки 8–9 — правило для парсинга чисел. Это DataType‑правило — что‑то среднее между обычными правилами (оно контекстно зависимое) и терминальными правилами (используется для парсинга примитивных значений атрибутов, для него не создаётся узел в абстрактном синтаксическом дереве). Зачем вообще нужны DataType‑правила, почему недостаточно терминальных правил? Терминальные правила не должны пересекаться между собой, они используются на этапе лексического анализа. Лексический анализатор не может отличить Numeric и UnlimitedNatural, например, число 42
может быть и тем, и тем.
Строки 11–12 — правило для парсинга верхней границы множественности свойств классов (либо натуральное число, либо не ограничено).
Строки 14–17 — терминальные правила, которые используются при лексическом анализе кода. Важно, что регулярные выражения для INT
и DECIMAL
не пересекаются. Если в числе есть точка, то это DECIMAL
, а если нет — INT
.
Строки 19–21 — скрытые терминальные правила, для них результат парсинга не попадает в абстрактное синтаксическое дерево ни в виде узлов, ни в виде атрибутов узлов. Это пробельные символы и комментарии. Правда есть пробело‑зависимые языки (Python) или иногда нужно анализировать и комментарии в коде, но это отдельная тема.
Возможно, вы обратили внимание на то, что названия многих правил и атрибутов в грамматике совпадают с названиями классов и атрибутов в метамодели. Это вполне логично, грамматика повторяет по структуре метамодель. В метамодели мы описали основные термины в нашей предметной области и связи между ними, а в грамматике для этих же самых терминов мы описали текстовую нотацию.
Создание текстового редактора
В качестве текстового редактора будем использовать Monaco Editor, он лежит в основе VS Code и достаточно популярный. Как добавить в него нашу грамматику? Langium поддерживает Language Server Protocol (LSP), а Monaco Language Client позволяет подключать Monaco Editor к LSP‑серверу.
Для Monaco Language Client доступны просто тонны какой‑то документации и примеров. Я несколько дней пытался во всём этом разобраться, честно говоря иногда меня поражает в чём проблема сделать минимальный понятный пример и не перегружать людям мозг. Короче я попробовал сделать несколько таких примеров от простого к сложному: демо и исходный код.
Минимальный пример использования:
import { classModelGrammar } from '../classmodel/grammar';
import { classModelGrammarExtension } from '../classmodel/grammarExtension';
import { classModelText } from '../classmodel/text';
import { DslEditor } from '../dsl-editor/DslEditor';
export function EditorPage() {
return (
<main>
<DslEditor
uri="file:///code"
language="classmodel"
grammar={classModelGrammar}
grammarExtension={classModelGrammarExtension}
value={classModelText}
/>
</main>
);
}
DslEditor
— это наша обёртка над MonacoEditorReactComp, который является обёрткой над MonacoEditorLanguageClientWrapper, который является обёрткой над Monaco Editor. У всего этого какие‑то свои безумные конфиги, а у меня флэшбэки как я пытался в этом разобраться.
Параметры DslEditor
:
uri — в Monaco Editor у каждого открытого документа должен быть уникальный адрес,
language — произвольный идентификатор DSL,
grammar — Langium‑грамматика, которую описали выше,
grammarExtension — расширение грамматики на JavaScript (см. далее),
value — код, отображаемый в текстовом редакторе.
DslEditor создаёт Web Worker с LSP‑сервером и создаёт конфиг для Monaco Editor Wrapper.
Почему недостаточно грамматики, зачем нужно ещё какое‑то расширение на JavaScript? Для свойств классов неограниченная множественность задаётся с помощью *
, например, [0..*]
. Соответственно в абстрактном синтаксическом дереве атрибут множественности должен быть строковым, чтобы он мог хранить *
. Но обычно удобнее сделать этот параметр числовым и вместо *
хранить -1
. В Langium грамматика слишком примитивная, чтобы описать такую логику, поэтому приходится реализовывать её на JavaScript.
Или, например, мне нужно, чтобы частично заданная множественность [1]
в абстрактном синтаксическом дереве преобразовывалась в полную форму [1..1]
. Или нужно чтобы при описании двунаправленной связи между классами в качестве обратной связи (opposite) можно было выбрать только совместимую связь. В Langium из коробки всё это решается следующим способом: для грамматики генерируется код, который вы можете расширять. Но я решил пойти другим путём — не генерировать для грамматики код, а просто дополнить её JavaScript‑кодом.
Вот, фрагмент расширения грамматики на JavaScript:
{
Reference: {
lower: {
normalize(node) {
return node.lower ?? 1;
},
denormalize(node) {
return node.lower !== node.upper ? node.lower : node.lower !== 1 ? node.lower : undefined;
},
},
upper: {
normalize(node) {
return node.upper ?? node.lower ?? 1;
},
denormalize(node) {
return node.lower !== node.upper ? node.upper : undefined;
},
},
opposite: {
scopes(node) {
return node.target.ref.properties
.filter((prop) => prop.$type === 'Reference' &&
prop.target.ref === node.$container);
},
},
},
UnlimitedNatural: {
value: {
parse(value) {
return value === '*' ? -1 : parseInt(value);
},
print(value) {
return value === -1 ? '*' : value.toString();
},
},
},
}
В этом JSON на первом уровне указываются названия расширяемых правил из грамматики. На втором уровне для обычных правил указываются свойства узлов в AST (Abstract Syntax Tree, абстрактное синтаксическое дерево), а для DataType‑правил указывается фиксированное значение value. На третьем уровне задаются сами расширения:
parse — функция, которая применяется к строке при парсинге кода, например, преобразует
*
в-1
,print — функция, которая применяется к значению атрибута AST при генерации кода, например, преобразует
-1
обратно в*
(вернёмся к этому в следующей статье),normalize — преобразует узел AST в удобный для дальнейшего анализа вид, например, преобразует множественность свойства класса
[1]
в[1..1]
(это пригодится для преобразования AST в модель, вернёмся к этому в следующей статье),denormalize — преобразует узел AST в удобный для отображения вид, например, преобразует множественность свойства класса
[2..2]
в[2]
(это пригодится для преобразования модели в AST и затем в код, вернёмся к этому в следующей статье),scopes — возвращает перечень допустимых элементов в исходном коде, на которые можно ссылаться. В большинстве случаев это работает из коробки, например, при указании типа данных для атрибута работает автодополнение по типам данных, описанным в коде, а если вы укажете неправильное название типа данных для атрибута, то Monaco Editor подсветит ошибку. Аналогично из коробки работает разрешение ссылок на классы. Но во всех этих случаях типы данных и классы определены в документе на верхнем уровне. А при описании двунаправленных связей вы ссылаетесь на другую связь, определённую внутри другого класса, и Langium не может автоматически разрешать такие ссылки. Зато это решается с помощью нашего расширения.
Можете потестировать все эти штуки с автодополнением и валидацией ссылок. Ещё по F2
доступно переименование.
Работает всё это следующим образом. Большая часть нашей логики LSP‑сервера реализована в классе AbstractDslServer. С помощью функции createServicesForGrammar()
из Langium он создаёт LSP‑сервер и регистрирует для него ValueConverter, ScopeProvider, CompletionProvider. Обычно в Langium под каждый DSL в коде реализуются специфические конвертеры и провайдеры. А в нашем случае они универсальные и используют функции из расширения грамматики.
Абстрактное синтаксическое дерево
Отлично, редактор кода у нас есть. Но дальше с этим кодом нужно делать что‑то полезное: генерировать из него другой код (SQL, Java, ...), генерировать из него документы, показывать его в виде диаграммы. Во всех этих случаях гораздо удобнее работать с абстрактным синтаксическим деревом (Abstract Syntax Tree, AST), а не с текстом. Благо Langium из коробки создаёт AST для кода, можно посмотреть пример здесь.
Приведу небольшой фрагмент:
{
"$type": "ClassModel",
"localizedName": [
{
"$type": "Ecore_EStringToStringMapEntry",
"key": "ru-RU",
"value": "модель данных интернет-магазина"
}
],
"name": "OnlineStore",
"classes": [
{
"$type": "Class",
"localizedName": [
{
"$type": "Ecore_EStringToStringMapEntry",
"key": "ru-RU",
"value": "пользователь"
}
],
"kind": {
"$type": "ClassKind__Regular"
},
"name": "User",
"properties": [
{
"$type": "Attribute",
"name": "firstName",
"dataType": {
"$ref": "#/dataTypes@0",
"$refText": "String"
},
"lower": 0,
"upper": 1,
}
]
}
]
}
Структура этого JSON полностью повторяет структуру правил, описанных выше в грамматике. Для большинства правил в JSON создаются объекты, у которых в свойстве $type
указывается название правила из грамматики, а названия других свойств соответствуют названиям свойств из грамматики.
Такой JSON уже вполне пригоден для дальнейшего анализа кода. И в принципе по структуре он очень похож на модели в нашем инструменте моделирования. Есть только одна проблема: каждый раз при парсинге кода создаётся полностью новое абстрактное синтаксическое дерево. А в инструменте моделирования нам важно при каждом редактировании не полностью пересоздавать всю модель, а вносить только реальные изменения, например, чтобы идентификаторы существующих объектов оставались неизменными, чтобы межмодельные ссылки оставались корректными. Мы вернёмся к этому в следующей статье, но, забегая вперёд, нам понадобится сравнивать абстрактные синтаксические деревья до и после редактирования кода, и таким образом мы сможем понять что реально менялось. Поэтому уже к этому примеру мы прикрутили JsonDiffPatch. Попробуйте что‑нибудь изменить в коде и справа будут подсвечены изменения:

Редактор грамматики
Планируется, что в нашем инструменте моделирования вы сами сможете описывать грамматику для ваших языков моделирования. Вот пример такого редактора грамматики. Например, я изменил слева ключевое слово для начала описания модели классов с classModel
на cm
, грамматика обновилась для редактор кода справа и теперь выдаётся такая ошибка:

Заключение
В статье описано как создать свой DSL (определить для него метамодель на естественном (русский) или формальном (OMG MOF) языке, описать грамматику), приведено несколько простых примеров использования Monaco Editor, Langium и Monaco Language Client для создания редактора кода. Есть демо и исходный код. В следующей статье мы расскажем про преобразование абстрактных синтаксических деревьев обратно в код. Также расскажем про преобразование AST в модели и обратно. Иными словами, мы рассмотрим полную цепочку: модель → AST → код → AST → модель.
nin-jin
Я позволил себе сделать DSL для вас на базе формата Tree:
Примерно так я и описывал сущности в своих проектах.
Ares_ekb Автор
Начал отвечать на комментарий и в итоге получилась мини‑статья :)
1) Про Tree и DSL
По сути любая модель — это дерево с дополнительными горизонтальными связями (или, если так удобнее, то граф с двумя видами связей: вложенность и ссылка). Соответственно её можно представить в XML, JSON, YAML, Tree формате. Хотя мне ближе всего S‑expressions :) Всё это универсальные форматы, в которых можно представить любую древовидную структуру. Но иногда хочется не универсальный формат, а кастомный.
Например, классы и типы данных в статье вложены в модель классов и по логике они должны были бы в тексте писаться с отступом и/или внутри фигурных скобок:
Но это избыточно и код выглядит и пишется проще, если этой дополнительной группировки нет (например, по аналогии с package и class в Java):
Или, например, параметры
@name
и@description
принадлежат модели, классу, атрибуту, ... и логично поместить их внутрь соответствующего объекта:Но так код сложнее воспринимается, потому что в Java, C#, ... аннотации обычно пишутся до объекта, к которому они относятся:
Соответственно если помещать их внутрь объекта, то для части людей будет не очевидно эта аннотация
@name
относится к классу, внутри которого она указана, или к первому свойству этого класса.Причём в зависимости от множества разных факторов могут быть удобны разные варианты: 1) с группировкой классов и типов данных внутри модели с помощью фигурных скобок/отступа или без 2) с аннотациями до описания объекта или внутри описания объекта. В этом и смысл, что синтаксис не универсальный, а кастомный, потому что по какой‑то причине так сложилось, так удобнее воспринимать код.
2) Про DSL в целом
Честно говоря, в глубине души я считаю, что вообще не нужны никакие языки кроме Lisp. Изначально у меня даже введение к этой статье было на эту тему, про программирование снизу вверху, про то, что любое приложение можно разбить на несколько предметных областей, для каждой из них запилить свой микрофреймворк/DSL и потом из них как из конструктора Lego собирать всё приложение.
При этом даже не обязательно пилить какой‑то кастомный синтаксис. В Lisp это просто не нужно, а в других языках можно использовать имеющиеся языковые конструкции. Например, ту же модель данных можно описывать просто в виде классов, в виде классов с аннотациями (Hibernate), в виде JSON, XML, YAML, Tree, с помощью Fluent API, на SQL, на DBML, на другом DSL. Или та же история с API: можно описать его на JSON или YAML (OpenAPI), можно просто для классов и методов добавить аннотации, можно описать в коде с помощью Fluent API, ... Форма не так важна, а важно, что в принципе такие вещи вынесены в отдельные домены. Аналогичные домены могут быть для валидации данных, для формочек, для проверки доступа, для бизнес‑правил, ...
3) Про ФП vs ООП
Но меня уже далеко понесло, сейчас бы ещё накинуть про ФП vs ООП. В ФП преимущественно используется описанный подход, а в ООП обычно разработка идёт наоборот сверху вниз, поэтому на нижних уровнях в коде иногда оказывается неподдерживаемая и непереиспользуемая трешатина.
4) Про ИИ, который заменит программистов
А ещё можно накинуть про то, что программирование для меня лично в значительной степени про язык:
И с этой точки зрения в перспективе у людей просто нет шансов писать код лучше, чем языковые модели. Если последние будут писать код таким образом: выделять домены, под каждый домен создавать свой предметно‑ориентированный язык, транслировать его в нужную форму как я описал выше (просто в классы, в классы с аннотациями (Hibernate, ...), в JSON, в XML, в YAML, в Tree, в код, использующий Fluent API из какого‑нибудь фреймворка, ...).
Словом, хорошо, что я удалил такое введение к статье и не стал развивать все эти темы, иначе я бы ещё год писал саму статью :)
nin-jin
Ну так Tree - это фактически и есть дерево S-выражений, только тут голова отделена от хвоста, а вместо кучи мельтешащих скобочек используются отступы. Самое то для создания своих DSL-ей без необходимости под каждый изобретать с нуля парсер, сериализатор, валидатор, форматтер, подсветку, подсказки и тд.
Ares_ekb Автор
Здесь тоже не нужно с нуля писать парсер и остальное. Слева пишется грамматика один раз, а справа готовый редактор со всем что нужно
nin-jin
Только вот для скриншота выше я даже грамматику не писал.
Ares_ekb Автор
Ну на самом деле писали один раз, когда придумывали синтаксис для Tree. Но если этот универсальный синтаксис не подходит, то придётся описать грамматику для DSL.
Либо если понадобится более сложная логика для автодополнения и валидации. Например, для атрибута можно указать только тип данных (а не класс):
Класс может наследоваться только от другого класса:
В качестве обратной ссылки можно выбрать только подходящую. В данном случае items указывает на класс OrderItem. Соответственно обратной ссылкой может быть ссылка только из OrderItem, которая указывает обратно на Order:
Т. е. универсальный формат не закрывает все сценарии
nin-jin
Сложную логику в любом случае руками реализовывать, но сделать это проще на более высоком уровне, не завязанном на способе сериализации.