stdray: (Default)
[personal profile] stdray
Пока в сети продолжаются споры от том нужны DSL или не очень, я решил разобраться, как на макросах Nemerle реализуются встраиваемые языки. Потребность такая может возникнуть, даже при наличии макросов, расширяющих синтаксис языка. Дело в том, что менять правила лексера и препарсера нельзя, а значит определенные последовательности символов будут невалидными, например непарные скобки, хотя я подозреваю, что ими ограничения не исчерпываются. Для выхода из ситуации используются рекурсивные строки, внутри которых пишется код на интересующем языке, а потом транслируется в AST Nemerle. Подобным образом реализованы xml-литералы и linq query syntax.

Я был уверен, что создание подобных макросов является очень сильным колдунством. Но на практике все оказалось достаточно просто. Правда есть некоторые нюансы, которым надо уделить внимания. Я расскажу, как сделать игрушечную реализацию BASIC'а.


Для начала надо написать парсер. Формально грамматика не задана, потому я могу достаточно вольно трактовать приведенные правила. Кроме того, мне не хотелось писать под этот парсер свое представление данных и я постарался обойтись AST самого Nemerle. Единственное, я сделал запись для представления строк следующего вида:

[Record] public class Line

public num : int;

public numloc : NLocation;

public expr : PExpr


Результатом работы парсера будет список таких строк. Вот описание грамматики и обработчиков правил, которые приводят к желаемому результату (полный код парсера с using'ами).

[PegGrammar(Options = EmitDebugSources, start, grammar {

digit = ['0'..'9'];

var = ['A'..'Z']+;

s : void = [Zs] / '\t' / '\v' / '\f';

nl : void = '\n' / '\r' / '\u2028' / '\u2029' / "\r\n";;

bind : void = '=';

print : void = "PRINT";

input : void = "INPUT";

let : void = "LET";

goto : void = "GOTO";

ifw : void = "IF";

then : void = "THEN";

rem : void = "REM";

sexpr = (!nl !then [Any])+;

PRINT : PExpr = print s+ sexpr;

INPUT : PExpr = input s+ var;

LET : PExpr = let s+ var s* bind s* sexpr;

GOTO : PExpr = goto s+ digit+;

IFTHEN : PExpr = ifw s+ sexpr then s+ digit+;

REM : PExpr = rem (!nl [Any])+;

statement : PExpr = PRINT / INPUT / LET / GOTO / IFTHEN / REM;

line : Line = s* digit+ s+ statement s* nl+;

lines : List[Line] = (s / nl)* line+ (s / nl)* ![Any];

start : List[Line] = lines;

})] public class BasicParser : LocatedParser

_typer : Typer

public Variables : HashSet[string] = HashSet()

public GotoLines : HashSet[int] = HashSet()

public this(location : NLocation, text : string, typer : Typer)

base(location, text)

_typer = typer

tok2expr(t: nt) : PExpr

MainParser.ParseExpr(_typer.Env, t |> GetText, t |> ToLocation, false)

tok2name(t : nt) : Name

def name = t |> GetText

_ = name |> Variables.Add

Name(name, t |> ToLocation)

tok2int(t : nt) : int

t |> GetText |> int.Parse

PRINT(t : nt) : PExpr

<[Console.WriteLine($(t |> tok2expr))]>

INPUT(t : nt) : PExpr

<[ $(t |> tok2name : name) = int.Parse <| Console.ReadLine() ]>

LET(t : nt, e : nt) : PExpr

<[ $(t |> tok2name : name) = $(e |> tok2expr) ]>

GOTO(t : nt) : PExpr

def num = t |> tok2int

_ = GotoLines.Add(num)

PExpr.Typed(t |> ToLocation, TExpr.Goto(_typer.Manager.InternalType.Void, num, 1))

IFTHEN(c : nt, l : nt) : PExpr

<[ when( $(c |> tok2expr)) $(l |> GOTO) ]>

REM(_ : nt) : PExpr

<[]>

line(l : nt, e : PExpr) : Line

Line(l |> tok2int, l |> ToLocation, e)


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

sexpr = (!nl !then [Any])+;


Для разбора такой строки в AST я использую родной парсер Nemerle, который все это умеет. Потому мне не приходится заново делать уже выполненную работу.

tok2expr(t: nt) : PExpr

MainParser.ParseExpr(_typer.Env, t |> GetText, t |> ToLocation, false)


В целом, я думаю, что все должно быть понятно. Правда, есть один существенный момент. Чтобы при наличие ошибки компилятор мог правильно указать ее местоположение в файле проекта, а так же для корректной работы интеграции вроде хинтов или intellisense, необходимо всегда при формировании выражений аккуратно заполнять поле Location. Это небольшая структура, которая хранит индекс файла в проекте и начало/конец выражения, поэтому во многих методах-обработчиках встречается вызов метода ToLocation. Он нужен для того, чтобы переводить координаты, вычисляемые относительно разбираемой строки в координаты файла в проекте. Этот метод и все для него необходимое я утащил из реализации xml-литераров в отдельный класс LocatedParser для повторного использования. Исходный код этого класса и реализацию методов можно посмотреть здесь.
После того как парсер написан, можно приступить к реализации самого макроса. Он будет вводить ключевое слово BASIC и принимать в качестве аргумента одно выражение.

public macro Basic(expr) syntax("BASIC", expr)

BasicIml.Transform(Macros.ImplicitCTX(), expr)


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

def getStrAndLoc(expr)

| PExpr.Literal(Literal where(RawString = rs)) =>

if (rs.Length == 0 || rs[0] != '<')

Message.FatalError(expr.Location,

"The literal in 'BASIC' macro must be recursive string.")

else

def str = rs.Substring(2, rs.Length - 4)

def loc = expr.Location

def loc = Location(loc.FileIndex, loc.Line, loc.Column + 2,

loc.EndLine, loc.EndColumn - 2)

(str, loc)

| _ =>

Message.FatalError(expr.Location,

"You must pass recursive string with basic code into 'BASIC' macro.")


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

typer.Env.Manager.MacroColors.PushUseSiteColor()

try

def parser = BasicParser(loc, code, typer)

match(parser.Parse <| code)

| Some(r) => mkLines(parser.Variables, r, parser.GotoLines)

| None => mkError <| parser

catch

| _ => <[]>

finally

typer.Env.Manager.MacroColors.PopColor()


Вызов метода typer.Env.Manager.MacroColors.PushUseSiteColor() нужен для того, чтобы можно было из кода на BASIC обращаться к идентификаторам доступным в окружении. Например, к переменным, объявленным в этом же скоупе выше вызова макроса. Вызов typer.Env.Manager.MacroColors.PopColor() не позволяет обращаться к идентификаторам, объявленным внутри макроса.
Сама кодогенерация разделена на два блока - создание переменных для использования в программе на BASIC и генерации тела. Я включаю в результирующий код только те метки (номера строк BASIC превращаются в метки), на которые происходит переход. Также я в функции mkExpr, которая из нескольких выражений составляет одно, отфильтровываю пустые выражения (например, комментарии REM). В этом нет особой необходимости, просто мне не нравится в подсказках интеграции смотреть на подобный мусор.

def mkLines(vars, lines, gotoLines)

def mkExpr(exprs)

def filtered = exprs.Filter(e => !(e is <[ () ]> || e is <[]>))

<[{..$filtered}]>

def mkLabel(n, loc)

if(gotoLines.Contains <| n)

def voidv = typer.Manager.InternalType.Void

PExpr.Typed(loc, TExpr.Label(voidv, n, voidv |> TExpr.DefaultValue))

else <[]>

def pvars = vars.Select(Name).Map(v => <[mutable $(v : name)]>)

def pbody = lines \

.OrderBy(_.num).NToList() \

.FoldRight(<[]>, (l, a) => mkExpr <| [mkLabel(l.num, l.numloc), l.expr, a])

mkExpr <| pvars + [pbody]


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

def mkError(parser)

def (pos, expected) = parser.GetMaxRollbackPosAndNames()

def expected = expected.NToList()

def msg =

if (expected.IsEmpty) "Unexpected character."

else match (expected.DivideLast())

| ([], last) => $"Expected $last."

| (expected, last) => $"Expected ..$expected or $last."

Message.FatalError(parser.ToLocation(pos, pos + 1), msg)


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


Это объемный макрос, но, на мой взгляд, простой. Я больше всего времени потратил, разбираясь с тем, что такое Location в Nemerle и как оно влияет на поведение компилятора и интеграции со студией. Думаю, что если изначально держать это в уме, то вообще никаких сложностей возникнуть не должно, и реализация будет занимать от силы полчаса. При этом я смотрел, как что-то подобное делается на Racket, и мне показалось, что количество совершаемых телодвижений при использование Nemerle существенно меньше (хотя, конечно, примеры не совсем эквивалентны). Теперь я более-менее понимаю, как работают подобные макросы и напишу свой шаблонизатор. По сути, все прозрачно и понятно, только надо помнить про координаты, про возможность использовать немерловый парсер и про прерывание гигиеничности для всей квазицитаты.

December 2019

S M T W T F S
1234567
891011121314
15161718192021
222324252627 28
293031    

Most Popular Tags

Style Credit

Expand Cut Tags

No cut tags