Локальная архивная копия страницы. Оригинал страницы находится по адресу:
http://codenet.ru/progr/alg/monitor/001.php

М. Черкашин. Компилятор пишется так...

Писать компилятор приходится чаще, чем обычно думают. Практически всякая большая система включает в себя входной язык — от примитивного до весьма сложного. Вспомним хотя бы dBASE — это ведь не язык программирования, а система баз данных. На нее даже программы пишутся. А раз нужен входной язык, то бывает нужен и компилятор. И часто нужен быстро. Конечно, всякий предпочтет компактный, быстрый, хорошо оптимизирующий компилятор, но далеко не всякому понравится такой компилятор писать.

Чтобы писать сложные, эффективные и быстрые компиляторы, есть много рецептов. Но здесь речь пойдет не о них. Как написать компилятор просто — вот в чем вопрос! Ведь такой компилятор и короче, и отлаживается легче. Здесь не будет каких-то хитрых алгоритмов, позволяющих достигать чудес эффективности или каких-то особенных способов организации данных. Увы, с этими способами одна проблема: заставить их работать может только чудо. А в этой статье — только практические рекомендации, а работают они надежно!

Оптимизацию и генерацию кода мы рассматривать не будем. Оптимизация — тема для отдельной статьи, а генерация кода — занятие для самоубийцы. Соломоново решение в данном случае — переводить программу не в коды машины, а на язык, который у вас уже есть. Так и делаем. Время компиляции, конечно, увеличивается, но зато мы избавляемся от большого количества неприятной возни.

Модельный компилятор

Лучший способ понять, как писать компилятор — написать его самому. Лучший способ объяснить это — предъявить текст программы. Вот мы и предлагаем статью-программу. Впрочем, ее можно читать и не заглядывая в листинг, но при этом вы много потеряете. Компилятор написан на Паскале, выходной язык — Паскаль, входной язык прост до идиотизма (pис.4). Читатели, владеющие с языком Паскаль, узнают и во входном языке знаковые конструкции.

Посмотрим на рис 1. Наш модельный компилятор состоит из четырех частей: сканер, блок таблиц имен, основная часть компилятора и семантические подпрограммы. В том или ином виде эти части можно встретить в любом компиляторе. Сканер — это глаза компилятора, который обращается к тексту программы только через него. Сканер читает входной файл и избавляет остальные части копилятора от необходимости следить за каждым входным символом. В таблице имен хранится информация о переменных программы. Обращение к ней осуществляется в основном через процедуры блок таблицы имен.

И, наконец, основная часть компилятора ...компилирует. Точнее, следит за ходом компиляции. Как только становится ясно, с какой конструкцией мы имеем дело, управление передается специализированной, имеющей дело только с этой конструкцией, процедуре, которая называется семантической подпрограмммой.

Сканер

Среди всех конструкций, воспринимаемых компилятором, есть минимальные, и которые уже не исеет смысла делить на части. Например: идентификатор, число, строка, ключевое слово. Они называются лексемами. Компилятору нет нужды разбираться в их структуре. Кстати, в описании старого, доброго Алгола-60 использовался термин иероглифы, то есть нечто такое, что можно обозначить значком. Но у компьютера всего-то 256 символов — и приходится писать, например, "procedure". А посему идея следующая: отдельная процедура считывает программу из файла, выделяет оттуда лексемы и передает их основной части компилятора. А той до текста программы уже нет никакого дела. Такая подпрограмма называется сканером.

В языке SIMPLE существуют пять видов лексем (pис.2): ключевые слова, идентификаторы, числа, спецсимволы и нечто в квадратных скобках. По поводу первых четырех типов, кажется, все ясно — см. рисунок. А пятый, последний — это наша хитрость. Дело в том, что компиляция выражений — это вопрос особый, и ему будет посвящена следующая статья. Поэтому, пользуясь тем, что выходной язык у нас Паскаль, мы перекладываем эту задачу на плечи компилятора с Паскаля. А чтобы у нас не возникало проблем — информацию, которые мы просто переписываеми в выходной файл, не разбираясь в его внутренней структуре, заключаем в квадратные скобки.

Посмотрим на текст программы (Пример 1. Сканер). Основная процедура сканера — Scan. Вызывая ее, мы получаем очередную лексему. Мы можем даже считать ее аналогом процедуры ввода — со странного устройства, которое передает внутрь компьютера лексемы. Процедура Scan по первому символу лексемы определяет, что за лексема нас сейчас ждет, и вызывает "специализированную" процедуру, которая только этим типом лексем и занимается. Единственная загвоздка здесь — ключевые слова путаются с именами. Поэтому они считываются вместе, а уж потом одно отделяются от другого.

Обратим внимание на вспомогательные процедуры GetCh и UngetCh. Процедура GetCh заменяет сиволы перевода строки и табуляции на пробелы и пропускает лишние пробелы (так получается, что несколько пробелов подряд эквивалентны одному). Процедура UngetCh используется, если мы "погорячились" и считали "лишний" символ. Тогда с помощью этой процедуры можно сказать: "Возьми обратно!" Процедура GetCh выдаст в следующий раз тот же символ.

Таблица имен

Когда человек пишет свою программу или разбирается в чужой, ему нужно держать в голове кое-какую информацию. Но машина железная, у нее головы нет. Поэтому необходима структура данных, которая про каждую переменную хранила бы определенную информацию. Эта структура называется таблицей имен. Без нее не обходится ни один компилятор.

Таблица имен действительно похожа на таблицу. Она состоит из "строк" — ячеек таблицы имен, которые могут содержать инфорацию об одной простой переменной, например, переменной целого типа. (Обычно эти ячейки называют записями таблицы имен, но здесь сознательно используется другой термин, чтобы не было путаницы с языком SIMPLE).

Посмотрим на рис.3. Там изображена структура нашей таблицей имен. Проще всего, когда переменная — целого типа. Для нее нужно хранить всего ничего — имя и тип (integer). В реальных, больших компиляторах, впрочем, есть еще кое-что — ее адрес, например. Но мы, слава Богу, компилируем в Паскаль!

С записями будет посложнее. Даже в голове у нас они хранятся не целиком, а как нечто, сложенное из кусочков. Вот фрагмент описаний переменных языка SIMPLE (Рис 3А). В голове он превратится во что-то подобное рис 3Б. А к чему должно быть ближе то, что хранится в таблице имен? Наверное, к тому, что хранится в голове, а не на бумаге.

Но в одну ячейку таблицы имен может поместиться информация максимум об одной переменной. Поэтому сейчас наша задача — "раскидать" по ячейкам информацию о структуре записи. Это можно сделать разными способами. Здесь выбран следующий. На любую переменную — i,j,u и т.д. — заводим одну запись таблицы имен. Если эта переменная — запись, то в поле Fields пишется ссылка на описание структуры ее полей. В данном случае ссылка — просто номер ячейки таблицы имен, содержащей заголовок этого описания. Таким образом, число в поле Fields соответствует жирным стрелкам на рис 3Б.

Описание структуры записи представляет собой заголовок (слово (record) на Рис 3Б или ячейка со значком " в поле Pname на Рис 3В) и еще несколько — по одной на каждое поле — ячеек. Каждая из этих ячеек (в том числе и заголовок) содержит в поле Ref номер ячейки для следующего поля записи. Таким образом, число в поле Ref соответствует нежирной стрелке на Рис 3Б. Ячейка для последнего поля содержит в поле Ref ноль. Это означает "Дальше полей нет!" и обозначается на Рис 3В диагональным крестом. Если же поле записи снова запись, то соответствующая этому полю ячейка содержит в поле Fields ссылку на описание структуры полей.

Первые MaxKey записей таблицы имен на самом деле не имена, а ключевые слова. Имена и ключевые слова вообще очень легко перепутать. Поэтому если имя будет найдено в таблице имен, но в записи с номером не большим, чем MaxKey, то это на самом деле не имя, а ключевое слово. Можно, конечно, завести для ключевых слов отдельный список, но так, как сделано здесь, проще.

Для обращения к таблице имен (Пример 2) предусмотрено всего три процедуры: поиск имени в таблице, помещение имени в таблицу и определение по номеру записи, соответствует ли она ключевому слову или нет (это всего лишь сравнение с константой MaxKey — но тем не менее это полезно).

Основной блок компилятора.

К моменту, когда вы уже готовы сесть и начать писать текст этого блока, вы должны знать, что у вас делают остальные части (сканер, процедуры работы с таблицами и т.д.). Причем желательно, чтобы остальные части делали побольше, а эта поменьше. Отдельную небольшую подпрограмму вы всегда отладите, а вот процедуру, которая вызыввает несколько других процедур, отладить несколько сложнее. Короче, главные проблеы у вас будут все-таки с основной частью.

Посмотрите на текст программы (Пример 3). Что должна делать основная часть? Читать описания переменных и заполнать таблицу имен. Потом содержимое таблицы имен должно быть вовремя записано в выходной файл в виде описаний переменных на языке Паскаль. Кроме того, для каждого оператора на языке SIMPLE в выходной файл должен быть записан эквивалентный ему оператор языка Паскаль.

В описаниях основная проблема — это описания записей. "Разобраться" с ними — значит поместить их в таблицу имен. В данной программе это сделано рекурсивно. А действительно, как это еще сделать? Ведь и записи определены рекурсивно: "...содержит в качестве полей целые числа и [другие]_записи".

Есть также определенные проблемы и с трансляцией описаний. Оператор присваивания или печати мы всегда можем считать целиком в память и уже потом компилировать. А вот оператор цикла сам может содержать внутри себя тысячи операторов. Поэтому нам остается только одно — встретив while...do, записать в выходной файл начало оператора цикла, потом как ни в чем ни бывало компилировать внутренние операторы цикла, а встретив close и вспомнив, что когда-то мы встречали while...do, записать в выходной файл все, что останется. К счастью, мы копилируе в Паскаль, а не в коды. И запоминать на самом деле нам ничего не надо. Достаточно, встретив while...do, написать "while ... do begin", а встретив close, написать "end" — и мы никогда не прогадаем, потому что "begin" ставим всегда.

А вот если бы мы транслировали в машинный код нам необходимо было бы знать, с какого адреса начинался этот оператор. А для этого нужна структура данных, называемая стеком.

Куда идти дальше?

У тех, кто дошел до этого места, возможно, возникнут вопросы. Я не берусь предугадать их все, но на некоторые можно ответить заранее.

— Что еще нужно сделать, если с компилятором будут работать часто?

— Необходимо вставить диагностику ошибок. На практике компилятор в большинстве случаев выдает не скомпилированный текст, а сообщение об ошибке. Поэтому и писать компилятор нужно так, чтобы проверка на ошибки производилась как можно легче.

В частности, чем больше хитрых приемов вы используете (например, ускоряющих компиляцию), тем больше вам придется ломать голову над тем, куда этот алгоритм забредет в случае ошибки и как сделать, чтобы копилятор не сбился с пути и обнаружил не только первую встретившуюся ошибку, но еще и какие-то другие.

— Как вставлять новые конструкции в язык?

— Если речь идет о структурах данных, то лучше добавить новое поле в запись таблицы имен, чем ломать голову над тем, как сэкономить лишнее поле.

Если речь идет об операторах — добавьте еще одну специализированную процедуру, копилирующую данный оператор. Соответственно, вызов этой процедуры должен быть вставлен в процедуру Code.

— А если хочется все-таки сделать компилятор более эффективным?

— Ради Бога! Только сначала пусть он заработает. А потом его можно оптимизировать по вкусу. Кроме того, прежде чем применить многообещающий алгоритм, хорошенько проверьте, будет ли он столь эффективен в вашем случае. А то ведь все эти алгоритмы на практике хороши в весьма специальных случаях. Подумайте, стоит ли овчинка выделки.

— Как отлаживать компилятор?

— Компилятор — очень коварная программа. И выловить все ошибки из него обычно не удается. Не полагайтесь на свою интуицию. Сделайте себе набор тестов — небольших, но сам набор пусть будет представительнымм. И когда компилятор будет подходить к готовности, прогоните их все. (Да, в том числе и те, что уже когда-то проходили!)

Пример 1. Сканер
(**************************************)
(*        Данные для сканера          *)
(**************************************)
const ExprLen = 240;
Type LexEnum  = (LexKeyword, LexNames, LexNumber, LexSpecs, LexExpr);
     SpecEnum = (SemiColn, Comma, Assign);
var LexType : LexEnum;
    LexVal : integer;
      (* Буфер для кое-чего в квадратных скобках *)
    Buf : string [ExprLen];
    EndStream, LogEOF : boolean;
      (* Буфер и указатель буфера
         строки входного файла*)
    LineBuf : string [81];
    LinePtr : integer;
      (* А вот здесь мы храним символ,
      от которого мы отказались
       с помощью функции UngetCh *)
    OldCh : char;
      (* True, если в OldCh что-то лежит*)
    Suspend : boolean;

procedure IniScan;
begin
    LineBuf := ' ';
    LinePtr := 1;
    Suspend :=False;
end;

(*
    Сканер. Оставляет в переменных
       LexType и LexVal.
    Нечто в квадратных скобках
      остается в буфере Buf.
    Функции Alpha (c) и  Num (c)
       равны True, если их аргумент
       буква или цифра соответственно.
*)
procedure Scan;
var Ch : char;
begin
    if Ch = ' ' then    (* Бог с Ним! *)
        else UngetCh;
    if EndStream then LogEOF := true  (* Конец файла! *)
        (* Встретили квадратную скобку — впереди выражение *)
    else if Ch = '['   then Brackets
        (* Впереди ключевое слово или переменная,
            а вот что неясно *)
    else if Alpha (Ch) then KeyOrNames
    else if Num   (Ch) then Numbers     (* Число!*)
    else if Ch = ';'   then Spech (SemiColn)
    else if Ch = ','   then Spech (Comma)
    else if Ch = ':'   then begin
        Ch := GetCh;
        Ch := GetCh;
        if Ch = '=' then Spech (Assign); (* := *)
    end;
end;

(*
    Сначала прочитать нечто алфавитно-цифровое,
    а потом уже разобраться, что это —
    ключевое слово или идентификатор
*)

procedure KeyOrNames;
var LexBuf : bloc;
    i,t : integer;
    Ch : char;
begin
    Ch := GetCh;
    i := 1;
    repeat
        LexBuf [i] := Ch;
        i := i+1;
        Ch := GetCh;
      until not (Alpha (Ch) or Num (Ch));
    LexBuf [0] := chr (i);
    UngetCh;
    t := Find (LexBuf);
    if KeyEntry (t) then begin
        LexType := LexKeyword;
        LexVal  := t;
    end else if t <> 0 then begin
        LexType := LexNames;
        Lexal := t;
    end else begin
        LexVal := LexNames;
        t := PutIn (LexBuf);
    end;
end;

(* Считать число *)
procedure Numbers;
    Count : integer;
    Ch : char;
begin
    Ch := GetCh;
    Count := 0;
    repeat
        Count := 10*Count + (ord (Ch)-ord ('0'));
        Ch := GetCh;
      until not Num (Ch);
    UngetCh;
    LexType := LexNumber;
    LexVal := Counter;
end;

(* Установить переменные LexType и LexVal
    для спецсимволов *)
procedure Spech (Sym : EnumSpec);
begin
    LexType := LexSpecs;
    LexVal  := ord (Sym);
end;

( Нечто в квадратных скобках **)
procedure Brackets;
var  Ch : char;
     i  : integer;
begin
    Ch := GetCh;
    Ch := GetCh;
    i := 1;
    while Ch <> ']' do
    begin
        Buf [i] := Ch;
        Ch := GetCh;
        i := i+1;
    end;
    Buf [0] := chr (i-1);
    LexType := LexExpr;
end;

(* Вспомогательные подпрограммы для сканера *)
Function GetCh : char;
var i  : integer;
    Ch : char;
begin
    if Suspend then begin
        GetCh := OldCh;
        Suspend := False;
      end
   else repeat
       if LinePtr>legth (LineBuf) begin
           Ch := ' ';
           NewLine;
       end else begin
           Ch := LineBuf [LinePtr];
           LinePtr := LinePtr+1;
       end;
     until ((Ch<>' ') and (Ch<>chr (Tab))) or Eof (SRC);
   if Eof (SRC) then EndStream := 1;
end;

procedure UngetCh;
begin
    Suspend :=True;
end;

procedure NewLine;
begin
    ReadLn (LineBuf);
    if Eof (SRC) then LinePtr := 1;
    LinePtr := 1;
end;
Пример 2. Таблица имен.
(**************************************)
(*      Процедуры для таблицы имен    *)
(**************************************)
const WordLen = 32;
      TabSize = 1024;
      MaxKey = 12;
Type bloc = string [WordLen];
    EnumTag = (Dunno, TagRecord, TagInt);
      (* Ячейка таблицы имен *)
    ncell = record
       Pname : bloc;
       Tag : EnumTag;
       Ref : integer;
       Fields : integer;
    end;
    (* Таблица имен *)
var TableOfNames : array [1..TabSize] of bloc;
    TabLen : integer; (* Размер таблицы имен *)

procedure IniTab;
begin
    TabLen := 0;
    PutIn("procedure");
    PutIn("main");
    PutIn("end");
    PutIn("int");
    PutIn("record");
    PutIn("if");
    PutIn("then");
    PutIn("else");
    PutIn("close");
    PutIn("while");
    PutIn("do");
    PutIn("print");
end;

(* Найти переменную с данным именем *)
int Find (Name: bloc);
var i : integer;
begin
    Find := 0;
    for i:=MaxKey+1 to TabLen do
        if Name = TableOfNames [i].Pname;
            then Find := i;
end;

(* Создать новую запись таблицы имен
    с данным именем *)
procedure PutIn (Name:bloc);
begin
    TabLen := TabLen +1;
    With TableOfNames [TabLen] do
    begin
        Pname  := Name;
        Tag    := Dunno;
    end;
end;

(* Если номер записи не больше MaxKey,
     то это на самом деле не имя,
     а ключевое слово *)
Function KeyEntry (t:integer) : boolean;
begin
    KeyEntry := (t>0) and (t<=MaxKey);
end;
     
Пример 3. Основной блок компилдятора.

Type EnumKey = (KeyProc,   KeyMain,  KeyEnd,  KeyInt,
                KeyRecord, KeyIf,    KeyThen, KeyElse,
                KeyClose,  KeyWhile, KeyDo,   KeyPrint);
var CommonTop : integer;
procedure Compile;
begin
    Scan;
    (* Сначала считаем описания переменных,
       доступных всей программе
    *)
    while LexType <> LexKey
      or  LexVal <> KeyProc do
        Declare;
    ProgInit;  (* Вывести стандартный
          заголовок программы на Паскале *)
    CommonTop := TabLen;
    while not LogEOF do
        ThisProc; (* Одна процедура *)
    ProgClose;   (* Вывести стандартное
           окончание программы на Паскале *)
end;

(* Скомпилировать одну процедуру *)
procedure ThisProc;
begin
    TabLen := ThisProc;
    while (LexType = LexKey) and
          (LexVal = ord (KeyInt)
            or LexVal = ord (KeyRecord)) do
        Declare;
    ProcInit; (* Заголовок процедуры *)
    Code;
    ProcClose; (* Окончание процедуры *)
end;

(* Обработать одно описание *)
procedure Declare;
var Y:integer;
begin
    if (LexType=LexKey)
        and (LexVal=ord (KeyInt)
     then NameList (TagInt,0)
     else if (LexType=LexKey)
        and (LexVal=ord (LexRecord))
     then begin
              PutIn ('');
              TableOfNames [TabLen].Fields := 0;
              Y := TabLen;
              ReadFields (Y);
              NameList (TagRecord,Y);
          end;
end;

(* Считать список имен переменных после описателя
    и запихнуть эти имена в таблицу имен*)
procedure NameList (TagVal, FldVal : integer);
begin
    repeat
        Scan;
        With TableOfNames [LexVal] do
         begin
            Tag := TagVal;
            Fields := FldVal;
            Ref := 0;
         end;
        Scan;
      until  (LexType=LexSpecs)
          and (LexVal=ord (SemiColn));
end;

(* Обработать поля записи *)
procedure ReadFields (Y0 : integer);
var X,Y,Pre : integer;
begin
    Y := Y0;
    repeat
        Pre := Y;
        Dcl1 (X,Y);
        TableOfNames [Pre].Ref := X;
      until (LexType=LexKey)
         and (LexVal=ord (KeyEnd));
    Table OfNames [Y].Ref := 0;
end;

(* Dcl1 делает почти тоже, что и Declare,
     только описание, которое он считывает,
     является полем записи.
     Поэтому вместо NameList используется
     другая процедура
*)
procedure Dcl1 (var X,Y : integer);
var C:integer;
begin
    if (LexType=LexKey)
        and (LexVal=ord (KeyInt))
     then NameList1 (TagInt,0)
     else if (LexType=LexKey)
        and (LexVal=ord (LexRecord))
     then begin
              PutIn ('');
              TableOfNames [TabLen].Fields := 0;
              C := TabLen;
              ReadFields (C);
              NameList1 (TagRecord,C,X,Y);
          end;
end;

(*  Разница между NameList и NameList1 в том,
    что последняя устанавливает "указатели" Ref.
*)
procedure NameList1 (TagVal,FldVal : integer;
                        var X,Y : integer);
begin
    X:=0;
    Pre:=0;
    repeat
        Scan;
        if X=0 then X:=LexVal;
        Y:=LexVal;
        With TableOfNames [LexVal] do
         begin
            Tag :=TagVal;
            Fields :=FldVal;
         end;
        if Pre<>0 then
            TableOfNames [Pre].Ref := LexVal;
        Pre:=LexVal;
        Scan;
      until (LexType=LexSpecs)
         and (LexVal=ord (SemiColn));
end;

(* Скомпилировать все операторы в процедуре *)
procedure Code;
begin
    repeat
       Statement;
     until (LexType=LexKey)
        and (LexVal=ord (KeyEnd));
end;

(* Скомпилировать один оператор *)
procedure Statement;
begin
    if LexType = LexKey then
         (*Большинство операторов можно распознать
         по первому ключевому слову*)
     begin
         if LexVal = ord (KeyIf)
             then SempIf
         else if LexVal = ord (KeyElse)
             then SempElse
         else if LexVal = ord (KeyWhile)
             then SempWhile
         else if LexVal = ord (KeyClose)
             then SempClose
         else if LexVal = ord (KeyPrint)
             then SempPrint
     end
       else if (LexType=LexNames)
             then SempAssignOrCall;
                 (* Присваивание или вызов процедуры *)
end;

(* Семантические подпрограммы *)

(* If...then *)
procedure SempIf;
begin
    WriteLn ('if');
    Scan;
    WriteLn (Buf);
    WriteLn ('then begin');
    Scan;  (**)
    Scan;
end;

(* Else *)
procedure SempElse;
begin
    WriteLn ('end else begin');
    Scan;
end;

(* Print...*)
procedure SempPrint;
begin
    Scan;
    Write ('WriteLn');
    id := TableOfNames [LexVal].Pname;
    Write ('(','''',id,' = ','''',',',id,')');
    Scan;   (*;*)
    Scan;
end;

(* По первой лексеме нельзя отличить
   вызов процедуры от присваивания.
   Поэтому отлдичаем одно от другого
   в одной семантической подпрограмме
   по ходу дела
*)
procedure SempAssignOrCall;
begin
    Write (TableOfNames [LexVal].Pname);
    Scan; (*:=*)
    if (LexType = LexSpecs) and (LexVal = ord (Assign))
        (* Присваивание *)
    then begin Scan;
               WriteLn (' := ',Buf,';');
               Scan; (*;*)
         end
    else if (LexType = LexSpecs) and (LExVal = ord (SemiColn))
    then  WriteLn (';'); (*  Вызов процедуры *)
    Scan;
end;

(* While...do *)
procedure SempWhile;
begin
    WriteLn ('While');
    Scan;
    WriteLn (Buf);
    WriteLn ('do begin');
    Scan;
    Scan;
end;

(* Close *)
procedure SempClose;
begin
    WriteLn ('end;');
    Scan;
end;

Краткий обзор языка SIMPLE

Программа на языке SIMPLE есть:
      описания переменных,
      общих для всей программы;
      procedure имя;
      begin
         описание переменных процедуры;
         операторы;
      end;
      ....
      procedure имя;
      ....
Одна из процедур носит имя main. Эта процедура и есть программа.

Описание
    int список переменных через запятую;
    record описание;
             ....
           описание;
        end список переменных через запятую;
Описание
    Присваивание: Имя := [....];
    Вызов процедуры: Имя;
    Печать: Print имя;
    Условный: if [....] then операторы else операторы close;
    Цикла: while [....] do операторы close;
Ключевые слова
(LexType = LexKey)

procedure then
main      else
end       close
int       while
record    do
if        print
Имена
(LexType = LexNames)
Например:
sight
gh218
slow1a6
Числа
(LexType = LexNumber)
Например:
17
0218
33
1
Спецсимволы
(LexType = LexSpecs)
:=
,
:
"Нечто в []"
(LexType = LexExpr)
Например:
[a*(8+d)}
[d mod h + 12]