Regex based Lexer with F#
This lexer allows you to define your regular expression based rules in a very declarative way using F# computation expressions.
open Lexerlet definitions =lexerDefinitions {do! addNextlineDefinition "NEWLINE" @"(\n\r)|\n|\r"do! addIgnoreDefinition "WS" @"\s"do! addDefinition "LET" "let"do! addDefinition "ID" "(?i)[a-z][a-z0-9]*"do! addDefinition "FLOAT" @"[0-9]+\.[0-9]+"do! addDefinition "INT" "[0-9]+"do! addDefinition "OPERATOR" @"[+*=!/&|<>\^\-]+"}
With those defined you can execute the lexer with:
open Lexerlet lex input =trylet y = Lexer.tokenize definitions inputprintfn "%A" ywith e -> printf "%s" e.Messagelex "let a = 5"
Which will result in:
seq [{name = "LET";
text = "let";
pos = 0;
column = 0;
line = 0;}; {name = "ID";
text = "a";
pos = 4;
column = 4;
line = 0;}; {name = "OPERATOR";
text = "=";
pos = 6;
column = 6;
line = 0;}; {name = "INT";
text = "5";
pos = 8;
column = 8;
line = 0;}]
The lexer’s code is structured in three parts. The first part is a state monad using the F# computation expressions. This enables the declarative approach (seen above) to setup your lexer rules.
module StateMonadtype State<'s,'a> = State of ('s -> ('a *'s))let runState (State f) = ftype StateBuilder() =member b.Return(x) = State (fun s -> (x,s))member b.Delay(f) = f() : State<'s,'a>member b.Zero() = State (fun s -> ((),s))member b.Bind(State p,rest) = State (fun s -> let v,s2 = p s in (runState (rest v)) s2)member b.Get () = State (fun s -> (s,s))member b.Put s = State (fun _ -> ((),s))
The second part are the combinators that are used to define your lexer rules. There are three main combinators: AddDefinition which lets you define a name / regex pair, AddIgnoreDefinition which lets you define characters which the lexer should ignore and AddNextlineDefinition which lets you define what characters determine a new line.
type LexDefinitions ={regexes : string list;names : string list;nextlines : bool list;ignores : bool list; }let buildDefinition name pattern nextLine ignore =state {let! x = state.Get()do! state.Put { regexes = x.regexes @ [sprintf @"(?<%s>%s)" name pattern];names = x.names @ [name];nextlines = x.nextlines @ [nextLine];ignores = x.ignores @ [ignore]}}let addDefinition name pattern = buildDefinition name pattern false falselet addIgnoreDefinition name pattern = buildDefinition name pattern false truelet addNextlineDefinition name pattern = buildDefinition name pattern true true
And the final part is the code that performs the tokenizing. It uses the Seq.unfold method to create the list of tokens. Unfold is a function which takes a single item and generates a list of new items from it. It is the opposite of Seq.fold which takes a list of items and turns it into a single item. The tokenize function used Seq.unfold to generate each token while keeping track of the current line number, position in that line and position in the input string.
type Token ={ name : string;text: string;pos :int;column: int;line: int }let createLexDefs pb = (runState pb) {regexes = []; names = []; nextlines = []; ignores = []} |> sndlet tokenize lexerBuilder (str:string) =let patterns = createLexDefs lexerBuilderlet combinedRegex = Regex(List.fold (fun acc reg -> acc + "|" + reg) (List.head patterns.regexes) (List.tail patterns.regexes))let nextlineMap = List.zip patterns.names patterns.nextlines |> Map.ofListlet ignoreMap = List.zip patterns.names patterns.ignores |> Map.ofListlet tokenizeStep (pos,line,lineStart) =if pos >= str.Length thenNoneelselet getMatchedGroupName (grps:GroupCollection) names = List.find (fun (name:string) -> grps.[name].Length > 0) namesmatch combinedRegex.Match(str, pos) with| mt when mt.Success && pos = mt.Index ->let groupName = getMatchedGroupName mt.Groups patterns.nameslet column = mt.Index - lineStartlet nextPos = pos + mt.Lengthlet (nextLine, nextLineStart) = if nextlineMap.Item groupName then (line + 1, nextPos) else (line,lineStart)let token = if ignoreMap.Item groupNamethen Noneelse Some {name = groupName;text = mt.Value;pos = pos;line = line;column = column; }Some(token, (nextPos, nextLine, nextLineStart))| otherwise ->let textAroundError = str.Substring(pos, min (pos + 5) str.Length)raise (ArgumentException (sprintf "Lexing error occured at line:%d and column:%d near the text:%s" line (pos - lineStart) textAroundError))Seq.unfold tokenizeStep (0, 0, 0) |> Seq.filter (fun x -> x.IsSome) |> Seq.map (fun x -> x.Value)
Lastly, here are the unit tests written using XUnit.Net:
module LexerFactsopen Xunitopen Lexeropen System.Linqlet simpleDefs =state {do! addNextlineDefinition "NextLine" "/"do! addIgnoreDefinition "IgnoredSymbol" "=+"do! addDefinition "String" "[a-zA-Z]+"do! addDefinition "Number" "\d+"do! addDefinition "Name" "Matt"}[<Fact>]let Will_return_no_tokens_for_empty_string() =let tokens = Lexer.tokenize simpleDefs ""Assert.Equal(0, tokens.Count())[<Fact>]let Will_throw_exception_for_invalid_token() =let tokens = Lexer.tokenize simpleDefs "-"let ex = Assert.ThrowsDelegateWithReturn(fun () -> upcast tokens.Count()) |> Record.ExceptionAssert.NotNull(ex)Assert.True(ex :? System.ArgumentException)[<Fact>]let Will_ignore_symbols_defined_as_ignore_symbols() =let tokens = Lexer.tokenize simpleDefs "========="Assert.Equal(0, tokens.Count())[<Fact>]let Will_get_token_with_correct_position_and_type() =let tokens = Lexer.tokenize simpleDefs "1one=2=two"Assert.Equal("Number",tokens.ElementAt(2).name)Assert.Equal("2",tokens.ElementAt(2).text)Assert.Equal(5,tokens.ElementAt(2).pos)Assert.Equal(5,tokens.ElementAt(2).column)Assert.Equal(0,tokens.ElementAt(2).line)[<Fact>]let Will_tokenize_string_with_alernating_numbers_and_strings() =let tokens = Lexer.tokenize simpleDefs "1one2two"Assert.Equal("1",tokens.ElementAt(0).text)Assert.Equal("one",tokens.ElementAt(1).text)Assert.Equal("2",tokens.ElementAt(2).text)Assert.Equal("two",tokens.ElementAt(3).text)[<Fact>]let Will_increment_line_with_newline_symbol() =let tokens = Lexer.tokenize simpleDefs "1one/2two"Assert.Equal("Number",tokens.ElementAt(2).name)Assert.Equal("2",tokens.ElementAt(2).text)Assert.Equal(5,tokens.ElementAt(2).pos)Assert.Equal(0,tokens.ElementAt(2).column)Assert.Equal(1,tokens.ElementAt(2).line)[<Fact>]let Will_give_priority_to_lexer_definitions_defined_earlier() =let tokens = Lexer.tokenize simpleDefs "Matt"Assert.Equal("String",tokens.ElementAt(0).name)
I attached a zip (Lexer.zip) containing all the code mentioned above.

