Описание подсистемы сбора информации «Nitra»

Введение
AST
Декларации
Отображение (mapping)
Простое отображение
Параметризованное отображение
Сопоставление с образцом при отображении
Зависимые свойства
Стадии
Символы
sealed-символы
Коллекторы
Области видимости, таблицы имен и связывание
Области видимости – объект Scope
Связывание
Ref<TSymbol> и IRef
Пример – простой язык
Грамматика
AST
Отображение (mapping)
IProjectSupport
Результат
Заключение
Ссылки

Введение

Для чего нужна Nitra?

Вы описываете расширенную «грамматику» своего языка, а Nitra генерирует по ней:

Парсер с автоматическим восстановлением после ошибок и возможностью динамического расширения грамматики.

AST.

Набор символов, подсистему связывания и разрешение имен для вашего языка.

ReSharper plugin предоставляющий базовые сервисы IDE (word completion, навигация, валидация, подсветка, и т.п.).

Любой язык описанный на Nitra может быть расширен дополнительными конструкциями и DSL-ями прямо налету.

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

Чтобы информация, полученная из дерева разбора (ДР, Parse Tree, PT), стала удобной для использования, нужно переложить ее в некую объектную модель. Такая модель в Nitra называется символами (Symbols). В отличие от дерева разбора, которое представляет информацию в разрезе отдельных файлов (входящих в проект), в символах информация описывает логические сущности языка. Так, в деревьях разбора встречаются узлы (обычно носящие имя CompilationUnit), описывающие содержимое отдельного исходного файла, содержатся различные директивы импорта (using для C# или import для Java), типы могут быть представлены несколькими ветками дерева разбора (например, в partial-классы в C#). В символах же хранится информация, относящаяся исключительно к логическому представлению кода. Так, в символах нет информации о файлах, директивах импорта, а информация о типах хранится в единственных экземплярах, по одному на тип, независимо от того, имеет этот тип несколько частей или нет.

В Nitra, кроме дерева разбора, есть такая сущность, как AST. AST содержит информацию из дерева разбора, но не всю, и эта информация обычно представлена в более абстрактной форме. Близкие языки (такие как VB и C#) могут иметь общий AST.

Потенциально можно собирать информацию для символов непосредственно по дереву разбора, обходясь без AST. Но это плохо по нескольким причинам:

  1. Дерево разбора описывается грамматикой. Если в нее поместить код вычислений по дереву разбора, читаемость грамматики резко ухудшается, и ее становится сложнее поддерживать и развивать.
  2. Дерево разбора содержит много не требующихся для вычисления деталей и занимает существенно больший (по сравнению с AST) объем памяти. Это и литералы (ключевые слова, скобки, знаки пунктуации и т.п.), и результат разбора игнорируемых void-правил (комментариев, пробелов, директив препроцессора и т.п.), и сложная структура дерева разбора, порождаемая тем, что оно отражает нюансы правил грамматики. Производить парсинг для всех файлов проекта или держать в памяти все деревья разбора, относящиеся к проекту, крайне расточительно. Вместо этого целесообразно иметь легко сериализуемый формат AST, который и держать в памяти, или подгружать из дискового кэша при необходимости. Одним словом, введение AST упрощает реализацию языкового сервиса для IDE, ускоряет их работу и повышает их масштабируемость.
  3. В языке может быть несколько синтаксических конструкций, порождающих один и тот же тип AST. Наличие AST способствует более качественной декомпозиции.
  4. Некоторые конструкции в грамматике языка являются «синтаксическим сахаром». Целесообразно свести их к более общим конструкциям языка, прежде чем производить по ним вычисления. Введение AST способствует более качественному абстрагированию. Например, в C# есть отдельный синтаксис для nulable-типов «X?», и в дереве разбора он представлен отдельными ветками. В AST же он отсутствует. Вместо этого nulable-типы представляются как обычные generic-типы «Nulable<T>». То же самое происходит с массивами и прочими специальными конструкциями. Все они представлены соответствующими типами .Net или intrinsic-типами (фэйковыми типами).
  5. AST абстрагирован от языка, а значит, на него можно отображать похожие языки с разным синтаксисом (например, C#, Visual Basic, Nemerle). Это позволяет описывать типизацию один раз для целой группы языков. Если алгоритмы у близких языков несущественно отличаются, эти отличия можно выразить созданием специализированных наследников для некоторых типов веток AST и переопределением алгоритмов в этих наследниках.

Учитывая вышесказанное, нами было принято решение поддерживать в Nitra AST.

AST

AST похож на классы в ООЯ, но с усеченной функциональностью. Тип AST объявляется с помощью конструкции:

ast «Имя типа AST» : «Родительские типы AST»
{
  ...
}

где вместо «...» может быть набор членов AST.

AST может быть абстрактным. От абстрактного AST можно наследовать любое количество потомков. При этом поддерживается множественное наследование (аналогично наследованию интерфейсов в C#/Java или виртуальному наследованию в C++). Конкретный (не абстрактный) AST может быть наследником абстрактного, но от него уже производить наследование нельзя.

AST может содержать свойства двух видов:

  1. Структурные – описывают структуру AST.
  2. Зависимые – используются для вычислений по AST и являются развитием идеи атрибутных грамматик.

Структурные свойства заполняются путем отображения (mapping) на них деревьев разбора. В Nitra разработан язык отображения, который будет описан ниже. Отображение может быть выполнено для отдельного узла дерева разбора или для файла (объекта типа Nitra.ProjectSystem.File).

Ниже приведен пример объявления двух типов AST:

namespace CSharp
{
  abstract ast UsingDirective
  {
  }

  ast UsingOpenDirective : UsingDirective
  {
    NamespaceOrTypeName : QualifiedReference;
  }
}

Здесь вводится тип AST с именем UsingOpenDirective, который является наследником абстрактного типа UsingDirective, объявленного выше. QualifiedReference – это тоже тип AST, объявленный в другом месте.

AST поддерживает как единичное, так и множественное наследование:

abstract ast Y { }
abstract ast X { }
abstract ast Z { }
ast A : Y, Z { }
ast B : X, Z { }

В этом абстрактные типы AST Nitra похожи на интерфейсы Java и C#, но, в отличие от интерфейсов, в абстрактных типах AST может присутствовать код. При этом могут возникать конфликты, которые разрешаются по следующей схеме: если в двух или более предках есть присвоение (вычисление) для одного и того же зависимого свойства (ЗС), компилятор Nitra выдаст сообщение об ошибке. Обойти это можно, описав новую версию вычисления свойства (для которого возник конфликт в предках) в потомке.

Переопределение присвоения свойства в потомке также позволяет изменить поведение потомка.

Декларации

Декларации (declaration) – это особый вид AST, для которого автоматически порождается символ. Nitra имеет встроенную поддержку деклараций. Все декларации автоматически являются наследниками AST-типа Declaration, определенного в стандартной библиотеке следующим образом:

namespace Nitra.Runtime.Binding
{
  ast Name
  {
    in Symbol : ISymbol2;
  }

  abstract ast ScopedAst
  {
    in ContainingTable : TableScope;
  }

  abstract ast Declaration : ScopedAst
  {
    out Symbol : ISymbol2;
    Name       : Name;
  }
}

У Declaration есть структурное свойство Name, имеющее тип Nitra.Runtime.Binding.Name, который также описан в стандартной библиотеки. Эти типы AST вводят новые имена в программах на разрабатываемых языках и используются в подсистеме связывания (об этом чуть позже).

Символ может порождаться как одной декларацией, так и несколькими декларациями. Символы, порождаемые сразу несколькими декларациями, называются multipart-символами. Примерами таких типов символов являются пространства имен и partial-классы в C#, или пакеты в Java. В Nitra есть встроенная поддержка multipart-символов. Достаточно переопределить два булева метода в символе, и рантайм Nitra будет автоматически объединять декларации в multipart-символ.

Ниже приведен пример объявления типа декларации:

declaration UsingAliasDirective : UsingDirective
{
  NamespaceOrTypeName : QualifiedReference;
}

Отображение (mapping)

Простое отображение

Nitra поддерживает DSL отображения дерева разбора на AST и декларации. Для каждого типа узла дерева разбора пишется процедура его отображения на некоторый тип AST. При компиляции компилятор Nitra читает эти описания и генерирует код преобразования Parse Tree в AST. Этот код помещается в метод GetAst() узла Parse Tree, к которому относится преобразование.

Вот как выглядит код преобразования для директивы using из C#:

map syntax TopDeclarations.UsingDirective -> CSharp.UsingDirective
{
  | Alias -> CSharp.UsingAliasDirective
    {
      Name          -> Name;
      QualifiedName -> NamespaceOrTypeName;
    }

  | Open -> CSharp.UsingOpenDirective
    {
      QualifiedName -> NamespaceOrTypeName;
    }
}

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

syntax UsingDirective
{
  | Alias = "using" Name "=" sm QualifiedName ";";
  | Open  = "using" QualifiedName ";";
}

Конструкция:

        map syntax TopDeclarations.UsingDirective -> UsingDirective

указывает, что надо преобразовать дерево разбора, получаемое от правила TopDeclarations.UsingDirective (TopDeclarations – это имя синтаксического модуля в котором объявлено правило) в AST CSharp.UsingDirective (описанное выше). Так как CSharp.UsingDirective является абстрактным, а правило TopDeclarations.UsingDirective является расширяемым и содержащим расширения Alias и Open, требуется преобразовать все расширения правила TopDeclarations.UsingDirective в наследников AST CSharp.UsingDirective.

Конструкция:

  | Alias -> CSharp.UsingAliasDirective
    {
      Name          -> Name;
      QualifiedName -> NamespaceOrTypeName;
    }

сопоставляется с расширением TopDeclarations.UsingDirective.Alias. Конструкция:

-> CSharp.UsingAliasDirective

указывает, что должно быть совершено преобразование в CSharp.UsingAliasDirective.

Следующие далее фигурные скобки содержат описание преобразования свойств. Для каждого свойства необходимо описать конструкцию:

«имя свойства из дерева разбора» -> «имя свойства из AST»;

Параметризованное отображение

В некоторых случаях одно и то же правило может использоваться в разных контекстах, порождая элементы AST разных типов. Например, определения поля и события в C# используют одно и то же подправило VariableDeclarators:

syntax VariableDeclarator  = Name ("=" VariableInitializer)?;
alias  VariableDeclarators = (VariableDeclarator; ",")+;

syntax TypeMemberDeclaration
{
  ...
  | Field       = Attributes Modifiers         AnyType VariableDeclarators ";";
  | SimpleEvent = Attributes Modifiers "event" AnyType VariableDeclarators ";";
}

Простое отображение не позволяет преобразовывать одно и то же дерево разбора в разные типы AST. Для того чтобы обойти это ограничение, Nitra поддерживает параметризированные правила отображения. От обычного отображения они отличаются тем, что имеют в своем описании параметры типа «AST». У разных перегрузок типы параметров должны отличаться. Выбор перегрузки осуществляется по типу параметра правила отображения. Вот пример правил отображения для дерева разбора VariableDeclarator (объявленного в модуле Statements):

map syntax Statements.VariableDeclarator(header : FieldHeader) -> Member.Field
{
  Name   -> Name;
  header -> Header;
}

map syntax Statements.VariableDeclarator(header : EventHeader) -> Member.Event
{
  Name   -> Name;
  header -> Header;
  None() -> InterfaceType;
  []     -> Accessors;
}

Первая перегрузка принимает на вход параметр типа FieldHeader:

ast FieldHeader
{
  Type       : QualifiedReference;
  Attributes : Attribute*;
  Modifiers  : Modifier*;
}

и возвращает (порождает) AST типа Member.Field (т.е. AST поля C#).

Вторая перегрузка принимает на вход параметр типа EventHeader:

ast EventHeader
{
  Type       : QualifiedReference;
  Attributes : Attribute*;
  Modifiers  : Modifier*;
}

и возвращает AST типа Member.Event (т.е. AST события C#).

Применение параметризированного отображения похожа на вызов метода:

map syntax TopDeclarations.TypeMemberDeclaration -> TypeMember*
{
  ...
  | Field       -> VariableDeclarators.Item1(FieldHeader{ AnyType -> Type; Attributes -> Attributes; Modifiers -> Modifiers; })
  | SimpleEvent -> VariableDeclarators.Item1(EventHeader{ AnyType -> Type; Attributes -> Attributes; Modifiers -> Modifiers; })
}

Так как VariableDeclarators является списком с разделителями, в котором элементы находятся в поле Item1, а разделители в Item2, конструкция:

VariableDeclarators.Item1

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

Сопоставление с образцом при отображении

Иногда отображение может быть разным (иметь ветвление) в зависимости от некоторого условия. Например, это требуется, когда производится отображение необязательных элементов грамматики. Для решения подобных задач в отображении Nitra применяется сопоставление с образцом. Например, в дереве разбора нашей C#-грамматики список базовых типов хранится в необязательным (optional) поле TypeBaseOpt порождаемом подправилом TypeBase? (суффикс «Opt» автоматически приписывается к необязательным полям):

syntax TypeDeclaration
{
  ...
  | Class = Attributes Modifiers Partial? "class" Name TypeParameters? TypeBase? TypeParameterConstraintsClauses TypeBody;
}

Однако в AST список базовых типов представлен списком (возможно пустым):

...
TypeBase : QualifiedReference*;

В таких случаях для отображения используется сопоставление с образцом (patternmatching):

map syntax TopDeclarations.TypeDeclaration -> NamespaceMember
{
  | Class -> TopClass
    {
      Name                            -> Name;
      TypeBody.TypeMemberDeclarations -> Members;
      match (TypeBaseOpt)       { Some(value) -> value | None() -> [] } -> TypeBase;
      match (TypeParametersOpt) { Some(value) -> value | None() -> [] } -> TypeParameterAliases;
      TypeParameterConstraintsClauses -> TypeParameterConstraints;
      IsPartial = ParsedValue(PartialOpt.Span, PartialOpt.HasValue);
      Attributes -> Attributes;
      Modifiers  -> Modifiers;
    }

Конструкция:

match (TypeBaseOpt)
{
  | Some(value) -> value
  | None() -> []
} -> TypeBase;

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

Каждое вхождение оператора match состоит из образца (pattern), идущего перед стрелкой, и выражения, вычисляемого в случае сопоставления значения с образцом. Выражение идет после стрелки.

Образцы описываются как конструкторы некоего объекта. Так, у типа option[T] есть два конструктора Some(value : T) и None(). Будучи использованными в образце, они сопоставляются с объектом типа option[T].Some, который может хранить значение, и с объектом типа option[T].None, который символизирует отсутствие значения.

СОВЕТ

Если вы не знакомы с концепцией опциональных типов, то можете рассматривать данный тип аналогом Nulable-типа в .NET, но в который можно запаковывать не только значимые (value) типы, но и ссылочные.

Образец:

Some(value)

не просто проверяет, что в TypeBaseOpt находится значение, но и сопоставляет это значение с локальной переменной «value». Таким образом, сопоставление с образцом позволяет производить декомпозицию объектов одновременно с распознаванием типа.

Кроме паттерна «конструктор», описанного выше, сопоставление с образцом поддерживает паттерн списка и паттерн кортежа.

Зависимые свойства

Зависимые свойства (ЗС) и специальный DSL на их основе используются в Nitra для вычислений по AST и являются развитием идеи атрибутных грамматик. Зависимыми они называются потому, что порядок, в котором будут вычисляться их присвоения, зависит от того, какие другие зависимые свойства используются в правой части присвоения (после «=»).

Есть два типа ЗС:

  1. in-свойства.
  2. out-свойства.

Также есть синтаксис для объявления сразу пары in и out свойств. Вот как выглядит объявление ЗС:

in    MyScope      : Scope;  // объявляет ЗС MyScope типа Scope
out   MySymbol     : Symbol; // объявляет ЗС MySymbol типа Symbol
inout Index        : int;    // объявляет ЗС IndexIn и IndexOut

ЗС можно объявлять в AST/декларациях и в символах. Символ для декларации хранится в зависимом свойстве Symbol, так что к нему можно обращаться из его декларации.

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

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

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

Если нужно "протащить" вычисления через ветку AST, можно создать два свойства (одно in и одно out) или объявить inout-свойство, что приведет к формированию пары свойств (одно in и одно out).

Сам язык (DSL) вычислений ЗС очень прост и состоит из двух конструкций:

1. Присвоения:

A.B = C.D + E;
A = X.Y;

2. Вызова метода:

A.B.Method(C.D);

В выражениях (идущих после «=» или находящихся в параметрах функций) допускаются:

  1. Обращения к другим зависимым и структурным свойствам.
  2. Вызовы функций и методов, а также обращение к операторам, не имеющим побочных эффектов.
  3. Вызовы методов объектов-коллекторов. Для них допустимы контролируемые (безопасные) побочные эффекты. См. раздел «Коллекторы».

Другие конструкции недопустимы. В функциях и методах, используемых при вычислении ЗС, может быть произвольный Nemerle- или C#-код, за исключением присвоений ЗС.

Nitra при компиляции вычисляет зависимости между свойствами и генерирует близкий к оптимальному код вычисления ЗС. Процесс вычисления ЗС может быть инициирован вызовом метода EvalProperties() у некоторого узла AST или у Nitra.ProjectSystem.File (выражающего абстракцию файла в Nitra). Все подузлы вычисляются автоматически.

Структурные свойства типа «список AST» (например, Members : Member*) или «опциональный AST» (например, Initializer : ConstructorInitializer?) также могут иметь ЗС. Причем Nitra автоматически генерирует их на основании ЗС, имеющихся у элементов списков, или опционального AST.

Для in-свойств значение, назначаемое ЗС списка, передается в ЗС всех элементов списка.

Для inout-свойств значение, назначаемое ЗС списка, передается в in-свойство первого элемента списка. Далее значение берется из соответствующего out-свойства первого элемента и передается в соответствующее in-свойство второго элемента. Это повторяется до тех пор, пока не будет достигнут последний элемент списка. Значение out-свойств последнего элемента возвращается как значение out-свойства списка. Это позволяет "протащить" вычисление через элементы списка. Подразумевается, что in- и out-свойства одного inout-свойства связаны между собой вычислением внутри каждого элемента списка. Если это не так, вычисления не завершатся. "Протаскивание" производится слева направо.

«Протаскивание» вычислений позволяет обойтись без императивных циклов, рекурсии и функций высшего порядка вроде Fold/Aggregate/Map/Where при вычислении ЗС, а это делает вычисления более декларативными и упрощает знакомство с ЗС.

В примере ниже демонстрируется использование inout-свойства Index:

declaration TypeParameterAlias
{
  symbol
  {
    Kind      = "type parameter alias";
    SpanClass = "NitraCSharpAlias";

    in TypeParameter : TypeParameterSymbol;
  }

  in    TypeParameterSymbols : SCG.IList[TypeParameterSymbol];
  inout Index                : int;

  IndexOut = IndexIn + 1;
  Symbol.TypeParameter = TypeParameterSymbols[IndexIn];
    
  Variance   : Variance;
  Attributes : Attribute*;
}

Для out-свойств возвращается список, содержащий значения соответствующих свойств всех элементов списка.

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

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

Стадии

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

Поддержка стадий в Nitra очень проста. При описании ЗС можно задать стадию, на которой это ЗС может быть доступно. В следующем примере:

abstract ast UsingDirective : ScopedAst
{
stage 1:
  inout Opening : list[Scope];
  in CurrentScope : Scope;
}

ЗС OpeningIn, OpeningOut и CurrentScope доступны только начиная со стадии 1. Обращение к ним на более ранних стадиях будет отложено до указанной стадии, даже если все зависимости уже доступны.

Если попытаться присвоить в ЗС значение, рассчитанное на основе ЗС, объявленных для более поздней стадии:

abstract ast Foobar
{
stage 1:
  out B : int;
stage 2:
  in A : int;

  B = A;
}

компилятор Nitra выдаст сообщение об ошибке:

error : Reversing stage dependency detected: property 'Foobar.B' from stage '1' value depends on property 'Foobar.A' from stage '2'.

Таким образом, вы не сможете случайно смешать стадии.

ЗС, объявленные без указания стадии или на нулевой стадии, могут использоваться везде.

Символы

Программы похожи на своеобразные СУБД. В программах есть именованные сущности, хранящие информацию, и есть ссылки на эти сущности в виде имен. Например, локальная переменная – это сущность, имеющая такие свойства, как тип, область видимости, в которой объявлена переменная, и инициализирующее выражение. У каждой переменной есть имя, идентифицирующее эту переменную в рамках области видимости, в которой она объявлена. В других частях программы можно ссылаться на переменную по ее имени.

Такие именованные сущности в Nitra называются символами. Каждый символ должен иметь имя и произвольный набор свойств.

Символы создаются для всех значимых сущностей в ЯП. Например, в C# символы создаются для: пространств имен, типов (классов, делегатов, перечислений, структур, встроенных типов и т.п.), членов типов (методов, свойств, полей, событий, ...), параметров методов, параметров типов, локальных переменных и т.п. Список далеко не полон. Главное понять, что символ – это основной способ описать некоторую именованную сущность языка, на которую можно ссылаться посредством имени (простого или квалифицированного, не важно).

Для одной логической сущности в программе создается один экземпляр символа. Каждый символ имеет свой тип, который может отличаться от типа других символов. У типа символов может быть несколько предков. Например, в C# у символа TopClassSymbol (класса, объявляемого в пространстве имен) может быть два предка GenericContainerTypeSymbol и TypeMemberSymbol, у символа NestedClassSymbol предками могут быть GenericContainerTypeSymbol и TypeMemberSymbol. Другими словами, для типов символов (как и для типов AST) поддерживается множественное наследование.

В Nitra нет специальной конструкции для объявления типов символов. Тип символов автоматически объявляется при объявлении декларации. Например, если написать:

declaration TopClass : GenericContainerType, NamespaceMemberDeclaration
{
}

то будет создана не только декларация TopClass (специальный тип именованного AST), но и символ TopClassSymbol. Как ясно из примера, имя типа символа формируется из имени декларации и суффикса «Symbol».

Как и AST, символы могут содержать объявления зависимых свойств. Но, в отличие от AST, символы не могут содержать структурных свойств.

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

Символы отличаются от деклараций тем, что декларации описывают синтаксическую структуру, а символы – логическую. Так, в некоторых языках поддерживаются составные (partial) сущности. Например, в C# поддерживаются пространства имен и partial-классы. У таких сущностей может быть несколько деклараций, но только один символ. Символ агрегирует информацию из всех деклараций составной сущности. Например, для следующего кода на C#:

partial class A
{
  public void Foo() { }
}

partial class A
{
  public int X;
}

будет создано два экземпляра декларации TopClass и только один экземпляр символа TopClassSymbol.

При этом созданный экземпляр символа будет помещен в зависимое свойство Symbol каждой из деклараций, а ссылки на обе декларации будут помещены в свойство Declarations символа. По умолчанию Nitra проделывает все это автоматически, но при желании можно переопределить это поведение и создать символы вручную.

Объединение (merging) разных деклараций в один символ также делается автоматически. Для того чтобы Nitra могла понимать, какие символы можно объединять, а какие нет, для каждого типа символа можно переопределить две функции, объявленные в самом базовом типе символов:

public virtual IsSameIdentity(candidate : Declaration) : bool;
public virtual CanMerge(candidate : Declaration) : bool;
ПРЕДУПРЕЖДЕНИЕ

На сегодня методы Nitra не позволяют объявить методы IsSameIdentity и CanMerge в разделе symbol соответствующей декларации. Их придется объявлять в partial-классе, имеющем то же имя, что и у символа, и находящемся в том же пространстве имен. Nitra генерирует класс для каждого символа. При этом такие классы помечаются как partial специально для того, чтобы пользователи могли добавить в них любые методы на Nemerle или C#.

Реализация IsSameIdentity должна ответить, сравнима ли переданная декларация с данным символом. Например, реализация этой функции для символа C#-класса должна возвратить true, если у текущего экземпляра классов нет (ноль) параметров типов, а в качестве параметра «candidate» передано пространство имен, другой класс или структура, у которых также нет параметров типов. Можно говорить, что у всех этих сущностей единый ключ идентичности. Если такие сущности объявлены в одной таблице имен, связывание по правилам C# не сможет выбрать один из них и произойдет ошибка.

Реализация CanMerge должна отвечать на вопрос «Можно ли объединить данный символ и переданную в качестве параметра candidate декларацию?». Если CanMerge возвращает true, IsSameIdentity также обязана возвращать true.

Реализовав эти две функции для типа символа, вы автоматически получаете поддержку составных символов, а всю «головную боль» по их объединению и оповещению пользователей языка об ошибках берет на себя Nitra.

Зависимые свойства, принадлежащие символу, описываются внутри конструкции symbol, помещаемой внутрь декларации, порождающей символ. Например, вот как выглядит описание зависимых свойств для символа, являющегося базовым для всех сущностей, поддерживающих параметры типов в C#:

abstract declaration GenericEntity : BindableAst
{
  symbol
  {
    out TypeParametersCount : int = GetTypeParametersCount(this.Declarations);
    out TypeParameters      : SCG.IList[TypeParameterSymbol] = CreateTypeParameters(TypeParametersCount);
  }
  ...
}

Отсутствие конструкции symbol никак не влияет на порождение символа. Просто у такого символа не будет свойств, специфичных для данной декларации (но они могут быть унаследованы от предков).

Иерархия наследования символов полностью совпадает с иерархией наследования порождающих их деклараций. Единственным исключением являются «sealed» символы.

sealed-символы

Иногда требуется порождать один и тот же тип символа разными языковыми конструкциями. Примером могут служить пространства имен C#. Пространство имен может быть объявлено явно или неявно. Следующий пример:

namespace Z
{
}

порождает пространство имен Z явно. А, следующий пример:

namespace X.Y.Z
{
}

порождает пространство имен Z явно, а так же пространства имен X и Y неявно.

У явной декларации есть тело, которое может содержать члены пространства имен, и есть ряд литералов (фигурные скобки и ключевое слово «namespace»), выделяющих эту декларацию среди прочих. Неявная же декларация пространства имен является всего лишь простым именем внутри составного имени явной декларации.

Однако, что явная, что не явная декларация все равно порождают символ пространства имен. Причем это один и тот же символ – NamespaceSymbol. Но, как было сказано выше, каждый тип декларации автоматически порождает новый тип символа. Чтобы преодолеть это правило, можно ввести для деклараций ExplicitNamespace и ImplicitNamespace общую базовую декларацию Namespace и поместить перед ее конструкцией symbol ключевое слово sealed. Вот как это выглядит на практике:

abstract declaration Namespace : NamespaceMemberDeclaration
{
  sealed symbol // символа запечатан!
  {
    ...
  }
}

declaration ImplicitNamespace : Namespace
{
  ...
}

declaration ExplicitNamespace : Namespace, NamespaceBody
{
  ...
}

abstract ast NamespaceBody : BindableAst
{
  ...
}

Обратите внимание на строчку с комментарием. В ней указывается, что символ у декларации Namespace запечатывается (помечается как sealed) – это предотвращает порождение наследников от NamespaceSymbol, несмотря на то, что декларация Namespace абстрактна, и у нее есть наследники. И декларация ImplicitNamespace, и ExplicitNamespace будут порождать один тип символа – NamespaceSymbol. Если попытаться описать секцию symbol в ImplicitNamespace или в ExplicitNamespace, компилятор Nitra выдаст сообщение об ошибке.

Сообщение об ошибке будет выдано и в случае, если попытаться унаследовать декларацию от двух деклараций с запечатанным символом или от декларации с запечатанным символом и декларации, имеющей секцию symbol. Грубо говоря, Nitra не позволит изменять запечатанные символы. Все наследники декларации с запечатанным символом будет иметь такой же тип символа.

Коллекторы

Зависимые свойства можно присваивать ровно один раз. Если нужно собрать информацию для составного (multipart) символа, это может быть неудобно. Для упрощения сбора информации в таких случаях нами были разработан специальный вид объекта – коллектор.

Особенностью коллекторов является то, что они работают в двух режимах. В первом режиме производится наполнение коллектора данными, а во втором – использование этих данных. Некоторые члены коллектора помечаются специальным атрибутом CollectAttribute. Эти члены можно вызывать, когда коллектор работает в режиме сбора данных. Остальные члены можно использовать только на следующих проходах. Если обратиться к таким членам изнутри выражения, рассчитывающего некоторого зависимое свойство, то расчет этого выражения будет отложен до следующего прохода. При этом гарантируется, что во время прохода инициализации будут выполнены все обращения к инициализирующим членам.

Члены, работающие на стадии использования коллектора, не должны изменять состояние этого коллектора, а члены, работающие на стадии инициализатора, не должны позволять прочесть его состояние. Это гарантирует, что данные не будут прочитаны в неконсистентном состоянии.

Если данные из одного коллектора помещаются в другой коллектор, появляется неявная зависимость, и заполнение этих объектов автоматически разводится на разные проходы. Вкупе с зависимыми свойствами это позволяет не задавать явно порядок обхода AST, но в то же время иметь возможность легко, удобно и безопасно собирать по нему данные.

Ниже приведен пример коллектора для модификаторов C# и его использования в зависимых свойствах.

public sealed class ModifierSet : ICollector, IEquatable[ModifierSet]
{
  public this(context : DependentPropertyEvalContext)
  {
    context.NotifyStagedObjectCreated(out CreatedOnStage, out CreatedOnPass);
  }

  public CreatedOnStage : int  { get; }
  public CreatedOnPass  : int  { get; }
  public IsEmpty        : bool { get { _modifiers == Modifiers.None } }

  private mutable _modifiers : Modifiers;

  [Collect]
  public Add(modifiers : Modifiers) : void
  {
    _modifiers |= modifiers;
  }

  [Collect]
  public Add(modifiers : ModifierSet) : void
  {
    _modifiers |= modifiers.GetModifiers();
  }

  public GetModifiers() : Modifiers
  {
    _modifiers
  }

  public Contains(modifiers : Modifiers) : bool
  {
    _modifiers %&& modifiers
  }

  public override ToString() : string
  {
    $"$CreatedOnStage($CreatedOnPass): $_modifiers"
  }

  public Equals(other : ModifierSet) : bool
    implements IEquatable[ModifierSet].Equals
  {
    | null => false
    | _    => this._modifiers == other._modifiers
  }

  public override Equals(other : object) : bool
  {
    | ModifierSet as other => Equals(other)
    | _                    => false
  }

  public override GetHashCode() : int
  {
    _modifiers :> int
  }
}

Красным выделены участки кода, относящиеся к поддержке коллекторов.

Как видите, данный класс просто собирает информацию о модификаторах в битовое поле. Кроме этого, он реализует паттерн «Коллектор», для чего хранит информацию о стадии (CreatedOnStage) и проходе (CreatedOnPass) данных которых коллектор был создан. Эта информация берется из контекста, передаваемого через параметр конструктора. Методы Add помечены атрибутом Collect. Этого достаточно, чтобы Nitra реализовала описанный выше протокол работы с коллектором.

А вот как используется данный коллектор:

abstract ast Modifier
{
  in Flags : ModifierSet; // 1

  | New       { Flags.Add(Modifiers.New      ); } // 2
  | Protected { Flags.Add(Modifiers.Protected); } // 2
  | Public    { Flags.Add(Modifiers.Public   ); } // 2
  | Internal  { Flags.Add(Modifiers.Internal ); } // 2
  | Private   { Flags.Add(Modifiers.Private  ); } // 2
  | Virtual   { Flags.Add(Modifiers.Virtual  ); } // 2
  | Volatile  { Flags.Add(Modifiers.Volatile ); } // 2
  | Static    { Flags.Add(Modifiers.Static   ); } // 2
  | Readonly  { Flags.Add(Modifiers.Readonly ); } // 2
  | Sealed    { Flags.Add(Modifiers.Sealed   ); } // 2
  | Override  { Flags.Add(Modifiers.Override ); } // 2
  | Abstract  { Flags.Add(Modifiers.Abstract ); } // 2
  | Extern    { Flags.Add(Modifiers.Extern   ); } // 2
  | Unsafe    { Flags.Add(Modifiers.Unsafe   ); } // 2
  | Async     { Flags.Add(Modifiers.Async    ); } // 2
}

abstract declaration Type : BindableAst
{
  symbol
  {
    in Flags : ModifierSet; // 3
  }

  Symbol.Flags        |= Modifiers.Flags; // 4
  Modifiers.Flags      = ModifierSet(context); // 5
  Attributes.NameScope = Scope;

  unless (Modifiers.Flags.IsEmpty || Modifiers.Flags.Equals(Symbol.Flags)) // 6
    Error("Partial declarations of type have conflicting accessibility modifiers.");

  Attributes : Attribute*;
  Modifiers  : Modifier*;
}

В AST Modifier объявляется зависимое свойство типа ModifierSet и происходит заполнение его значениями. Аналогичное свойство объявляется в символе TypeSymbol (автоматически объявляемом декларацией Type). При этом его значение инициализируется оператором «|=». Этот оператор проверяет, установлено ли свойство, идущее слева от оператора, если нет, он создает новый коллектор и инициализирует им свойство. Далее производится вызов метода, помеченного атрибутом Collect (в данном случае метода Add). Если свойство уже инициализировано, то сразу производится вызов метода, помеченного атрибутом Collect.

Таким образом, в данном примере первой вычисляется строка с номером 5. В ней создается новый экземпляр коллектора, который присваивается свойству Modifiers.Flags. Так как Modifiers – это структурное свойство, хранящее коллекцию узлов AST типа Modifier, данная строчка передает ссылку на созданный коллектор каждому элементу коллекции.

Далее для каждого Modifier из коллекции Modifiers выполняется строчка 2. Это приводит к тому, что коллектор заполняется значениями модификаторов, AST которых находится в списке.

Далее выполняется строчка 4. Так как данная строчка пытается обратиться к содержимому коллектора, данное вычисление откладывается до следующего прохода. Это гарантирует, что к моменту считывания значения коллектора он уже должным образом проинициализирован. Так как Symbol.Flags не проинициализирован, оператор «|=» инициализирует его новым коллектором, после чего производит копирование в него значения первого коллектора. Если Type состоит из нескольких частей (multi-part), то создание коллектора и инициализация свойства произойдет один раз, а вызов метода, помеченного атрибутом Collect, произойдет столько раз, сколько частей имеет Type. Это обеспечивает одноактный сбор всех атрибутов у всех частей. При этом в Symbol.Flags помещаются все модификаторы всех частей.

Строка 6, содержащая проверку условия и вывод сообщения об ошибке (если это условие ложно), выполняется только после того, как оба коллектора (ссылки на которые находятся в Symbol.Flags и в Modifiers.Flags) перейдут в состояние использования. Это гарантирует, что к моменту проверки условия сбор данных для обоих коллекторов будет завершен. В результате условие успешно рассчитается и приведет или не приведет к генерации сообщения об ошибке. Так как генерация сообщения об ошибке также является зависимым вычислением, оно будет произведено ровно по одному разу для каждого AST (для каждой части декларации Type), и мы без труда получим корректные диагностические сообщения для каждой части типа.

Области видимости, таблицы имен и связывание

Области видимости – объект Scope

Для того чтобы в коде заработало связывание имен, нужно правильно вычислить и задать области видимости. Области видимости в Nitra представлены наследниками класса Scope. Этот класс имеет очень простой интерфейс:

public abstract class Scope
{
  public abstract BindMany[TSymbol](reference : Reference, results : ref LightList[TSymbol]) : void
    where TSymbol : ISymbol;

  public abstract MakeCompletionList(prefix : string) : Seq[ISymbol];

  public Bind[TSymbol](reference : Reference) : Ref[TSymbol]
    where TSymbol : ISymbol;

  /// Если не может связать, возвращает AmbiguousSymbol или UnresolvedSymbol.
  public TryBind[TSymbol](reference : Reference) : Ref[TSymbol]
    where TSymbol : ISymbol;
}

У этого класса есть несколько наследников:

Как ни странно, но данная иерархия классов позволяет описать области видимости любой сложности и для любых языков программирования.

Если в языке встречается какое-то уж очень необычное поведение, его можно реализовать, создав наследника классов Scope или TableScope, и переопределить в нем методы BindMany, MakeCompletionList и т.п. Например, в реализации C# на Nitra нам пришлось создать класс AttributeLookupScope, чтобы реализовать принятое в C# поведение при связывании имен атрибутов в атрибутных секциях.

public class AttributeLookupScope : Scope
{
  public this(scope : Scope)
  {
    _scope = scope;
  }

  private _scope : Scope;

  public override BindMany[TSymbol](reference : Reference, results : ref LightList[TSymbol]) : void
  {
    _scope.BindMany(reference, ref results);

    def reference2 = Reference(reference.File, reference.Span, reference.Text + "Attribute");
    _scope.BindMany(reference2, ref results);
  }

  public override MakeCompletionList(prefix : string) : Seq[ISymbol2]
  {
    _scope.MakeCompletionList(prefix)
  }

  public override ToString() : string
  {
    "attribute lookup for " + _scope
  }
}

Этот наследник Scope используется в атрибутных секциях, обеспечивая там связывание не только по полному имени атрибута, но и без учета суффикса «Attribute».

Связывание

Код, выполняющий связывание в Nitra, располагается в библиотечном AST с именем Reference:

ast Reference
{
  in  Scope : Scope;
  out Ref   : Ref[ISymbol2] = Scope.TryBind(this);
}

В принципе, автор языка может создать собственный аналог, но обычно Reference вполне достаточно. К тому же для этого типа выполняется некоторая автоматизация. Просто пометьте атрибутом Reference правило, разбирающее идентификаторы в вашем языке, и вы получите автоматическое отображение результатов разбора этого правила в AST Reference, задайте область видимости (Scope) для него, и вы автоматически получите связывание имен.

Связывание имени производится методом TryBind (описан выше) объекта Scope. Связывание произойдет сразу после того, как станет доступным (вычисленным) свойство Scope. Так что все, что от вас требуется – это правильно сформировать Scope и подставить его в это свойство.

Даже если вы все сделали правильно, это не значит, что связывание произойдет без проблем. В предоставленной области видимости может не быть связываемого имени, или имя может встречаться более одного раза. Именно поэтому результатом связывания является не ссылка на искомый символ, а специальный объект-обертка Ref.

Ref<TSymbol> и IRef

Тип Ref<TSymbol> предназначен для хранения информации о результате связывания. Для хранения разных результатов связывания у этого типа есть ряд наследников (точнее этот тип является вариантным типом Nemerle, но для C#-программиста этот тип выглядит как абстрактный класс, имеющий вложенных в него запечатанных (sealed) наследников):

Some – используется когда связывание прошло успешно, найденный кандидат соответствует типу параметра типов методов Bind/TryBind (объекта Scope) или методов Resolve/TryResolve (объекта Ref<TSymbol>). Получив Ref<TSymbol>.Some, вы можете смело обращаться к свойству Symbol и зависящим от него свойствам. Движок IDE использует эту информация для навигации по коду.

Unresolved – используется, когда в процессе связывания не было найдено ни одного подходящего символа (с нужным именем, и тип которого соответствует параметру типа, заданному в методе, производившем связывание: Bind/TryBind/Resolve/TryResolve). У такого объекта можно узнать текст ссылки (имя, которое пытались связать), файл, положение в файле (где встретилась эта ссылка), а также Scope, на котором производилась попытка связать имя. Движок IDE использует эту информация для реализации функции автодополнения при вводе (Word Complenion).

Ambiguous – создается когда в заданной области видимости удается найти более одного символа с искомым именем и типом. У Ref<TSymbol>.Ambiguous можно узнать список найденных символов. Кроме того, у него можно вызвать метод Resolve или TryResolve, чтобы попытаться произвести выбор между неоднозначными символами. Например, в одной области видимости могут быть доступны классы с именем «X», но с разным количеством параметров типов, а в данном месте AST известно, что требуется класс с конкретным количеством параметров типов (предположим, с одним). Вызвав метод TryResolve и передав ему необходимый алгоритм разрешения имен, вы можете устранить неоднозначность. Метод TryResolve не изменяет состояние объекта Ref<TSymbol>, на котором он вызывается. Вместо этого он возвратит новый экземпляр объекта Ref<TSymbol>, который можно поместить в другое зависимое свойство. Таким образом, на AST остается история всех связываний и разрешения имен, что позволяет движку IDE предоставлять пользователям больше информации и возможностей.

Методы Resolve/TryResolve объявлены непосредственно в классе Scope, так что их можно вызывать, не заботясь о том, какой конкретно тип находится в зависимом свойстве. При правильной реализации разрешения имен все наследники Scope кроме Ambiguous должны породить Ref<TSymbol>, аналогичный текущему. Впрочем, не исключено, что в каком-то языке это будет не так. Вы вольны сами решать, как будут вести себя алгоритмы разрешения имен в ваших языках.

Пример – простой язык

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

var x = 2 + y;
var y = 3 * 4 + 28;

Особенностью данного языка является то, что в нем поддерживается связывание имен, и имена доступны глобально, т.е. переменные могут быть объявлены в другом файле или ниже по файлу и все равно будут видны.

Грамматика

Для начала опишем синтаксис.

Файл: Sample-Syntax.nitra
syntax module Sample // 1
{
  using Nitra.CStyleComments; // 2
  using Nitra.Core;           // 2

  keyword regex ['a'..'z', '_'..'_']+ rule S; // 3

  regex Keyword = "var"; // 4

  [Reference]                                // 5
  token Reference = !Keyword IdentifierBody; // 6

  [Name]                                // 7
  token Name = !Keyword IdentifierBody; // 8

  [StartRule]
  syntax TopRule = (VariableDeclaration nl)*; // 9

  syntax VariableDeclaration = "var" sm Name sm "=" sm Expression ";"; // 10

  syntax Expression // 11
  {
    | [SpanClass(Number)] Num = Digits // 12
      {
        regex Digits = ['0'..'9']+; // 13
      }

    | Braces = "(" Expression ")"; // 14
    | Variable = Reference; // 15

  precedence Sum: // 16
    | Sum = Expression sm Operator="+" sm Expression; // 17
    | Sub = Expression sm Operator="-" sm Expression; // 17

  precedence Mul: // 18
    | Mul = Expression sm Operator="*" sm Expression; // 19
    | Div = Expression sm Operator="/" sm Expression; // 19

  precedence Unary: // 20
    | Plus  = Operator="+" Expression // 21
    | Minus = Operator="-" Expression // 21
  }

  extend token IgnoreToken // 22
  {
    | [SpanClass(Comment)] SingleLineComment // 23
    | [SpanClass(Comment)] MultiLineComment  // 23
  }
}

Строка 1 декларирует синтаксический модуль Sample, в котором размещена грамматика нашего демонстрационного языка.

Строки 2 – это знакомые по C# директивы «using». В отличие от C#, они открывают не только пространства имен, но также синтаксические модули и статические классы. Члены открытых пространств имен или модулей доступны по не квалифицированному имени.

Nitra.Core и Nitra.CStyleComments – это имена стандартных синтаксических модулей, входящих в стандартную библиотеку Nitra (Nitra.Runtime.dll). В Nitra.Core находятся стандартные имена для литералов, маркеры для разметки pretty-print и свертывания кода (outlining), правила для пробельных (void) правил и для разбора идентификаторов. В Nitra.CStyleComments находятся правила комментариев языка C.

Директива «keyword regex» (строка 3) задает имя правила (в данном случае S), которое будет вставляться за ключевыми словами, описываемыми в грамматике как литералы. В нашей грамматике есть только одно ключевое слово "var", но мы все равно задаем правило, распознающее ключевое слово, регулярным выражением «['a'..'z', '_'..'_']+», чтобы не приходилось его изменять, если в дальнейшем в языке появятся новые ключевые слова.

В строке 4 определяется regex-привило Keyword. Его использование можно увидеть только в предикатах (в строках 6 и 7).

В строке 6 определяется token-правило Reference. Оно помечено одноименным атрибутом (строка 5). Этот атрибут указывает, что данное правило используется как простая (не квалифицированная) ссылка на именованную сущность. В нашем простом языке единственным представителем именованной сущности является переменная.

Строка 8 содержит определение правила Name. Оно аналогично правилу Reference, за тем исключением, что определяет не ссылку на имя, а само имя.

Reference и Name по сути описывают идентификатор языка. Разделение на два правила сделано для того, чтобы отличать использование идентификатора для объявления имени от его же использования для ссылки на имя. Разметка атрибутами порождает автоматическое отображение (mapping). Атрибут Name создает отображение на AST типа Nitra.Runtime.Binding.Name, а Reference, соответственно, на Nitra.Runtime.Binding.Reference. Оба типа AST объявлены в стандартной библиотеке и используются в стандартной схеме связывания имен, предоставляемой Nitra.

Правило TopRule (строка 9) является стартовым правилом языка. Оно помечается атрибутом StartRule. В этом правиле содержится один цикл, содержащий в себе объявления переменных – VariableDeclaration. «nl» - это маркер, говорящий Nitra, что при pretty-print за каждым объявлением переменной должен следовать перевод строки. Если не добавить эту аннотацию, все переменные будут отображаться в одной строке.

Строка 10 описывает правило VariableDeclaration, разбирающее отдельное объявление переменной. Маркер sm задает места, где при pretty-print-е должен печататься пробел. Без него все будет сливаться. Имя переменной разбирается правилом Name, так как это имя нового символа, а не ссылка на него.

Строки 11 и все, что идут за ней в фигурных скобках, описывают расширяемое правило Expression, описывающее набор выражений нашего языка. Каждый символ «|» начинает декларацию нового расширяющего правила. Такие правила задают альтернативы для Expression.

Правило Num (строка 12) разбирает целочисленный литерал (число). Атрибут SpanClass(Number) задает имя, определяющее стиль раскраски. В Nitra такое имя называется «span class». Span class-ы задаются в языковых файлах (.nleng). Там же определяются значения стиля отображения, принимаемые по умолчанию для span class-ов. Span class-ы используются в плагинах к IDE для подсветки синтаксиса. Для каждого span class в IDE создается запись, описывающая стиль подсветки для участков кода, размеченных span class-ом.

Строка 13 задает вложенное regex-правило, которое и производит реальный разбор числа.

Строка 14 описывает выражение, взятое в скобки.

Строка 15 описывает ссылку на переменную (имя переменной).

Строка 16 описывает группы таблицы приоритетов. Правила (строки 17), задаваемые ниже такой строки, имеют одинаковый приоритет. Если ниже идет еще одна группа (строка 18), то правила (строки 19), идущие за ней, имеют более высокий приоритет, нежели правила, идущие перед ней. Располагая правила сверху вниз и разделяя их группами приоритетов, можно задавать относительный приоритет операторов.

Чтобы задать только отношение между группами приоритетов (без указания правил), можно описать их, последовательно опуская правила. Это удобно, если вы расширяете другой язык и вам нужно вклиниться между уже существующими группами приоритетов. Например, если в расширяемом языке есть группы приоритетов Mul и Unary (как в нашем примере), а нужно добавить группу приоритетов Power, которая бы была приоритетнее Mul и менее приоритетной, чем Unary, то можно написать следующий код:

extend syntax Expression
{
  precedence Mul:
  precedence Power:
    | Pow = Expression sm Operator="^" sm Expression;    
  precedence Unary:
}

Оператор Pow будет иметь приоритет выше, чем операторы, относящиеся к группе Mul (т.е. операторы Sum и Sub), но ниже, чем операторы, относящиеся к группе Unary.

Строки 21 описывают унарные операторы Plus и Minus. Они относятся к группе приоритетов (Unary) и имеют наивысший приоритет в нашем языке.

Строка 22 задает расширение правила IgnoreToken, расположенного в модуле Nitra.Core стандартной библиотеки. Это правило используется в правиле «s», стандартной библиотеки, описывающем пробельное правило (расставляемое автоматически в грамматиках Nitra). Таким образом, данная конструкция расширяет список игнорируемых токенов комментариями языка C.

Если скомпилировать данную грамматику и загрузить в Nitra.Visualizer, мы получим подсветку синтаксиса нашего нового языка и pretty-print для него.

Чтобы наш язык начал делать что-то полезное, нужно описать для него AST, отобразить на него правила (автоматически формируемое для них дерево разбора) и описать вычисления на AST.

Начнем с AST.

AST

Файл: Sample-Ast.nitra
using Nitra;
using Nitra.Runtime;
using Nitra.Declarations;
using Nitra.Runtime.Binding;

ast Top // 1
{
  in ContainingTable : TableScope; // 2

  Variables.ContainingTable = ContainingTable; // 3

  Variables  : Variable*; // 4
}

declaration Variable // 5
{
  symbol // 6
  {
    Kind = "var";
  stage 1:
    in Result : double; // 7
  }

  Expression.Scope = ContainingTable;   // 8
  Symbol.Result    = Expression.Result; // 9

  Expression : Expression; // 10
}

abstract ast Expression // 11
{
stage 1: // 12
  in  Scope  : Scope;  // 13
  out Result : double; // 14
}

ast Number : Expression // 15
{
  Result = Value.ValueOrDefault; // 16

  Value : double; // 17
}

abstract ast Binary : Expression // 18
{
  Expression1.Scope = Scope; // 19
  Expression2.Scope = Scope; // 20

  Expression1 : Expression; // 21
  Expression2 : Expression; // 22
}

ast Sum : Binary // 23
{
 Result = Expression1.Result + Expression2.Result; // 24
}

ast Sub : Binary // 25
{
  Result = Expression1.Result - Expression2.Result; // 26
}

ast Mul : Binary // 27
{
  Result = Expression1.Result * Expression2.Result; // 28
}

ast Div : Binary // 29
{
  Result = Expression1.Result / Expression2.Result; // 30
}


abstract ast Unary : Expression // 31
{
  Expression.Scope = Scope; // 32
  
  Expression : Expression; // 33
}

ast Plus  : Unary // 34
{
  Result = Expression.Result; // 35
}

ast Minus : Unary // 36
{
  Result = -Expression.Result; // 37
}


ast VariableRef : Expression // 38
{
  out Ref : Ref[VariableSymbol] = Name.Ref.TryResolve[VariableSymbol](); // 39

  Name.Scope = Scope; // 40
  Result   = Ref.Symbol.Result; // 41

  Name : Reference; // 42
}

В строке 1 и идущих за ней фигурных скобках описывается корневая ветка AST – «Top». В строке 4 объявляется структурное поле Variables типа список Variable. Структурные свойства заполняются в процессе отображения, который запускается сразу после окончания парсинга (например, при изменении файла в IDE). Описание отображения будет приведено ниже.

Variable – это декларация. Она объявлена ниже, в строке 5. Как было сказано выше, декларация – это вид AST, который объявляет символ. В частности, декларация Variable объявляет символ VariableSymbol. Он доступен в декларации Variable через зависимое свойство «Symbol». Обращение к нему можно увидеть в строке 9. Сам символ создается автоматически, когда становится доступно значение зависимого свойства ContainingTable. ContainingTable – это свойство типа TableScope, которое объявлено в AST типа ScopedAst (в стандартной библиотеке). Так как декларации автоматически наследуются от этого AST, у всех деклараций есть это свойство.

В строке 2 в AST Top объявляется зависимое in-свойство ContainingTable. Так как Top является корнем AST, это свойство необходимо присвоить извне. Это делается в специальном методе RefreshProject, о котором будет рассказано ниже. Там создается единый объект типа TableScope, который присваивается в свойство ContainingTable всех файлов в проекте. Это приводит к тому, что все переменные нашего языка «живут» в одной области видимости. Так что переменная, объявленная в одном файле, будет видна в другом. Это примерно соответствует глобальному пространству имен в более серьезных языках.

В строке 3 происходит присвоение значения свойства ContainingTable текущей ветки AST свойству Variables.ContainingTable. Тут происходит некоторая «магия» Nitra, так что на этом моменте стоит остановиться подробнее.

Variables – это имя структурного свойства, объявленного в Top. Это поле имеет тип «Variable*», что означает «список AST типа Variable». У Variable есть свое поле ContainingTable типа TableScope. Как говорилось выше, оно унаследовано от ScopedAst. Магия заключается в том, что Nitra, видя свойства у элементов списка, автоматически добавляет аналогичные свойства в тип списка (см. раздел «Зависимые свойства»). В данном случае у списка типа «Variable*» появляется in-свойство ContainingTable типа TableScope. Задаваемое ему значение автоматически предается аналогичным свойствам всех элементов коллекции. Таким образом строчку:

  Variables.ContainingTable = ContainingTable;

можно рассматривать как замену вот такому императивному псевдо-коду на C#:

foreach (var variable in Variables)
  variable.ContainingTable = ContainingTable;

Говоря более образно, присвоение из строки 3 передает таблицу имен всем переменным, объявленным внутри файла. При присвоении значения свойству ContainingTable декларации Variable будет автоматически создан символа VariableSymbol. Ссылка на этот символ будет помещена в свойство Symbol декларации и в таблицу имен, присвоенную в свойство ContainingTable. В дальнейшем это позволит находить переменные по их имени. Обратите внимание на то, что код создания символа не написан явно. Его автоматически генерирует Nitra. Если бы его пришлось писать вручную, то он выглядел бы примерно так:

Symbol = ContainingTable.Define<VariableSymbol>(this, context);

В строке 8 область видимости, на которую ссылается зависимое свойство ContainingTable, назначается зависимому свойству Expression.Scope. Expression – это структурное свойство типа Expression, которое объявлено в строке 10. Описание AST-типа Expression находится на строке 11. На него отображаются выражения нашего языка. Свойство Scope объявлено в строке 13. Тип этого свойства Scope. Так как в выражениях происходит только связывание имен (т.е. поиск, а не определение), можно передавать туда абстрактный тип Scope, а не конкретный TableScope. Использование абстрактного Scope позволяет помещать в свойство как TableScope, так и комбинацию различных наследников Scope, что позволяет виртуально объединять области видимости или скрывать одни области видимости другими. Комбинирование областей видимости позволяет описывать самые изощренные правила статической видимости для самых различных языков.

Зависимое свойство Scope (со строки 13) интереснее еще и тем, что оно относится к стадии 1. Это происходит потому, что оно объявлено после декларации «stage 1:» (строка 12). Декларация «stage» задает стадию, на которой могут быть присвоены свойства, идущие после нее. Стадии задаются целочисленными значениями, начиная с нуля. Если не задана никакая стадия, это означает, что свойство может быть присвоено, начиная с нулевой стадии, а это значит, что оно может быть присвоено когда угодно. Мы понимаем, что безликие числа – это нехорошо. В дальнейшем мы откажемся от чисел в пользу имен (наподобие того, как это сделано с группами приоритетов).

Так как свойство Scope может быть присвоено только на стадии «1», а попытка его присвоения (в строке 8) производится на стадии «0», она откладывается (как бы «подвисает») до наступления следующей стадии. Тем временем стадия «0» продолжает свою работу, что приводит к посещению всех деклараций переменных и к тому, что все символы, описывающие переменные, попадают в глобальную таблицу имен. К моменту, когда наступает вторая стадия, в таблице имен, переданной каждому выражению через свойство Scope, уже содержатся все имена всех переменных.

AST Expression является абстрактным базовым AST, от которого унаследованы: Number, Binary, Unary, VariableRef (строки: 15, 18, 31 и 38, соответственно), а опосредовано еще и: Sum, Sub, Mul, Div, Plus и Minus (строки: 23, 25, 27, 29, 34 и 36).

Если в выражении встречается число (числовой литерал), оно отображается в Number (строка 15). При этом значение числа помещается в структурное поле Value (строка 17). Обратите внимание, что тип этого свойства – double. Это обычный .NET-тип. Nitra допускает использование в качестве типа структурного свойства только AST, NSpan и примитивные типы (string, int, double, и т.п.). Значения примитивных типов запаковываются в специальную структуру Nitra.ParsedValue<T>. ParsedValue<T>, во-первых, хранит позицию в тексте, из которой было получено значение (ее нужно задать при отображении вручную), а во-вторых, если длина позиции равна нулю, значение свойства считается не вычисленным. Такое может произойти, если парсер восстановит как пропущенное (Missing) правило, содержащее такое структурное свойство. Узнать о том, доступно ли значение, можно через свойство HasValue, которое имеется у ParsedValue<T>, или проверив длину (Length) у свойства Span. У типа ParsedValue<T> есть еще два свойства Value, хранящие значения, и ValueOrDefault, которое вернет разобранное значение или значение, принятое по умолчанию (для хранимого типа), если значение не задано. Для целочисленных типов значением по умолчанию является 0.

В строке 16 используется свойство ValueOrDefault для вычисления зависимого свойства Result, объявленного в базовом AST Expression (строка 14). Это приведет к тому, что в случае ошибки восстановления парсера будет браться ноль. Если это не подходит, можно хранить результат также завернутым в ParsedValue<T>, протягивая его сквозь вычисления. Впоследствии это также поможет выдать сообщение об ошибке, указывающее на истинное место возникновение ошибки. Впрочем, парсер сам сообщит об ошибке парсинга, так что в данном примере мы просто воспользуемся значением по умолчанию.

В строке 18 объявляется базовый AST для всех бинарных операторов. В нем определяется два структурных свойства Expression1 (строка 21) и Expression2 (строка 22). Тип этих свойств - Expression. Это позволяет хранить в них любые подвыражения. Такое рекурсивное определение характерно для расширяемых правил, хранящих операторы.

Строки 19 и 20 передают полученную сверху область видимости своим подвыражениям.

На строках 23, 25, 27 и 29 объявляются наследники Binary: Sum, Sub, Mul и Div соответственно. Они наследуют структурные свойства и вычисление зависимых свойств у Binary, так что в них остается только лишь описать вычисление свойства Result, вычисление которого осталось не заданным в Binary. Каждый из наследников Binary использует для вычисления своего свойства Result значения свойств Result из вложенных подвыражений. Каждый из наследников использует свой оператор для вычисления. То же самое происходит и с унарными операторами. В стоке 31 описан базовый AST для всех унарных операторов, а в строках 34 и 36 – наследники, реализующие логику конкретных операторов.

Если мы захотим расширить наш язык новыми операторами, то все, что нам придется сделать – это создать новые наследники Expression, Binary или Unary, и реализовать в них нужную логику. Это можно сделать как в той же, так и в отдельной сборке. Можно даже реализовать динамическую подгрузку расширений.

Последний тип узла AST VariableRef (строка 38) - самый интересный, так как в нем производится связывание имени с переменной (с описывающим ее символом). Вычисление его зависимого свойства Result (описанного в строке 14) возвращает значение хранящееся в символе (описывающем переменную), имя которого отображается на поле Name (объявленное в строке 42).

После передачи области видимости (строка 40), в которой должно связываться имя, в свойство Name.Scope, автоматически произойдет связывание имени (на которое указывает свойство Name) с символом, и символ будет помещен в зависимое свойство Ref, объявленное в AST Reference (тип, использованный при объявлении свойства Name).

В нашем языке есть только символы одного типа – типа VariableSymbol, но в более сложных языках могут присутствовать и другие символы. При связывании имени может случиться так, что с именем будет связан неподходящий символ. В строке 39 производится разрешение имени (т.е. фильтрация символа по некоторому критерии). Для этого вызывается стандартный метод TryResolve<VariableSymbol>(). Он фильтрует символы по типу своего типа параметра (в данном случае VariableSymbol). Если с символом свяжется более одного символа VariableSymbol или не свяжется ни одного, TryResolve вернет несвязанную ссылку (объект типа Ref<VariableSymbol>.Ambiguous или Ref<VariableSymbol>.Unresolved соответственно). Впоследствии это приведет к выводу сообщения об ошибке. В случае успеха TryResolve вернет ссылку, связанную с одним символом типа VariableSymbol. Символ будет доступен через зависимое свойство Symbol у Ref<VariableSymbol> и будет иметь тип VariableSymbol (строка 41).

В итоге, после вычисления зависимого свойства Result (строка 41) у корневого выражения, будет произведено вычисление (строка 9) свойства Result, объявленного в символе VariableSymbol (строка 7).

СОВЕТ

Обратите внимание на то, что последовательность вычислений задается не их позицией в тексте, как это обычно бывает в императивных программах, а связями вычислений. Это позволяет переложить ответственность за правильную последовательность вычислений на Nitra, а самим сосредоточиться на логике вычислений. Чтобы лицезреть порядок вычислений воочию, можно поставить (в Visual Studio) точки останова на строчках, содержащих присвоения зависимых свойств, и запустить перерасчет.

Отображение (mapping)

Ниже приведено отображение узлов дерева разбора, описываемого грамматикой нашего языка, в AST нашего языка.

Файл: Sample-Mapping.nitra
using Nitra;
using Nitra.Runtime;
using Nitra.Declarations;
using Nitra.Runtime.Binding;

map syntax Sample.TopRule -> Top // 1
{
  VariableDeclarations -> Variables; // 2
}

map syntax Sample.VariableDeclaration -> Variable
{
  Name       -> Name;
  Expression -> Expression;
}

map syntax Sample.Expression -> Expression // 3
{
  | Num -> Number // 4
    {
      Value = ParsedValue(Digits, double.Parse(GetText(Digits))); // 5
    }
  | Braces = Expression.GetAst();
  | Variable -> VariableRef
    {
      Reference -> Name;
    }
  | Sum -> Sum
    {
      Expression1 -> Expression1;
      Expression2 -> Expression2;
    }
  | Sub -> Sub
    {
      Expression1 -> Expression1;
      Expression2 -> Expression2;
    }
  | Mul -> Mul
    {
      Expression1 -> Expression1;
      Expression2 -> Expression2;
    }
  | Div -> Div
    {
      Expression1 -> Expression1;
      Expression2 -> Expression2;
    }
  | Plus -> Plus
    {
      Expression -> Expression;
    }
  | Minus -> Minus
    {
      Expression -> Expression;
    }
}

В строке 1 описывается отображение дерева разбора для правила Sample.TopRule (где Sample – это имя синтаксического модуля, а TopRule – имя правила в нем), на AST типа Top. В строке 2 описывается отображение между свойствам дерева разбора и AST. При отображении полей дерева разбора на структурные свойства AST рантайм Nitra попытается найти описание отображения для соответствующих сочетаний типов дерева разбора и типов AST. Если отображение не находится, выдается сообщение об ошибке. Иначе будет произведено отображение.

При отображении Nitra не только копирует и преобразует данные, но и копирует позиции веток дерева разбора в AST. Это устраняет ручной труд по копированию местоположений элементов AST и исключает ошибки, которые неизбежны при ручном отображении. В дальнейшем по местоположению, заданному в ветке AST, можно будет запросить соответствующее ей дерево разбора. Так что если вам, например, потребуется информация о токенах, ее всегда можно будет получить. Также это гарантирует, что сообщения об ошибках будут указывать в те места программы, где реально произошла ошибка.

В строке 3 приведено отображение дерева разбора, порожденного для расширяемого правила Sample.Expression. Каждое расширение отображается отдельным вхождением внутри фигурных скобок.

Отображение в нашем примере довольно простое и прямолинейное, так что не стоит описывать каждую его строчку. Остановимся лишь на ручном отображении поля Num.Digits (дерева разбора) в структурное свойство Value AST-узла типа Number (строка 5). Поле Digits имеет тип NSpan. Это поле указывает диапазон исходного кода, который был разобран одноименным правилом. Все, что можно с ним сделать – это получить текст, разобранный правилом. В AST же в свойстве Value должно храниться число с плавающей точкой (double), обернутое в ParsedValue<T>. Nitra не знает, как преобразовать диапазон в число. Посему использовать стандартный синтаксис отображения для этого поля нельзя. Чтобы преобразовать диапазон в число, мы воспользуемся возможностью использовать при отображении произвольное Nemerle-выражение. Оператор «=», использованный в строке 5, как раз и указывает Nitra, что данное отображение нужно интерпретировать как ручное. Слева от «=» должно идти структурное свойство AST, а слева – то самое произвольное выражение. В нашем случае:

ParsedValue(Digits, double.Parse(GetText(Digits)))

ParsedValue – это вызов конструктора (в Nemerle конструктор рассматривается как обычная функция и не требует указания ключевого слова «new»). Первым параметром этому конструктору передается диапазон (т.е. значение типа NSpan), соответствующий хранимому значению, а вторым – само значение. Так как типом хранимого значения является double, нужно преобразовать диапазон в double. Для этого сначала, с помощью метода GetText, получается текст, соответствующий диапазону, а потом этот текст передается .NET-методу double.Parse.

IProjectSupport

IProjectSupport – это интерфейс, используемый на сегодня в Nitra для работы с AST всего проекта. Это временное решение, которое в ближайшие месяцы будет заменено на полноценную поддержку проектов в Nitra. Но на сегодня без этого решения нельзя получить поддержку проектов в Nitra.

Движок IDE и тестовая утилита Nitra.Visualizer.exe пытаются запросить у корневого AST вашего языка интерфейс IProjectSupport.

namespace Nitra.Declarations
{
  /// Implement this interface if you need custom calculation of the dependent properties for one or more files.
  /// This interface is requested from a start rule after the Parse Tree to AST mapping is finished.
  public interface IProjectSupport
  {
    RefreshProject(files : Seq[File]) : void;
  }
}

Единственный метод этого интерфейса получает список файлов проекта и может произвести требуемую для проекта обработку.

Реализация этого интерфейса для нашего примера выглядит следующим образом:

public partial class Top : AstBase, IProjectSupport
{
  public RefreshProject(files : Seq[File]) : void
  {
    def files   = files.ToArray();
    def context = DependentPropertyEvalContext(); // 1
    def scope   = TableScope("Variables");        // 2

    foreach (file in files)
      when (file.Ast is Top as top)
        top.ContainingTable = scope; // 3

    AstUtils.EvalProperties(context, files, "Collect variables", 0); // 4
    AstUtils.EvalProperties(context, files, "Compute variables", 1); // 5
  }
}

Top – это класс, генерируемый Nitra по описанию AST Top. Он специально генерируется как partial. Это позволяет определить дополнительную часть, в которой можно (как в нашем случае) реализовать дополнительный интерфейс.

В строке 1 создается объект DependentPropertyEvalContext, описывающий контекст вычисления зависимых свойств. Через этот контекст передаются дополнительные данные (например, информация о стадии компиляции).

В строке 2 создается таблица имен, хранящая имена глобальной области видимости нашего примера. Ссылка на нее помещается (строка 3) в in-свойство корневых AST всех файлов проекта.

Далее в строке 4 производится выполнение нулевой стадии компиляции. Для этого мы обращаемся к методу AstUtils.EvalProperties, описанному в стандартной библиотеке.

В строке 5 происходит выполнение первой стадии компиляции. Для нашего простого языка достаточно двух стадий. На нулевой стадии строится таблица имен, а на первой производится связывание имен и вычисление значений выражений (т.е. зависимого свойства Result).

Если бы мы, например, писали компилятор, то после строки 5 следовало бы произвести генерацию исполняемого кода.

В будущем в Nitra будет реализована более изощренная поддержка проектов, которая:

Результат

Мы описали расчет значений во время компиляции. Это аналогично свертке констант в полнофункциональных языках. Вычисления будут происходить во время компиляции или во время работы IDE. Чтобы посмотреть, как работает наш пример, можно воспользоваться утилитой Nitra.Visualizer.exe, идущей в поставке Nitra. Для этого нужно создать TestSuit со сборкой нашего языка и добавить тест с кодом на нашем языке, например, с:

var x = 2 + y ;
var y = 3 * 4 + 28;

Далее можно встать курсором на ссылку на переменную «y» в первой строке, переключиться на закладку Declarations и посмотреть на результат наших стараний.


Рисунок 1. Описание ссылки на переменную «y».

Как видите, имя «y» разрешилось (resolved) в соответствующую переменную, представленную символом типа VariableSymbol. Значение его зависимого свойства (то, что оно зависимое, видно из зеленого цвета и предлога «in») равно 40.

Если попытаться создать циклическую зависимость между переменными, вычисление не будет произведено. Это особенность вычислительной модели зависимых свойств, которую нужно принимать в расчет. Для выявления циклов можно воспользоваться коллекторами (см. раздел «Коллекторы»).


Рисунок 2. Циклическая ссылка.

Если сослаться в выражении на несуществующее имя, то зависимое out-свойство Ref будет содержать ссылку на объект типа Ref<TSymbol>.Unresolved. При этом такая ссылка будет подсвечиваться красным в IDE, и будет выдаваться сообщение об ошибке:

test-0001(2, 22): Unresolved reference 'z'


Рисунок 3. Ссылка на несуществующее имя «x».

Заключение

Приведенный в данной статье пример не отражает всех нюансов использования подсистемы связывания и областей видимости Nitra. Его цель пояснить базовую логику работы AST в Nitra. В качестве более детального примера рекомендуем смотреть код типизации C#, который доступен здесь.

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

Кроме того, нам предстоит реализовать:

Также предстоит реализовать ряд оптимизаций, которые позволят поддерживать проекты огромных размеров (как это сейчас делают, например, языковые сервисы Visual Studio и ReSharper).

Все это требует времени, но, несмотря на это, Nitra можно использовать уже сейчас, так как и в текущем виде она способна сильно упростить создание языков программирования и DSL с автоматической поддержкой IDE и беспрецедентной расширяемостью.

Если кому-то интересно помочь нам и внести свой вклад в проект Nitra, то вы можете сделать это через pool request на https://github.com/JetBrains/Nitra/.

Ссылки


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