اگه یه روزی به سرت زد که یه زبان برنامهنویسی بنویسی و انقدر هم تو هپروت نبودی که بخوای با regex جمعش کنی، احتمالا اسم ANTLR4 را شنیدی (شاید باور نکنی ولی گزینههات برای این کار نسبتا زیادن). اگه نه، خیلی ساده بخوام توضیح بدم، با ANTLR4 میتونی یه دستور زبان ایجاد کنی، مثل یه زبان برنامهنویسی شبیه پایتون یا C++ یا هر زبان دیگه. از دیدگاه یک دانشجوی زجر کشیده کامپیوتر، اراجیف درس نظریه زبان و ماشین را با ANTLR4 میتونی پیادهسازی کنی و عذاب بکشی.
ماجرا از اینجا جالب میشه که بدونی پیادهسازی زبانهایی مثل C++ و Java و JSON خیلی راحته و در مقابل پیادهسازی زبانی مثل پایتون یا YAML دهن سرویسکنترین کاره! یعنی برای اینکه یه زبانی داشته باشی که نخواد ملت هعی مثل حیوون آکولاد و کروشه و پرانتز بذارند، نویسنده دستور زبان به چوخ میره. من هم چندسالی بود که میخواستم این چالش را انجام بدم و میدونید چی شد؟ انجامش دادم....یهیهیهی....
البته باید تشکر ویژه کنم از نویسندگان این شاخه از ریپو که گرامر کامل پایتون را با ANTLR4 نوشتن و من صرفا قسمتایی از کدشون را که نیاز داشتم استفاده کردم: grammars-v4/python/python3_12_1 at master · antlr/grammars-v4 (github.com)
در این پست توضیح میدم که یه مینی گرامر شبیه پایتون چطور پیادهسازی میشه. منظورم از مینی گرامر اینه که یه گرامر خیلی ریز که صرفا بتونه تشخیص بده بدنه یه تابع کجاست. بدیهتا خواننده عاقل و کمی آشنا با نوشتن دستور زبان متوجه میشه که حلقه و هندل کردن بقیه statement ها مطلقا اینجا اهمیتی نداره و هر کسی با باز کردن یه کتاب مثل ANTL4 Mega Book و دو ساعت خوندنش میتونه اینا را هندل کنه. توی این پست مه دقیقا تمرکز را روی همین میذارم.
خلاصه این که گرامر زیر را قراره پارس کنیم و Abstract Syntax Tree شو بگیریم (بله، اون Enter های اول و آخر فایل هم باید هندل بشن. در بدنه تابع از تب استفاده شده نه space):
pass pass; pass; pass pass; pass; def func: pass
خب، در اولین مرحله باید دستور زبان را تعریف کنیم. برای اینکار یه فایل به اسم Indented.g4 ساختم که از اول قسمت به قسمت میگم چیه (خیلی یه بلوک بزرگ کد را خوب نمیشه توضیح داد وقتی شماره خط نداریم 😢)
grammar Indented; tokens { INDENT, DEDENT }
چند تا توکن ساده دیگه هم تعریف میکنیم که با کاراکترای داخل فایل match میشن.
NAME: [a-zA-Z]+; NEWLINE: '\r'?'\n'; SPACE: [ ] -> skip; WS: [\t]+ -> channel(HIDDEN);
بعد میریم قوانین پارسر را تعریف میکنیم.
file: statements? EOF; statements: statement+; statement: compound_stmt | simple_stmts; simple_stmts: simple_stmt (';' simple_stmt)* ';'? NEWLINE; simple_stmt: 'pass'; compound_stmt: function_def; function_def: function_def_raw; function_def_raw: 'def' NAME ':' block; block : NEWLINE INDENT statements DEDENT | simple_stmts ;
نکته مهم دستور زبان بالا اینجاست که هیچ جا مشخص نکردیم توکنهای INDENT و DEDENT چطور مشخص میشن. پس منطقا نمیتونیم block (بدنه تابع) را در این مرحله تشخیص بدیم. باید این مورد را در کد هندل کنیم.
قبل از نوشتم کد، باید دستور زبان بالا را بیلد کنیم تا پارسر و Lexer خود ANTLR4 را بگیریم، تا بشه روشون کد زد. برای بیلد یه justfile نوشتم ولی چون احتمالا خیلیها نمیدونن چیه درگیرش نمیشم. صرفا این کدها را بزنین تا فایلهای لازم برای Java ایجاد بشن. برای بقیه زبانها هم کافیه یه یگ Dlanguage بعد از antlt4 استفاده کنین. ربطی به کار من نداره. اینم اضافه کنم که چند تا پکیج گرامر مختلف دارم و justfile هم کف پروژه است، پس خودتون با توجه به ساختار پروژتون دستورات را تطابق بدین.
export BASE_PATH="./src/main/java/info/navidlabs" export PROJECT_PACKAGE="info.navidlabs" export GRAMMAR_PACKAGE="P01Indented" export GRAMMAR_NAME="Indented" antlr4 {{BASE_PATH}}/{{GRAMMAR_PACKAGE}}/{{GRAMMAR_NAME}}.g4 -package {{PROJECT_PACKAGE}}.{{GRAMMAR_PACKAGE}}.gen -o {{BASE_PATH}}/{{GRAMMAR_PACKAGE}}/gen
بعد از اجرای دستورات یه پوشه به اسم gen ایجاد میشه که شبیه تصویر زیره. خیلی درگیر نامگذاریم نشین، اگه فایلها را گرفتیم حله، اگه نه زور بزنین تا بگیرینشون. به موقعیت اون justfile هم یه نگاه بندازین بد نیست.
و اما بریم سر کد. قسمت بامزه کار اینه که باید Lexer تولید شده توسط ANTLR را توسعه بدیم تا بتونه INDENT و DEDENT را به دنباله توکنها اضافه کنه. برای اینکار، یه فایل بنام MyLexer.java نوشتم. اینو هم اضافه کنم که برای زبانهای دیگه اگه خواستین کافیه به همون ریپو بالا یه سر بزنین.
با هم تیکه تیکه کدشو بررسی میکنیم. اول توی فایل pom.xml این نیازمندی را اضافه میکنیم که antlr را دانلود کنه:
<dependencies> <dependency> <groupId>org.antlr</groupId> <artifactId>antlr4</artifactId> <version>4.13.1</version> </dependency> </dependencies>
بعد توی فایل MyLexer.java اسم پکیج و ایمپورتها را داریم که توضیح خاصی نداره. فقط بجای antlr.v4 اشتباهی antlr خالی استفاده نکنین.
package info.navidlabs.P01Indented; import info.navidlabs.P01Indented.gen.IndentedLexer; import info.navidlabs.P01Indented.gen.IndentedParser; import org.antlr.v4.runtime.CharStream; import org.antlr.v4.runtime.CommonToken; import org.antlr.v4.runtime.Token; import java.util.ArrayDeque; import java.util.Deque; import java.util.LinkedList;
کلاس، متغیرهایی داخلی و کانستراکتور را اینجا تعریف کردیم. به ترتیب:
در کانستراکتور هم تابع init را صدا زدیم.
public class MyLexer extends IndentedLexer { private Deque<Integer> indentLengthStack; private LinkedList<Token> pendingTokens; private int previousPendingTokenType; private int lastPendingTokenTypeFromDefaultChannel; private CommonToken curToken; private Token ffgToken; protected MyLexer(CharStream input) { super(input); this.init(); }
تابع init صرفا متغیرهایی را که بالا تعریف کردیم را مقداردهی اولیه میکنه (با صفر و Null و چیزای دیگه)
private void init() { this.indentLengthStack = new ArrayDeque<>(); this.pendingTokens = new LinkedList<>(); this.previousPendingTokenType = 0; this.lastPendingTokenTypeFromDefaultChannel = 0; this.curToken = null; this.ffgToken = null; }
در گام بعد تابع nextToken کلاس IndentedLexer را بازنویسی میکنیم. کلا ANTLR هر توکنی که دید این کلاسو صدا میزنه. ما با بازنویسی ایم کلاس کاری میکنیم که Lexer طبق قوانین ما توکنها را پردازش کنه. در اینجا میگیم هر توکنی اومد اول متود checkNextToken را صدا بزن، بعد هم از لینک لیست توکنهای در انتظر بررسی (pendingTokens) اولین عضو را برگردون.
توی پرانتز: منطق کاری Lexer اینه که یه رشته از توکنها برمیگردونه و پارسر AST را میسازه.
@Override public Token nextToken() { this.checkNextToken(); return this.pendingTokens.pollFirst(); }
حالا در checkNextToken بررسی میکنیم چی اومده. اگه به ته فایل (EOF) نرسیدیم، تابع setCurrentAndFollowingTokens را صدا میزنیم تا توکن فعلی (curToken) و بعدی (ffgToken) را آپدیت کنه. اگه داخل بدنه یه بلوک هم نبودیم (با indentLengthStack اینو میشه چک کرد)، یه تابع بنام handleStartOfInput را صدا میزنیم که اون اراجیف اول فایل (فاصله و NEWLINEهای الکی) که در بلوک کد اول پست دیدیم را هندل کنه.
بعد از این کارا نوع توکنی که الان داره بررسی میشه را چک میکنیم.
دقت کنین که تب را ریختیم توی کانال HIDDEN و الان داریم کانال DEFAULT را بررسی میکنیم. پس در case ها INDENT و DEDENT نداریم.
private void checkNextToken() { if (this.previousPendingTokenType != Token.EOF) { this.setCurrentAndFollowingTokens(); if (this.indentLengthStack.isEmpty()) { this.handleStartOfInput(); } switch (this.curToken.getType()) { case IndentedParser.NEWLINE: this.handleNEWLINEtoken(); break; case Token.EOF: this.handleEOFtoken(); break; default: this.addPendingToken(this.curToken); } } }
در متود setCurrentAndFollowingTokens چک میکنیم که آیا توکن بعدی (ffgToken) وجود داره یا نه. اگه وجود داشت توکن فعلی (curToken) را میذاریم ffgToken، مگرنه با صدا زدن super.nextToken مقدار دهیش میکنیم. این تابع super.nextToken اگه ته فایل باشیم EOF برمیگردونه!
به صورت مشابه، چک میکنیی به تع فایل رسیدیم یا نه و اگه رسیدیم، ffgToken هم EOF میذاریم، مگرنه توکن بعدی را با super.nextToken میگریم میریزیم توش (یه واحد جلوتر از curToken میشه چون هربار super.nextToken را صدا بزنی خودکار یکی میره جلو).
private void setCurrentAndFollowingTokens() { this.curToken = this.ffgToken == null ? new CommonToken(super.nextToken()) : new CommonToken(this.ffgToken); this.ffgToken = this.curToken.getType() == Token.EOF ? this.curToken : super.nextToken(); }
متود بعدیمون، handleStartOfInput هست که هدفش خلاص شدن از تبها و خطهای اضافی اول فایله. برای اینکار، اول یه صفر میندازه سر indentLengthStack که یعنی در بلوکی نیستیم (بدیهتا هیچ فایل پایتونی با تب و دستور شروع نمیشه طبق دستور زبان).
بعد توی یه حلقه، چک میکنیم که آخر فایل هم نباشیم (فایل خالی مثل __init__ هم باید هندل بشه). اینجا چک میشه آیا داخل کانال DEFAULT هستیم یا کانال HIDDEN. یادآوری میکنم که کانال HIDDEN برامون تب را هندل میکرد.
اگه داخل کانال DEFAULT بودیم، چک میکنیم که توکنی فعلی NEWLINE هست یا نه. اگه آره، hideAndAddPendingToken را صدا میزنیم، مگر نه بیخیال میشیم و برمیگردیم. اگه هم کانال HIDDEN بودیم، addPendingToken را صدا میزنیم. بعد setCurrentAndFollowingTokens را اجرا میکنیم که توکنهای curToken و ffgToken آپدیت بشن و تا ته فایل را پیمایش کنیم.
private void handleStartOfInput() { this.indentLengthStack.push(0); while (this.curToken.getType() != Token.EOF) { if (this.curToken.getChannel() == Token.DEFAULT_CHANNEL) { if (this.curToken.getType() == IndentedParser.NEWLINE) { this.hideAndAddPendingToken(this.curToken); } else { return; } } else { this.addPendingToken(this.curToken); } this.setCurrentAndFollowingTokens(); } }
تا اینجا فراخوانی متودها این شکلیه (خودمم نمیدونم چه نموداری کشیدم، ولی مفهومو میرسونه...احتمالا):
بخش خیلی مهم کار اینه که باید یجوری INDENT و DEDENT را به استریم توکنها اضافه کنیم تا شروع و پایان هر block مشخص بشه. این کار را در handleNEWLINEtoken انجام میدیم. اول توکن فعلی که NEWLINE هست را در nlToken ذخیره میکنیم. بعد چک میکنیم که توکن بعدی تب هست یا نه (در گرامر گفتیم WS با تب Match بشه)، اگه بود، isLookingAhead را True قرار میدیم، مگرنه False.
حالا اگه isLookingAhead درست بود، setCurrentAndFollowingTokens را صدا میزنیم تا یه واحد توکنهای curToken و ffgToken برن جلو. اگه ffgToken توکن NEWLINE بود که یعنی یه خط در بدنه block همینطوری هست و هیچی توش تایپ نشده، پس نقش خاصی هم در AST نداره و میدیم hideAndAddPendingToken تا هندلش کنه. اگه داخل بدنه یه block بودیم (با isLookingAhead چک میکنیم)، توکن curToken را به addPendingToken میدیم تا بذاره توی stream خروجی Lexer.
اگه ffgToken توکن NEWLINE نبود، توکن nlToken که اول ذخیره کردیمش (کاراکتر NEWLINE هم هست) را به addPendingToken میدیم تا بره تو استریم خروجی، اگه isLookingAhead درست نبود، insertIndentOrDedentToken را با پارامتر صفر صدا میزنیم تا بگیم توی بلوک نیستیم.
اگه هم isLookingAhead درست بود (داخل یه بلوک جدید رفتیم/توکن ffgToken تب بود)، getIndentationLength را صدا میزنیم تا تعداد تب را از اول خط را بهمون بده. بعد مقداری که گرفتیم را به insertIndentOrDedentToken میدیم تا کاراکتر INDENT یا DEDENT را با توجه به اینکه داریم وارد بلوک میشیم یا ازش بیرون میایم اضافه کنه.
private void handleNEWLINEtoken() { CommonToken nlToken = new CommonToken(this.curToken); final boolean isLookingAhead = this.ffgToken.getType() == IndentedParser.WS; if (isLookingAhead) { this.setCurrentAndFollowingTokens(); } switch (this.ffgToken.getType()) { case IndentedParser.NEWLINE: this.hideAndAddPendingToken(nlToken); if (isLookingAhead) { this.addPendingToken(this.curToken); } break; default: this.addPendingToken(nlToken); if (isLookingAhead) { final int indentationLength = this.ffgToken.getType() == Token.EOF ? 0 : this.getIndentationLength(this.curToken.getText()); this.addPendingToken(this.curToken); this.insertIndentOrDedentToken(indentationLength); } else { this.insertIndentOrDedentToken(0); } } }
خب، میرسیم سر وقت insertIndentOrDedentToken. این تابع از indentLengthStack استفاده میکنه تا ببینه چند Level بلوک تو در تو داریم و الان کجاییم. اول یه دید میزنه سر indentLengthStack که ببینه چند لول رفتیم داخل (چیزی از indentLengthStack برداشته نمیشه چون peek را صدا زدیم). اگه دید یه لول اضافه شده، یعنی یه block داره تشکیل میشه و با صدا زدن createAndAddPendingToken یه توکن INDENT به استریم خروجی Lexer اضافه میشه. indentLengthStack هم آپدیت میکنیم.
اگه هم نه، دیدیم indentLengthStack کم شده، میریم داخل یه لوپ تا ببینیم چند تا بلوک دارن بسته میشن. به اضای بسته شدن هر بیوک یه بار createAndAddPendingToken را صدا میزنیم تا یه DEDENT اضافه بشه.
private void insertIndentOrDedentToken(final int indentLength) { int prevIndentLength = this.indentLengthStack.peek(); if (indentLength > prevIndentLength) { this.createAndAddPendingToken(IndentedParser.INDENT, Token.DEFAULT_CHANNEL, null, this.ffgToken); this.indentLengthStack.push(indentLength); } else { while (indentLength < prevIndentLength) { this.indentLengthStack.pop(); prevIndentLength = this.indentLengthStack.peek(); this.createAndAddPendingToken(IndentedParser.DEDENT, Token.DEFAULT_CHANNEL, null, this.ffgToken); } } }
تابع بعدی insertTrailingTokens هست. نقشش اینه که اگه آخر فایلمون NEWLINE نبود، اضافه کنه. چرا؟ چون گفتیم بعد هر simple_stmt باید NEWLINE باشه و بعد از compound_stmt باید DEDENT باشه. ولی ممکنه کاربر عشقش نکشه اون Enter آخر را بزنه که در نتیجه موارد بالا اضافه نمیشن.
private void insertTrailingTokens() { switch (this.lastPendingTokenTypeFromDefaultChannel) { case IndentedParser.NEWLINE: case IndentedParser.DEDENT: break; default: this.createAndAddPendingToken(IndentedParser.NEWLINE, Token.DEFAULT_CHANNEL, null, this.ffgToken); } this.insertIndentOrDedentToken(0); }
تابع بعدی هم صرفا اگه دید رسیدیم ته فایل (EOF دید)، تابع بالا را صدا میزنه.
private void handleEOFtoken() { if (this.lastPendingTokenTypeFromDefaultChannel > 0) { this.insertTrailingTokens(); } this.addPendingToken(this.curToken); }
تابع بعدیمون هم وقتی صدا زده بشه، توکن دریافتی را میریزه توی کانال HIDDEN (اول کانال را عوض میکنه و بعد addPendingToken را صدا میزنه). دو جایی که بالا این تابع را صدا زدیم، وقتی بود که یه NEWLINE میدیدم (متودهای handleStartOfInput و handleNEWLINEtoken را ببین).
private void hideAndAddPendingToken(CommonToken cToken) { cToken.setChannel(Token.HIDDEN_CHANNEL); this.addPendingToken(cToken); }
تابع بعد، که تا حالا چندبار هم ازش استفاده کردیم، createAndAddPendingToken هست. کلا کار این تابع اینه که یه توکن را بریزه توی یه کانال دلخواه، یه اسم هم در پارامتر text میگیره که کمک میکنه موقع parse یه اسم با معنی برای توکنها داشته باشیم. بعد هم توکن را میدیم به addPendingToken. از baseToken هم (که مقدارش ffgToken هست) استفاده میکنیم که مشخص کنیم توکن فعلی کجا تموم میشه (ایندکسش در فایل ورودی از کجا تا کجاست).
private void createAndAddPendingToken(final int type, final int channel, final String text, Token baseToken) { CommonToken cToken = new CommonToken(baseToken); cToken.setType(type); cToken.setChannel(channel); cToken.setStopIndex(baseToken.getStartIndex() - 1); cToken.setText(text == null ? "<" + this.getVocabulary().getSymbolicName(type) + ">" : text); this.addPendingToken(cToken); }
تابع بعدی صرفا توکن را میگیره، چک میکنه روی کانال DEFAULT هست یا نه. اگه بود lastPendingTokenTypeFromDefaultChannel را آپدیت میکنه. همینطور، بدون توجه با کانال توکن، previousPendingTokenType را آپدیت میکنه و میذارتش ته pendingTokens.
private void addPendingToken(final Token token) { this.previousPendingTokenType = token.getType(); if (token.getChannel() == Token.DEFAULT_CHANNEL) { this.lastPendingTokenTypeFromDefaultChannel = this.previousPendingTokenType; } this.pendingTokens.addLast(token); }
تابع بعدی کمکمون میکنه تعداد تب از اول خطی که توکن داخل آرگومان گرفته را مشخص کنیم. اینکه اون TAB_LENGTH نقشی داره یا نه، بستگی به ادیتورتون داره 🤔. اگه تنظیم شده که بجای تب، space بزنه، این کد اوکیه. اگه هم tab میذاره بمونه، بازم اوکیه. ولی اگه تعداد دیگهای space زد باید این تنظیم بشه. بازم ربطی به کار من نداره، راحت هم میشه تست کرد اوضاع چطوره.
private int getIndentationLength(final String textWS) { final int TAB_LENGTH = 4; int length = 0; for (char ch : textWS.toCharArray()) { length += TAB_LENGTH - (length % TAB_LENGTH); } return length; }
در پایان هم متود reset را override میکنیم که کار که تموم شد، همه متغیرهایی که برای state برنامه ازشون استفاده کردیم به مقدار اولیه برگردن.
@Override public void reset() { this.init(); super.reset(); }
کد بالا را با تکه کد زیر اجرا کردم
inputFile = classloader.getResource("test-files/p01-t01.txt").getPath(); info.navidlabs.P01Indented.TestGrammar gp01t01 = new info.navidlabs.P01Indented.TestGrammar(); try{ gp01t01.test01(inputFile); } catch (Exception e) { return; } break;
نتیجه شد این، بدون هیچ اروری 😁. میبینید که به زیبایی هر چه تمام تر بدنه تابع شناسایی شده. البته این رو باید اضافه کنم که در این پست خیلی درگیر کنترل خطا هم نشدم. چند تا متود هم برای نمایش پیام خطا و اضافه کردن توکن مناسب برای نمایش اون در گرامر و کد ریپو اصلی هست. خواستین یه نگاه بندازین.
(file (statements (statement (simple_stmts (simple_stmt pass) \r\n)) (statement (simple_stmts (simple_stmt pass) ; \r\n)) (statement (simple_stmts (simple_stmt pass) ; (simple_stmt pass) \r\n)) (statement (simple_stmts (simple_stmt pass) ; (simple_stmt pass) ; \r\n)) (statement (compound_stmt (function_def (function_def_raw def func : (block \r\n <null> (statements (statement (simple_stmts (simple_stmt pass) \r\n))) <null>)))))) <EOF>)
اولا تبریک میگم اگه نشستی این پست را خوندی! میدونم خیلی فرد شاخص و انسان خفنی هستم 😆 (سه روز وقتم سر راه انداختن این کد و خوندن ANTLR4 Mega Book رفت...)
انصافا این کارای دستور زبانی مصیبته. تازه تا اینجا صرفا Abstract Syntax Tree را ساختیم. در فاز بعد باید پارسر را بنویسیم (بهتره بگم Listener ها را پیادهسازی کنیم) تا بشه با AST یه کاری کرد. مثلا فایل JSON ساخت یا این جور کارا. در مجموع قسمت سخت کار همین Lexer بود، بقیش مثل هر زبان دیگست...
دقیقا توی پروژم از این جنایت علیه بشریت قراره استفاده کنم تا یه زبان برای back testing بورس بزنم. فالوم کن تا قبل از بقیه بتونی پروژه را تست کنی!