自作言語を作ってみたい1

エスペラント語ベースに、様々なプログラミング言語の文法をミックスした言語を作ります。

動機

自分で言語を作るってかっこいいですよね。
 なので、作ります。
 これがリポジトリです。
 (READMEとかは全然整備してません……)

特徴

「ぼくのかんがえたさいきょうのぷろぐらみんぐげんご」を作るにあたって、ある程度特徴を決めないといけません。
 なので、私が欲しいなぁと思う特徴を適当に列挙し以下に並べてみます。

  • エスペラント語ベース
    • 最近エスペラント語を勉強しているので
    • if文 -> se文、for文 -> por文、のようにごく簡単に置き換える
  • 手続き型言語
  • JIS配列で入力するのが楽な言語
  • C++, Python, JavaScriptを合体させたような文法
  • 文はセミコロン区切り  とまあ、こんな具合で。
     実装途中に思いついたとかのもどんどん入れていきます。

構文解析(parser)

とりあえず構文解析は、『自作プログラミング言語を初級者が1週間で作る方法』を参考に、Node.jsのPeggyを書いていきます。
 簡単に言うと、構文が正しく書けているかは、いったんJavaScriptに変換&実行して確かめよう!といったかんじです。
 当然このインタプリタだけでも十分に動きますが、それだけだと味気ないので、もっと発展させたいところではあります。(LLVM使ってコンパイラにするとか)

下準備

用意した文を、パーサーに与える処理を書いておきます。
test.js

const fs = require("fs");
const parser = require("./parser");
const code = fs.readFileSync("./input.espr", "utf-8");
const ast = parser.parse(code);
console.dir(ast, {depth: null});

package.json

{
  "scripts": {
    "test": "npx peggy grammar.pegjs -o parser.js && node test.js"
  },
}

./parserは、peggyjsを変換したものです。
 テストしたいプログラムファイルを./input.esprとしておきます。
 読み込んだプログラムをパーサーに与え、返り値を受け取り表示するといった処理です。
 peggyjsを変換するには、一度コマンドを叩く必要があります。
 なのでscripts内に変換コマンドとテストの実行コマンドを書いておきましょう。
 これで下のコマンドを叩くだけでテストまで実行できます。

npm run test

実装

peggyjsの文法については基本的に言及しません。
 BNFの理解さえあればある程度直感的に分かると思います。
 _(1つのアンダーバー)は0文字以上の空白文字を、
 __(2つのアンダーバー)は1文字以上の空白文字を表します。
 この2つは頻出ですが最下部で定義しており名前でも分かりにくいため、先に書いておきました。

Start

まずはエントリポイントのようなものとなる、Startを作っておきます。

Start
  = _ p:Program _ {
    const prettier = require("prettier");
    prettier.format(p, {parser:"babel"}).then((code) => {
      console.log(code);
    });
    return eval(p);
  }

Programは、まあその名の通りプログラムを表します。
 BNFの慣習的なものとして、上位の構文から順に書いていきます。
 そのため、まだ実装していない下位の構文が出てきます。
 おおよそどんなものか分かるように、しっかりと分かりやすい命名をしなければなりません。
 本質的な箇所は、return eval(p);だけです。
 ここで、解析し変換したJavaScriptコードの文字列を評価し、返り値にします。
 デバッグ用に、prettierでJavaScriptコードを整形し、出力させておきます。
 prettier.format()Promiseが返ってくるらしいので、then内で出力処理を書きました。

Program

プログラム、つまり「文」の集合です。

Program
  = statements:(Statement _ ";" _)* e:Expression? _ {
    const code = statements.reduce((acc, x) => `${acc} ${x[0]};\n`, "");
    const returnCode = e ? `return (${e});` : "";
    return `(() => {\n${code}${returnCode}\n})()`;
  }

reduce()を使って、文をセミコロン改行区切りにします。
 最後に「式」があれば、これを返り値にします。

Statement

Statement = Block / IfThenElseStatement / ForStatement / WhileStatement / DoWhileStatement / VariableDeclaration / Expression
Block
  = "{" _ stmts:(Statement _ ";" _)* _ "}" {
    return `{\n${stmts.map(s => s[0]).join(";\n")};\n}`;
  }
IfThenElseStatement
  = "se" _ "("_ e:Expression _")" _ "tiam" _ trueBody:Statement _ "alie" _ falseBody:Statement {
    return `if(${e})${trueBody}else${falseBody}`;
  }
ForStatement
  = "por" _ "(" _ init:(Expression / VariableDeclaration)? _ ";" _ cond:Expression? _ ";" _ update:Expression? _ ")" _ body:Statement {
    return `for (${init ?? ""}; ${cond ?? ""}; ${update ?? ""}) ${body}`;
  }
WhileStatement
  = "dum" _ "(" _ cond:Expression _ ")" _ body:Statement {
    return `while (${cond}) ${body}`;
  }
DoWhileStatement
  = "fari" _ body:Statement _ "dum" _ "(" _ cond:Expression _ ")" {
    return `do ${body} while(${cond})`;
  }
VariableDeclaration
  = "var" __ name:Identifier _ "=" _ value:Expression {
    return `let ${name} = ${value}`;
  }

コードブロック, if(se)文, for(por)文, while(dum)文, do-while(fari-dum)文, 変数宣言(初期化)を実装しました。
 コードブロックは波括弧{}でまとめることにします。まあよくあるやつですね。
 ただこれもProgramに入るので、波括弧の終わりにはセミコロンを付ける必要があります。
 修正したいですね。C++でもclass宣言の終わりのセミコロンはよく忘れるので。
 if(se)文は、正確にはif-then-else(se-tiam-alie)文ですね。
 条件->真のとき->偽のときの順で記述します。
 for(por)文はC++準拠で、初期化処理->条件->ループ時処理の順でセミコロン区切りにします。
 最初の初期化処理のところだけは、変数宣言と変数代入の両方を許可したいため、文と式の両方を入れています。
 while(dum)文、do-while(fari-dum)文については特筆することはないでしょう。
 最期に変数宣言(初期化)ですが、型の指定は行わず、varにしておきます。
 エスペラント語でも変数は「variablo」といい、最初の3文字を取った結果昔のJavaScriptみたいになってしまいました。

Expression

Expression = LambdaExpression / AssignmentExpression / OrExpression

LambdaExpression
  = i:Identifier _ "@" _ e:Expression {
    return `${i} => ${e}`;
  }

AssignmentExpression
  = name:Identifier _ "=" _ value:Expression {
    return `${name} = ${value}`;
  }

OrExpression
  = head:AndExpression tail:(_ OrOperator _ AndExpression)* {
    return tail.reduce((acc, x) => `(${acc}) || (${x[3]})`, head);
  }
AndExpression
  = head:EqualExpression tail:(_ AndOperator _ EqualExpression)* {
    return tail.reduce((acc, x) => `(${acc}) && (${x[3]})`, head);
  }
EqualExpression
  = head:RelatExpression tail:(_ EqualOperator _ RelatExpression)? {
    return tail === null ? head : `(${head}) ${tail[1]} (${tail[3]})`;
  }
RelatExpression
  = head:AddExpression tail:(_ RelatOperator _ AddExpression)? {
    return tail === null ? head : `(${head}) ${tail[1]} (${tail[3]})`;
  }
AddExpression
  = head:MultiExpression tail:(_ AddOperator _ MultiExpression)* {
    return tail.reduce((acc, x) => `(${acc}) ${x[1]} (${x[3]})`, head);
  }
MultiExpression
  = head:CallExpression tail:(_ MultiOperator _ CallExpression)* {
    return tail.reduce((acc, x) => `(${acc}) ${x[1]} (${x[3]})`, head);
  }
CallExpression
  = callee:Term tail:(_ Argument)* {
    return tail.reduce((acc, x) => `${acc}${x[1]}`, callee);
  }
Argument
  = "(" _ e:Expression _ ")" {
    return `(${e})`;
  }

OrOperator = "aux" / "||"
AndOperator = "kaj" / "&&"
EqualOperator = "==" / "!="
RelatOperator = ">=" / ">" / "<=" / "<"
AddOperator = "+" / "-"
MultiOperator = "*" / "/" / "%"

式は、ラムダ式、代入式、数式の現状3つです。
 ラムダ式は、「<変数> @ <式>」としています。
 1変数のみのアロー関数のようなイメージです。
 引数を2個以上入れる場合には、「<変数> @ <変数> @ <式>」としてやればいいです。  次に代入式ですが、まあそのままですね。
 VariableDeclarationについていたvarを取っ払ったかんじです。
 そして数式です。演算優先度としては、
 関数呼び出し > 乗除算 > 加減算 > 不等号 > 等号 > 論理積 > 論理和
 となってます。
 また、RelatExpressionEqualExpressionは、演算子を0,1回のみしか繋げられなくしてあります。
 比較演算子を複数繋げると予期せぬ動作が発生しうるためです。

Term

ハイライトが崩れています。おそらくエスケープ文字処理部分のせいでしょう。

Term
  = Paren / String / Number / Identifier / Boolean / Undefined / Null / IfThenElseTerm

IfThenElseTerm
  = "se" __ a:Expression __ "tiam" __ b:Expression __ "alie" __  c:Expression {
    return `${a} ? ${b} : ${c}`;
  }
Paren
  = "(" _ e:Expression _ ")" {
    return `(${e})`;
  }
String
  = "\"" chars:Char* "\"" {
    return `"${chars.join("")}"`;
  }
Char
  = EscapedChar / NormalChar
EscapedChar
  = "\\" c:. {
    return "\\" + c;
  }
NormalChar
  = !["\\] . {
    return text();
  }
Number = Float / Integer
Float
  = Integer "." [0-9]+ {
    return text();
  }
Integer
  = [1-9] [0-9]* {
    return text();
  } / "0"
Boolean
  = bool:("vero" / "malvero") !IdentifierContinue {
    return text()==="vero" ? "true" : "false";
  }
Undefined
  = "nedifinito" !IdentifierContinue{
    return "undefined";
  }
Null
  = "nulo" !IdentifierContinue{
    return "null";
  }

Identifier
  = !ReservedWord head:IdentifierStart tail:IdentifierContinue* {
    return "$" + text();
  }
ReservedWord
  = ("var" / "nedifinito" / "nulo" / "vero" / "malvero" / "kaj" / "aux" / "se" / "tiam" / "alie" / "por" / "dum" / "fari") !IdentifierContinue
IdentifierStart = [A-Za-z_]文字
IdentifierContinue = [0-9A-Za-z_]
__ = [ \t\n\r]+
_  = [ \t\n\r]*

丸括弧、文字列、数値、変数、真偽値、undefined(nedifinito)、null(nulo)、三項演算子
 を定義しました。
 三項演算子は、if-then-else(se-tiam-alie)で記述します。
 CやJavaScriptのものと異なり、Pythonのようにスペースを空けなければならないことに注意です。
 文字列は"(ダブルクォーテーション)で括ることにします。
 '(シングルクォーテーション)では括らせません。(思想)
 エスケープ処理はバックスラッシュで行わせます。
 数値は、整数と小数点の両方を指します。
 真偽値は、veromalveroの2つです。
 エスペラント語では、単語の頭にmalが付くと反対の意味になるのでわかりやすいですね。
 また、undefined(undifinito)とnull(nulo)も追加しておきました。
 nullはともかくとして、undefinedの登場によりかなりJavaScript味が増しました。
 最後にある変数名の定義ですが、自作言語の予約語に完全一致するものは弾きておきます。
 また、内部的にはJavaScriptなので、変数名にも同等の制約がつきます。
 JavaScriptの予約語も使えるように、内部的に変数名の先頭に$を付けて被らないようにしています。

テスト

ひとまず今回はここまでにして、実装した文法をおおよそ網羅できるようなテストコードを組んでみました。
input.espr

var x = 10;
var y=20;
var adicias4 = a @ b @ c @ d @ a+b+c+d;
var adicias3 = adicias4(0);
var adicias2 = adicias3(0);
var s = 0;
var i = 0;
por(i = 0; i < 10; i = i + 1){
  s = adicias2(s)(i);
};
dum(i){
  se(i%2 == 0)tiam{
    s = adicias2(s)(i);
  }alie{
    s = s - i;
  };
  i = i - 1;
};
i = 15;
fari{
  s = adicias2(s)(i);
  i = i - se (s%2 == 0) tiam 1 alie 2;
}dum(i);
s = s + (se vero tiam se vero tiam 3 alie 4 alie 2 * 10) * 10;
s = s + se vero && malvero tiam 1 alie 10;
s = s + se vero || malvero tiam 1 alie 10;
"Kalkulis: " + s

このコードを追いかけてみると、出力はKalkulis: 179となります。
 (追いかける過程は流石に省略させてください。)
 これでテストを行ってみると、

$ npm run test
> programlingvo@1.0.0 test
> npx peggy grammar.pegjs -o parser.js && node test.js
'Kalkulis: 179'
(() => {
  let $x = 10;
  let $y = 20;
  let $adicias4 = ($a) => ($b) => ($c) => ($d) => $a + $b + $c + $d;
  let $adicias3 = $adicias4(0);
  let $adicias2 = $adicias3(0);
  let $s = 0;
  let $i = 0;
  for ($i = 0; $i < 10; $i = $i + 1) {
    $s = $adicias2($s)($i);
  }
  while ($i) {
    if ($i % 2 == 0) {
      $s = $adicias2($s)($i);
    } else {
      $s = $s - $i;
    }
    $i = $i - 1;
  }
  $i = 15;
  do {
    $s = $adicias2($s)($i);
    $i = $i - ($s % 2 == 0 ? 1 : 2);
  } while ($i);
  $s = $s + (true ? (true ? 3 : 4) : 2 * 10) * 10;
  $s = $s + (true && false ? 1 : 10);
  $s = $s + (true || false ? 1 : 10);
  return "Kalkulis: " + $s;
})();

うまくいってそうですね!
 最初に最終出力のKalkulis: 179が表示され、
 そのあとに、prettierで整形されたコードが出てきました。

まとめ

今回実装したもの

  • 整数/小数/文字列/真偽値/undefned/null
  • 変数の初期化宣言
  • 変数代入
  • if-then-else(se-tiam-alie)文
  • for(por)文
  • (dum), do-while(fari-dum)文
  • 三項演算子(se ~ tiam ~ alie ~)
  • ラムダ式(@)
  • カリー化
  • 関数呼び出し > 乗除算 > 加減算 > 不等号 > 等号 > 論理積 > 論理和

今回のまとめとしてはこんなところでしょうか。
200行弱でプログラミングの基礎である、順次反復分岐を実装できました。

今後実装したいもの

  • bit演算
  • ラムダ関数のコードブロック
  • return文
  • break, continue文
  • 配列、オブジェクト
  • インクリメント、デクリメント
  • 標準入出力
  • 演算代入演算子
  • セミコロン必要箇所の調整

こんなところでしょうか。
 特に優先度は決めていませんが、プログラミングの幅が広がるものを早めに実装したいですね。
 この中だと配列、オブジェクト、標準入出力あたりですね。
 まあ急がず焦らず気が向いたときにぼちぼち作っていきますよ~。