Встраиваемые языки на Nemerle
Mar. 20th, 2013 10:04 pm![[personal profile]](https://www.dreamwidth.org/img/silk/identity/user.png)
Я был уверен, что создание подобных макросов является очень сильным колдунством. Но на практике все оказалось достаточно просто. Правда есть некоторые нюансы, которым надо уделить внимания. Я расскажу, как сделать игрушечную реализацию 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 существенно меньше (хотя, конечно, примеры не совсем эквивалентны). Теперь я более-менее понимаю, как работают подобные макросы