Lexer Generator Reference
Lexi is the part of Lexigram that generates a lexer.
The lexer, or scanner, is the very front-end of a parser. It reads the input text, recognizes symbols, and outputs a stream of tokens corresponding to those symbols. The lexer isn’t aware of the syntax of the input language, only of its lexical rules. The stream of tokens is generally read by a parser, but it can also be used directly in a user application.
The lexical rules are defined in a lexicon, in a human-readable language described in this reference. The code of the lexer proper already exists in the lexigram-core crate; its main object is Lexer. Lexi takes a lexicon and generates the data required by Lexer to scan the language.
The language used to describe a lexicon is very similar to that of the ANTLR tool, though there are a few differences.
Notation
We’re using the following conventions in the lexicon:
- Terminals (or tokens, which means the same in this context) are written in upper camel case:
Assign,Id,StringLiteral. - Fragments are written in upper snake case:
DIGIT,HEX_DIGIT. - Modes are written in upper snake case and end with
_MODE:DEFAULT_MODE,TAG_MODE. - Channels are written in upper snake case and end with
_CHANNEL:COMMENTS_CHANNEL.
Those conventions are not enforced by Lexi.
Main Concepts
The lexicon defines the tokens emitted by the lexer, using simple regular expressions:
Assign : '=';
Add : '+';
If : 'if';
While : 'while';
Id : [_a-z][_a-z0-9]+;
StringLiteral : '"' (~('"'|'\n'|'\r') | '""')* '"';
From the lexicon, Lexi generates the source code of the lexer:
- The symbol and the finite state machine tables, other supporting data.
- A
build_lexer()method that creates aLexerfrom those data.
Lexicon Structure
A lexicon can be made up of several elements.
It begins with an optional header:
lexicon LexiLexer;
After the optional header, the lexicon is a list of items, each of which is an option, a rule, or a declaration.
The only option currently implemented is a list of additional channels:
channels { COMMENTS_CHANNEL, ATTRIBUTE_CHANNEL }
A rule is the definition of a token, which is what the lexer outputs, or a fragment, which is akin to an alias or a constant:
fragment DIGIT : [0-9];
Number : DIGIT+;
The only declaration currently implemented is the mode section, in which rules are active only when that mode is the current one. The starting mode, DEFAULT_MODE, is implicitly declared at the top, and thus the default mode of the lexer.
mode COMMENT_MODE;
Matching rule
Among the rules of the current mode, the one that matches the longest input is selected to issue a token. If you need the keywords "down", "downto", and "to" in your language, you can safely define the three tokens in whichever order you prefer. The resulting lexer will be the same, and it won’t cut the input "downto" in two tokens "down" and "to".
Down: 'down';
DownTo: 'downto';
To: 'to';
When there is a conflict between two rules, the first token defined in the lexicon takes precedence. This typically occurs with variable tokens:
Down: 'down';
DownTo: 'downto';
To: 'to';
Id: [_a-z][_a-z0-9]*;
"to"is scanned asTo. There is a conflict betweenToandId("to"), soTois selected because it’s defined first."total"is scanned asId("total"). There is no conflict after reading the first two characters"to"because*and+are greedy by default, soIdcaptures the whole word"total". The lexer chooses the token matching the longer input, which isId("total").
Were Id defined first, none of the other tokens would ever be selected. However, Lexigram would see that some tokens are never output, so it would produce an error before generating the lexer, suggesting to re-order the definitions.
With a non-greedy repetition, we would get a different result for "total": Once the first two characters "to" are read by the lexer, To and Id("to") are in conflict this time:
To: 'to';
Id: [_a-z][_a-z0-9]*?;
"to"is scanned asTo. Like above, there is a conflict betweenToandId, which is resolved by taking the first definition."total"is scanned asTo,Id("tal"). Once the first two characters"to"are read, there is a conflict because of the non-greedy*?.Tois selected because it’s defined first, which leaves the rest of the input"tal". That remaining input is scanned asId("tal")
In conclusion, the usual way to design a lexicon is to put the fixed tokens first and the variable tokens last, unless there is no risk of conflict.
Fragments
The fragment keyword defines a fragment of lexemes that may be used several times, like an alias or a constant. It can also be used to split a rule into more explicit components.
fragment DIGIT : [0-9];
fragment HEX_DIGIT : ( DIGIT | [A-Fa-f] );
fragment DEC_INTEGER : DIGIT ( '_' | DIGIT )* DIGIT | DIGIT;
fragment HEX_INTEGER : HEX_DIGIT ('_' | HEX_DIGIT)* HEX_DIGIT | HEX_DIGIT;
Integer : DEC_INTEGER | '0x' HEX_INTEGER;
Fragments are globally accessible, so they can be used in any mode section.
Regular Expressions
The regular expressions used in each rule or fragment can include the following elements:
'a','script': string literals that must be matched entirely (single quotes are used for both characters and strings)\n,\r,\t,\', and\\: common escape codes for characters inside string literals\u{0}-\u{d7ff},\u{e000}-\u{10ffff}: general Unicode escape codes
- character sets:
- inside square brackets:
[abc],[a-z0-9_],[+\-*/]: explicit character set; ‘-’ is used for inclusive ranges inside square brackets\n,\r,\t,\\,\],\-: escape codes for characters inside square brackets (both[and\[are accepted)
- outside square brackets:
'a'..'z': inclusive range of characters.: represents any character
- valid both inside and outside square brackets:
\w: represents a single word character:[_0-9A-Za-z]\d: represents a decimal digit:[0-9]
- inside square brackets:
(and)around item(s): groups the content to avoid conflicts of precedence with other operators~: negates the character set on the right:~[ \t\n\r]: any non-space character~( 'a' | 'b' | 'c' ): any character but ‘a’, ‘b’, or ‘c’, equivalent to~'a'..'c'
*: zero or more instances of the item on the left:[a-z]*: zero or several lettersab*: ‘a’ followed by zero or more ‘b’ characters('--'|'==')*: zero or more pairs of ‘-’ or ‘=’
+: one or more instances of the item on the left (same principle as*)?: zero or one instance of the item on the left (same principle as*)- after
*or+: lazy repetition; for example,'/*' .*? '*/'(avoids the ending"*/"characters to be captured by.*)
- after
|: alternative between several items; for example,'if' | 'while' | [a-z]+- fragment; for example,
( DIGIT | [a-f] )+whereDIGIThas been defined as a fragment
Channels
The lexer can output tokens on different channels, if parts of the input must be processed differently. For example, it can be used to isolate a sublanguage that is better parsed with another parser, or it can extract comments that must be put back in the output of a transpiler but would be too complex to handle in the grammar.
Tokens are normally output on the predefined DEFAULT_CHANNEL (channel #0). If other channels are required, they must be declared with the channels option, and the channel(<channel name>) action must then be used in rules to output the corresponding tokens to that channel.
channels { COMMENTS_CHANNEL };
Comma : ',';
Keyword : [a-zA-Z]+;
CommentLine : '//' ~[\r\n]* -> channel(COMMENTS_CHANNEL);
WhiteSpaces : [ \t\r\n]* -> skip;
CommaandKeywordtokens are output on channel #0.CommentLinetokens are output on channel #1 (COMMENTS_CHANNEL).
The lexer provides two methods to output the tokens:
get_token(&mut self) -> Result<Option<LexerToken>, LexerError>tokens(&mut self) -> LexInterpretIter, which returns an iterator ofLexerTokenitems.
Both methods provide LexerToken items, defined as the following tuple, where the channel is the 2nd element:
#![allow(unused)]
fn main() {
pub type LexerToken = (TokenId, ChannelId, String, PosSpan);
}
The TokenSpliterator trait defines a few convenient methods which can be used with tokens() to filter the tokens. In particular, keep_channel0() discards all the channels but the default one, and its output can be directly connected to a parser generated by Lexigram. An example can be found in the tutorial.
Modes
The lexicon can be divided into mode sections where the rules are independent of the other modes. It is very useful to scan a sublanguage.
For example, Lexi uses square brackets to define a character set: [a-z]. The lexicon used to generate Lexi handles the content of square brackets in a separate mode to simplify the definition and to avoid any conflict with the other rules:
fragment ESC_SET_CHAR : '\\' [nrt\\[\]\-]; // simplified for this example
// ...
Minus : '-';
FixedSet : ('\\w' | '\\d');
LSbracket : '[' -> push(SET_CHAR_MODE);
mode SET_CHAR_MODE;
RSbracket : ']' -> pop;
Minus2 : '-' -> type(Minus);
SetChar : (ESC_SET_CHAR | ~[\n\r\t\\\]\-]);
FixedSet2 : ('\\w' | '\\d') -> type(FixedSet);
When the next input character is "[", the LSbracket token is emitted and the lexer switches to SET_CHAR_MODE. In that mode:
\w,\d, and-emit the same tokens as they would in the default mode- unescaped
"]"emitsRSbracketand returns to the default mode - other valid characters and escape sequences emit
SetChar.
If this had to be written as a single rule, it would require a complex repetition of both escaped and unescaped codes—of different lengths—while avoiding an accidental capture of the ending "]". It would also emit a single token for the whole expression, so the parser would have to interpret it on its side.
With the use of this separate mode, the definitions are simple, and the separate components are sent to the parser, simplifying its job considerably. Here is the relevant part of Lexi’s grammar:
char_set:
LSbracket (char_set_one)+ RSbracket
// ...
;
char_set_one:
SetChar Minus SetChar // adds the range to the current set
| SetChar // adds the character to the current set
| FixedSet // adds the set to the current set
;
Actions
When a rule is matched, the default action of the lexer is to emit the token. Other actions can be specified by placing them on the right side of a -> symbol. Several actions are separated by a comma:
TagOpen: '<' -> mode(TAG_MODE), more;
List of actions:
channel(<channel>)emits the token on a specific channel (which must be defined in achannelsrule).DEFAULT_CHANNELis the name of channel 0, which is the default output channel for all tokens:channels { COMMENTS_CHANNEL, WHITESPACES_CHANNEL }; CommentLine : '//' ~[\r\n]* -> channel(COMMENTS_CHANNEL);skipdiscards the match:WhiteSpaces : [ \t\r\n]* -> skip;mode(<mode>)changes the mode.TagOpen: '<' -> mode(TAG_MODE), more;push(<mode>)changes the mode after pushing the current mode on the stack:ScriptOpen : '<script' -> push(SCRIPT_MODE); Id : [A-Za-z][A-Za-z0-9]+; mode SCRIPT_MODE; ScriptClose : '>' -> pop; ScriptInclude : 'include'; ScriptExecute : 'execute'; ScriptId : [a-z][a-z0-9]+;poppops a mode from the stack and changes to that mode.morecontinues evaluating the rules but keeps all the characters that have been matched so far for the next token. Below, theTagtoken is associated with the<and>delimiters and all the characters in-between:TagOpen : '<' -> mode(TAG_MODE), more; mode TAG_MODE: TagMore : [a-zA-Z][a-zA-Z0-9]* -> more; Tag : '>' -> mode(DEFAULT_MODE);type(<token>)emits another defined token instead of one with the rule’s name. This action can be used if the same token must be emitted from different modes. Below, bothMinusinDEFAULT_MODEandMinus2inSET_CHAR_MODEemit aMinustoken for the"-"input:fragment ESC_SET_CHAR : '\\' [nrt\\[\]\-]; Minus : '-'; LSbracket : '[' -> push(SET_CHAR_MODE); mode SET_CHAR_MODE; RSbracket : ']' -> pop; Minus2 : '-' -> type(Minus); SetChar : (ESC_SET_CHAR | ~[\n\r\t\\\]\-]); FixedSet2 : ('\\w' | '\\d') -> type(FixedSet);hookmarks a token as interceptable. Whenever there’s a possibility that this token is parsed next, the parser calls a specific listener methodhook(…), and the user implementation can change the token before the parsing continues. See also Token Interception.Id : [a-zA-Z][a-zA-Z_0-9]*; (Type) -> hook;
If skip or more is in the list of actions, no token is emitted. Otherwise, the rule emits a token corresponding to the rule name or, if specified, to the type(<token>) action. In the example below, the String and StringContent rules don’t emit any token; they accumulate the input characters. The StringClose rule emits the final String token with all the accumulated characters, including the surrounding double quotes.
String : '"' -> push(STRING_MODE), more;
mode STRING_MODE;
StringClose : '"' -> pop, type(String);
StringContent : [ -z]* -> more;
Fixed / Variable Tokens
Tokens can correspond to a fixed string literal, like "-", or to an expression that can match different strings, like [a-z]+ or "a" | "A". In the documentation, we call them fixed and variable tokens (or terminals), respectively. The distinction is important if you get the symbol table from Lexi, or when implementing the listener generated by Lexigram.
Fixed tokens have an associated (constant) string literal in the symbol table, and they’re not represented in the context argument of the exit_*(…) listener methods. Variable tokens don’t have an associated string in the symbol table, but they’re represented in the context arguments with the captured input, which is generally useful for the parser: an identifier’s name, an integer literal, and so on.
For example, with the following lexicon / grammar:
lexicon Example;
Equal : '=';
Id : [a-z]+;
Num : [0-9]+;
grammar Example;
assign:
Id Equal Num
;
The generated listener has the following method, where Equal is not present in the context because it’s fixed:
#![allow(unused)]
fn main() {
fn exit_assign(&mut self, ctx: CtxAssign) -> SynAssign {
// assign -> Id "=" Num
let CtxAssign::V1 { id, num } = ctx; // id, num: String
SynAssign()
}
}
Note
If the same token is emitted by several rules, the corresponding token is fixed only if all the literals are equal:
Interval : '..'; LSbracket : '[' -> push(SET_CHAR_MODE); mode SET_CHAR_MODE; RSbracket : ']' -> pop; Interval2 : '-' -> type(Interval); SetChar : [a-z];Since
Intervalcan be either".."or"-", it is a variable token.Only emitted tokens are considered. In this somewhat contrived example,
A0 : 'a' -> type(A); A : 'A' -> skip;the generated lexer is such that the input
"a"emits the token 0 =(A, Some("a")), not(A, None), so it won’t be in the context of the listener methods."A"is valid but emits no token.
Advanced Concepts
Token Indices
Each token has an index, which is the index in the symbol table and the value used internally by the lexer and the parser. Normally, it’s transparent to the Lexigram user, who doesn’t need to deal with those values, except when the tokens must be intercepted. In that case, an option of Lexigram generates an optional type that represents the tokens in an enum: --token-enums (or the option builder method token_enums(true), if the lexer is generated from Rust code). See Token Interception for more details.
As a general rule, the index corresponds to the position of the rule in the lexicon. There are a few nuances:
- Rules that don’t produce a token don’t count in the indexing, in order to avoid gaps in the indices.
- If a rule has a
type(Other)action, its definition is used for matching the input, but the output token isOtherand its index is the index given to theOtherrule. Note that if theOtherrule itself doesn’t produce anything, it’s now considered as “productive” because its reference in another productive rule.
produces a lexer such that the inputZero: '0'; NotAToken: 'x' -> type(Other); One: '1'; Other: 'y' -> skip;"0"emits token 0 =(Zero, Some("0"))"1"emits token 1 =(One, Some("1"))"x"emits token 2 =(Other, Some("x"))"y"is valid but doesn’t emit any token
Token Interception
If a rule has the name of the token between parentheses and no definition, it’s a reserved token. Lexi reserves the token index but doesn’t create any lexer rule that outputs the token, unless another rule specifies the token with the type action.
Reserved tokens are mainly used when the tokens are intercepted dynamically by the listener, where they can be changed to another token.
In this example of lexicon, Id takes all the identifiers, even those that are possibly types. Type is reserved, and it’s used in the hook(…) method when the identifier is indeed a language type or a user-defined type.
Id : [a-zA-Z][a-zA-Z_0-9]*;
(Type) -> hook;
Its accompanying grammar has, among other rules, decl:
program:
(<L=decl_i> decl)* (<L=inst_i> inst)+
;
decl:
Type Id (<L=id_i> Comma Id)* SemiColon
| Typedef Type Id SemiColon
;
inst:
Let Id Eq expr SemiColon
| Print expr SemiColon
;
// ...
The two alternatives include Type, though at a different position.
- When time
declis the next nonterminal to derive, or when there’s a possibilitydeclis predicted,hook(…)is called to determine the actual token, to see if the first production should be chosen. - Also, if the 2nd alternative has been predicted,
hook(…)is called afterTypedefhas been successfully matched. At this point, if the method returns anything else than the tokenType, the parser issues a syntax error.
#![allow(unused)]
fn main() {
impl TypedefListener for TypeListener<'_> {
// ...
fn hook(&mut self, token: TokenId, text: &str, span: &PosSpan) -> TokenId {
match text {
"int" | "float" | "double" => Term::Type as u16,
t => {
if self.types.contains_key(t) {
Term::Type as u16
} else {
token
}
}
}
}
}
}
(Term is an enum generated with the --token-enums option)
Warning
Since the method is called each time the hooked token may occur at any point in the grammar, it will potentially encounter all the other tokens that could also occur at that point instead.
In the grammatical definition of
declabove, theTypein the first alternative is “competing” with theTypedefof the second alternative. Therefore, the method could getTerm::TypeorTerm::Typedef. That’s not all:instis competing withdeclas next predicted nonterminal inprogram, so ``LetandPrintare two other arguments thathook(…)` receive.
The listener offers another way to intercept all the tokens: the parser calls the listener’s intercept_token(…) method each time it receives a token from the lexer. Its default implementation is empty, so it’s optimized out by the compiler. Implementing it to intercept tokens may have a bigger performance impact than hook(…), and it requires to take all the incoming tokens into account.
See also the gen_typedef and typedef examples, which illustrate the two interception methods.
Formal Grammar Definition
Here are the lexicon and the grammar of Lexi, using the Lexigram syntax. Those files can be found in the repository, in the build-stage1 directory.
Lexi’s Lexicon
lexicon LexiLexer;
fragment BLOCK_COMMENT : '/*' .*? '*/';
fragment LINE_COMMENT : '//' ~[\r\n]*;
fragment HEX_DIGIT : [0-9a-fA-F];
fragment UNICODE_ESC : 'u{' HEX_DIGIT+ '}';
fragment ESC_CHAR : '\\' ([nrt'\\] | UNICODE_ESC);
fragment CHAR : ESC_CHAR | ~[\n\r\t'\\];
fragment CHAR_LITERAL : '\'' CHAR '\'';
fragment STR_LITERAL : '\'' CHAR CHAR+ '\'';
// Char inside a '[' ']' set
fragment ESC_SET_CHAR : '\\' ([nrt\\[\]\-] | UNICODE_ESC);
Arrow : '->'; /* // first token // */
Colon : ':';
Comma : ',';
Dot : '.';
Ellipsis : '..';
Lbracket : '{';
Lparen : '(';
Negate : '~';
Minus : '-';
Plus : '+';
Or : '|';
Question : '?';
Rbracket : '}';
Rparen : ')';
Semicolon : ';';
Star : '*';
Channels : 'channels';
Fragment : 'fragment';
Lexicon : 'lexicon';
Mode : 'mode';
Pop : 'pop';
Push : 'push';
More : 'more';
Skip : 'skip';
Type : 'type';
Channel : 'channel';
Hook : 'hook';
SComment : BLOCK_COMMENT -> skip;
SLineComment : LINE_COMMENT -> skip;
SWhiteSpace : [ \n\r\t]+ -> skip;
Id : [a-zA-Z][a-zA-Z_0-9]*;
CharLit : CHAR_LITERAL;
StrLit : STR_LITERAL;
FixedSet : ('\\w' | '\\d');
LSbracket : '[' -> push(SET_CHAR_MODE);
mode SET_CHAR_MODE;
RSbracket : ']' -> pop;
Minus2 : '-' -> type(Minus);
SetChar : (ESC_SET_CHAR | ~[\n\r\t\\\]\-]);
FixedSet2 : ('\\w' | '\\d') -> type(FixedSet);
Lexi’s Grammar
grammar LexiParser;
file: header? file_item*;
file_item:
option | declaration | rule
;
header:
Lexicon Id Semicolon
;
declaration:
Mode Id Semicolon
;
option:
Channels Lbracket Id (Comma Id)* Rbracket
;
rule:
rule_fragment_name Colon match Semicolon
| rule_terminal_name Colon match (Arrow actions)? Semicolon
// only reserves the token, but doesn't add a rule to scan it:
| Lparen rule_terminal_name Rparen opt_str_lit (Arrow Hook)? Semicolon
// "grammar" Id ";" => start of grammar section
| rule_terminal_name Id Semicolon
;
opt_str_lit:
(Colon StrLit)?
;
rule_fragment_name:
Fragment Id
;
rule_terminal_name:
Id
;
actions:
action (Comma action)*
;
action:
Mode Lparen Id Rparen
| Push Lparen Id Rparen
| Pop
| Skip
| More
| Type Lparen Id Rparen
| Channel Lparen Id Rparen
| Hook
;
match:
alt_items
;
alt_items:
alt_item (Or alt_item)*
;
alt_item:
repeat_item+
;
repeat_item:
item Star Question?
| item Plus Question?
| item Question?
;
item:
Id
| CharLit (Ellipsis CharLit)?
| StrLit
| char_set
| Lparen alt_items Rparen
| Negate item
;
char_set:
LSbracket (char_set_one)+ RSbracket
| Dot
| FixedSet
;
char_set_one:
SetChar Minus SetChar
| SetChar
| FixedSet
;