├── .gitignore ├── .travis.yml ├── Guardfile ├── LICENSE ├── README.md ├── benchmark ├── mtg_json.json ├── parslet_json_benchmark.rb └── slow_json.cr ├── examples ├── hex_colors.cr ├── road_names.cr └── slow_json.cr ├── shard.yml ├── spec ├── examples │ ├── road_names_spec.cr │ └── slow_json_spec.cr ├── lingo │ ├── not_rule_spec.cr │ ├── ordered_choice_spec.cr │ ├── parser_spec.cr │ ├── pattern_terminal_spec.cr │ ├── repeat_spec.cr │ ├── sequence_spec.cr │ ├── string_terminal_spec.cr │ └── visitor_spec.cr └── spec_helper.cr └── src ├── lingo.cr └── lingo ├── context.cr ├── lazy_rule.cr ├── named_rule.cr ├── node.cr ├── not_rule.cr ├── ordered_choice.cr ├── parse_failed_exception.cr ├── parser.cr ├── pattern_terminal.cr ├── repeat.cr ├── rule.cr ├── sequence.cr ├── string_terminal.cr ├── version.cr └── visitor.cr /.gitignore: -------------------------------------------------------------------------------- 1 | /doc/ 2 | /libs/ 3 | .crystal/ 4 | /.shards/ 5 | 6 | 7 | # Libraries don't need dependency lock 8 | # Dependencies will be locked in application that uses them 9 | /shard.lock 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | def run_specs_for(file_prefix) 2 | cmd = "crystal spec spec/#{file_prefix}_spec.cr" 3 | puts cmd 4 | `#{cmd}` 5 | end 6 | 7 | guard :shell do 8 | watch(%r{src/(.*)\.cr}) { |m| run_specs_for(m[1]) } 9 | watch(%r{examples/(.*)\.cr}) { |m| run_specs_for("examples/" + m[1]) } 10 | 11 | watch(%r{spec/(.*)_spec\.cr}) { |m| run_specs_for(m[1]) } 12 | watch(%r{spec/spec_helper\.cr}) { |m| `crystal spec` } 13 | end 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Robert Mosolgo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lingo [![Build Status](https://travis-ci.org/rmosolgo/lingo.svg)](https://travis-ci.org/rmosolgo/lingo) 2 | 3 | A parser generator for Crystal, inspired by [Parslet](https://github.com/kschiess/parslet). 4 | 5 | Lingo provides text processing by: 6 | - parsing the string into a tree of nodes 7 | - providing a visitor to allow you to work from the tree 8 | 9 | ## Installation 10 | 11 | Add this to your application's `shard.yml`: 12 | 13 | ```yaml 14 | dependencies: 15 | lingo: 16 | github: rmosolgo/lingo 17 | ``` 18 | 19 | ## Usage 20 | 21 | Let's write a parser for highway names. The result will be a method for turning strings into useful objects: 22 | 23 | ```ruby 24 | def parse_road(input_str) 25 | ast = RoadParser.new.parse(input_str) 26 | visitor = RoadVisitor.new 27 | visitor.visit(ast) 28 | visitor.road 29 | end 30 | 31 | road = parse_road("I-5N") 32 | # 33 | ``` 34 | 35 | (See more examples in [`/examples`](https://github.com/rmosolgo/lingo/tree/master/examples).) 36 | 37 | In the USA, we write highway names like this: 38 | 39 | ``` 40 | 50 # Route 50 41 | I-64 # Interstate 64 42 | I-95N # Interstate 95, Northbound 43 | 29B # Business Route 29 44 | ``` 45 | 46 | ### Parser 47 | 48 | The general structure is `{interstate?}{number}{direction?}{business?}`. Let's express that with Lingo rules: 49 | 50 | ```ruby 51 | class RoadParser < Lingo::Parser 52 | # Match a string: 53 | rule(:interstate) { str("I-") } 54 | rule(:business) { str("B") } 55 | 56 | # Match a regex: 57 | rule(:digit) { match(/\d/) } 58 | # Express repetition with `.repeat` 59 | rule(:number) { digit.repeat } 60 | 61 | rule(:north) { str("N") } 62 | rule(:south) { str("S") } 63 | rule(:east) { str("E") } 64 | rule(:west) { str("W") } 65 | # Compose rules by name 66 | # Express alternation with | 67 | rule(:direction) { north | south | east | west } 68 | 69 | # Express sequence with >> 70 | # Express optionality with `.maybe` 71 | # Name matched strings with `.named` 72 | rule(:road_name) { 73 | interstate.named(:interstate).maybe >> 74 | number.named(:number) >> 75 | direction.named(:direction).maybe >> 76 | business.named(:business).maybe 77 | } 78 | # You MUST name a starting rule: 79 | root(:road_name) 80 | end 81 | ``` 82 | 83 | #### Applying the Parser 84 | 85 | An instance of a `Lingo::Parser` subclass has a `.parse` method which returns a tree of `Lingo::Node`s. 86 | 87 | ```ruby 88 | RoadParser.new.parse("250B") # => 89 | ``` 90 | 91 | It uses the rule named by `root`. 92 | 93 | #### Making Rules 94 | 95 | These methods help you create rules: 96 | 97 | - `str("string")` matches string exactly 98 | - `match(/[abc]/)` matches the regex exactly 99 | - `a | b` matches `a` _or_ `b` 100 | - `a >> b` matches `a` _followed by_ `b` 101 | - `a.maybe` matches `a` or nothing 102 | - `a.repeat` matches _one-or-more_ `a`s 103 | - `a.repeat(0)` matches _zero-or-more_ `a`s 104 | - `a.absent` matches _not-`a`_ 105 | - `a.named(:a)` names the result `:a` for handling by a visitor 106 | 107 | ### Visitor 108 | 109 | After parsing, you get a tree of `Lingo::Node`s. To turn that into an application object, write a visitor. 110 | 111 | The visitor may define `enter` and `exit` hooks for nodes named with `.named` in the Parser. It may set up some state during `#initialize`, then access itself from the `visitor` variable during hooks. 112 | 113 | 114 | ```ruby 115 | class RoadVisitor < Lingo::Visitor 116 | # Set up an accumulator 117 | getter :road 118 | def initialize 119 | @road = Road.new 120 | end 121 | 122 | # When you find a named node, you can do something with it. 123 | # You can access the current visitor as `visitor` 124 | enter(:interstate) { 125 | # since we found this node, this is a business route 126 | visitor.road.interstate = true 127 | } 128 | 129 | # You can access the named Lingo::Node as `node`. 130 | # Get the matched string with `.full_value` 131 | enter(:number) { 132 | visitor.road.number = node.full_value.to_i 133 | } 134 | 135 | enter(:direction) { 136 | visitor.road.direction = node.full_value 137 | } 138 | 139 | enter(:business) { 140 | visitor.road.business = true 141 | } 142 | end 143 | ``` 144 | 145 | #### Visitor Hooks 146 | 147 | During the depth-first visitation of the resulting tree of `Lingo::Node`s, you can handle visits to nodes named with `.named`: 148 | 149 | - `enter(:match)` is called when entering a node named `:match` 150 | - `exit(:match)` is called when exiting a node named `:match` 151 | 152 | Within the hooks, you can access two magic variables: 153 | 154 | - `visitor` is the Visitor itself 155 | - `node` is the matched `Lingo::Node` which exposes: 156 | - `#full_value`: the full matched string 157 | - `#line`, `#column`: position information for this match 158 | 159 | ## About this Project 160 | 161 | ### Goals 162 | 163 | - Low barrier to entry: easy-to-learn API, short zero-to-working time 164 | - Easy-to-read code, therefore easy-to-modify 165 | - Useful errors (not accomplished) 166 | 167 | ### Non-goals 168 | 169 | - Blazing-fast performance 170 | - Theoretical correctness 171 | 172 | ### TODO 173 | 174 | - [ ] Add some kind of debug output 175 | 176 | ### How slow is it? 177 | 178 | Let's compare the built-in JSON parser to a Lingo JSON parser: 179 | 180 | ``` 181 | ./lingo/benchmark $ crystal run --release slow_json.cr 182 | Stdlib JSON 126.45k (± 1.55%) fastest 183 | Lingo::JSON 660.18 (± 1.28%) 191.54× slower 184 | ``` 185 | 186 | Ouch, that's __a lot slower__. 187 | 188 | But, it's on par with Ruby and `parslet`, the inspiration for this project: 189 | 190 | ``` 191 | $ ruby parslet_json_benchmark.rb 192 | Calculating ------------------------------------- 193 | Parslet JSON 4.000 i/100ms 194 | Built-in JSON 3.657k i/100ms 195 | ------------------------------------------------- 196 | Parslet JSON 45.788 (± 4.4%) i/s - 232.000 197 | Built-in JSON 38.285k (± 5.3%) i/s - 193.821k 198 | 199 | Comparison: 200 | Built-in JSON: 38285.2 i/s 201 | Parslet JSON : 45.8 i/s - 836.13x slower 202 | ``` 203 | 204 | Both Parslet and Lingo are slower than handwritten parsers. But, they're easier to write! 205 | 206 | ## Development 207 | 208 | - Run the __tests__ with `crystal spec` 209 | - Install Ruby & `guard`, then start a __watcher__ with `guard` 210 | -------------------------------------------------------------------------------- /benchmark/mtg_json.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "Sen Triplets", 3 | 4 | "manaCost" : "{2}{W}{U}{B}", 5 | "cmc" : 5, 6 | "colors" : ["White", "Blue", "Black"], 7 | 8 | "type" : "Legendary Artifact Creature — Human Wizard", 9 | "supertypes" : ["Legendary"], 10 | "types" : ["Artifact", "Creature"], 11 | "subtypes" : ["Human", "Wizard"], 12 | 13 | "rarity" : "Mythic Rare", 14 | 15 | "text" : "At the beginning of your upkeep, choose target opponent. This turn, that player can't cast spells or activate abilities and plays with his or her hand revealed. You may play cards from that player's hand this turn.", 16 | 17 | "flavor" : "They are the masters of your mind.", 18 | 19 | "artist" : "Greg Staples", 20 | "number" : "109", 21 | 22 | "power" : "3", 23 | "toughness" : "3", 24 | 25 | "layout" : "normal", 26 | "multiverseid" : 180607, 27 | "imageName" : "sen triplets", 28 | "id" : "3129aee7f26a4282ce131db7d417b1bc3338c4d4" 29 | } 30 | -------------------------------------------------------------------------------- /benchmark/parslet_json_benchmark.rb: -------------------------------------------------------------------------------- 1 | 2 | require 'parslet' 3 | require 'benchmark/ips' 4 | require 'json' 5 | 6 | # From a parslet example on Github 7 | module MyJson 8 | class Parser < Parslet::Parser 9 | rule(:spaces) { match('\s').repeat(1) } 10 | rule(:spaces?) { spaces.maybe } 11 | 12 | rule(:comma) { spaces? >> str(',') >> spaces? } 13 | rule(:digit) { match('[0-9]') } 14 | 15 | rule(:number) { 16 | ( 17 | str('-').maybe >> ( 18 | str('0') | (match('[1-9]') >> digit.repeat) 19 | ) >> ( 20 | str('.') >> digit.repeat(1) 21 | ).maybe >> ( 22 | match('[eE]') >> (str('+') | str('-')).maybe >> digit.repeat(1) 23 | ).maybe 24 | ).as(:number) 25 | } 26 | 27 | rule(:string) { 28 | str('"') >> ( 29 | str('\\') >> any | str('"').absent? >> any 30 | ).repeat.as(:string) >> str('"') 31 | } 32 | 33 | rule(:array) { 34 | str('[') >> spaces? >> 35 | (value >> (comma >> value).repeat).maybe.as(:array) >> 36 | spaces? >> str(']') 37 | } 38 | 39 | rule(:object) { 40 | str('{') >> spaces? >> 41 | (entry >> (comma >> entry).repeat).maybe.as(:object) >> 42 | spaces? >> str('}') 43 | } 44 | 45 | rule(:value) { 46 | string | number | 47 | object | array | 48 | str('true').as(:true) | str('false').as(:false) | 49 | str('null').as(:null) 50 | } 51 | 52 | rule(:entry) { 53 | ( 54 | string.as(:key) >> spaces? >> 55 | str(':') >> spaces? >> 56 | value.as(:val) 57 | ).as(:entry) 58 | } 59 | 60 | rule(:attribute) { (entry | value).as(:attribute) } 61 | 62 | rule(:top) { spaces? >> value >> spaces? } 63 | 64 | root(:top) 65 | end 66 | 67 | class Transformer < Parslet::Transform 68 | 69 | class Entry < Struct.new(:name, :val); end 70 | 71 | rule(:array => subtree(:ar)) { 72 | ar.is_a?(Array) ? ar : [ ar ] 73 | } 74 | rule(:object => subtree(:ob)) { 75 | (ob.is_a?(Array) ? ob : [ ob ]).inject({}) { |h, e| h[e.name] = e.val; h } 76 | } 77 | 78 | rule(:entry => { :key => simple(:ke), :val => subtree(:va) }) { 79 | Entry.new(ke, va) 80 | } 81 | 82 | rule(:string => simple(:st)) { 83 | st.to_s 84 | } 85 | rule(:number => simple(:nb)) { 86 | nb.match(/[eE\.]/) ? Float(nb) : Integer(nb) 87 | } 88 | 89 | rule(:null => simple(:nu)) { nil } 90 | rule(:true => simple(:tr)) { true } 91 | rule(:false => simple(:fa)) { false } 92 | end 93 | 94 | TRANSFORMER = Transformer.new 95 | PARSER = Parser.new 96 | def self.parse(s) 97 | tree = PARSER.parse(s) 98 | out = TRANSFORMER.apply(tree) 99 | out 100 | end 101 | end 102 | 103 | 104 | JSON_TEXT = File.read("./mtg_json.json") 105 | 106 | Benchmark.ips do |x| 107 | x.report("Parslet JSON ") { MyJson.parse(JSON_TEXT) } 108 | x.report("Built-in JSON") { JSON.parse(JSON_TEXT) } 109 | x.compare! 110 | end 111 | -------------------------------------------------------------------------------- /benchmark/slow_json.cr: -------------------------------------------------------------------------------- 1 | require "../examples/slow_json" 2 | require "benchmark" 3 | require "json" 4 | 5 | JSON_TEXT = File.read("./mtg_json.json") 6 | 7 | Benchmark.ips do |x| 8 | x.report("Stdlib JSON") { JSON.parse(JSON_TEXT) } 9 | x.report("Lingo::JSON") { SlowJSON.parse(JSON_TEXT) } 10 | end 11 | -------------------------------------------------------------------------------- /examples/hex_colors.cr: -------------------------------------------------------------------------------- 1 | require "../src/lingo" 2 | 3 | module HexColors 4 | def self.parse(color_string) 5 | tree = Parser.new.parse(color_string) 6 | visitor = Visitor.new 7 | visitor.visit(tree) 8 | visitor.color 9 | end 10 | 11 | class Color 12 | property red : String? 13 | property green : String? 14 | property blue : String? 15 | 16 | def to_s 17 | "" 18 | end 19 | end 20 | 21 | class Parser < Lingo::Parser 22 | root(:color) 23 | 24 | rule(:color) { hash_mark >> octet.named(:red) >> octet.named(:green) >> octet.named(:blue) } 25 | rule(:hash_mark) { str("#") } 26 | rule(:octet) { match(/[0-9A-f]{2}/i) } 27 | end 28 | 29 | class Visitor < Lingo::Visitor 30 | getter :color 31 | 32 | def initialize 33 | @color = Color.new 34 | end 35 | 36 | enter(:red) { 37 | visitor.color.red = node.full_value.upcase 38 | } 39 | enter(:blue) { 40 | visitor.color.blue = node.full_value.upcase 41 | } 42 | enter(:green) { 43 | visitor.color.green = node.full_value.upcase 44 | } 45 | end 46 | end 47 | 48 | puts HexColors.parse("#ff0000").to_s 49 | puts HexColors.parse("#ab1276").to_s 50 | -------------------------------------------------------------------------------- /examples/road_names.cr: -------------------------------------------------------------------------------- 1 | require "../src/lingo" 2 | 3 | module RoadNames 4 | class Road 5 | property :number, :interstate, :direction, :business 6 | @number : Int32? 7 | @direction : String? 8 | @interstate = false 9 | @business = false 10 | end 11 | 12 | class RoadParser < Lingo::Parser 13 | # Match a string: 14 | rule(:interstate) { str("I-") } 15 | rule(:business) { str("B") } 16 | 17 | # Match a regex: 18 | rule(:digit) { match(/\d/) } 19 | # Express repetition with `.repeat` 20 | rule(:number) { digit.repeat } 21 | 22 | rule(:north) { str("N") } 23 | rule(:south) { str("S") } 24 | rule(:east) { str("E") } 25 | rule(:west) { str("W") } 26 | # Compose rules by name 27 | # Express alternation with | 28 | rule(:direction) { north | south | east | west } 29 | 30 | # Express sequence with >> 31 | # Express optionality with `.maybe` 32 | # Name matched strings with `.as` 33 | rule(:road_name) { 34 | interstate.named(:interstate).maybe >> 35 | number.named(:number) >> 36 | direction.named(:direction).maybe >> 37 | business.named(:business).maybe 38 | } 39 | # You MUST name a starting rule: 40 | root(:road_name) 41 | end 42 | 43 | class RoadVisitor < Lingo::Visitor 44 | getter :road 45 | 46 | def initialize 47 | @road = Road.new 48 | end 49 | 50 | # When you find a named node, you can do something with it. 51 | # You can access the current visitor as `visitor` 52 | enter(:interstate) { 53 | # since we found this node, this is a business route 54 | visitor.road.interstate = true 55 | } 56 | 57 | # You can access the named Lingo::Node as `node`. 58 | # Get the matched string with `.full_value` 59 | enter(:number) { 60 | visitor.road.number = node.full_value.to_i 61 | } 62 | 63 | enter(:direction) { 64 | visitor.road.direction = node.full_value 65 | } 66 | 67 | enter(:business) { 68 | visitor.road.business = true 69 | } 70 | end 71 | 72 | def self.parse_road(input_str) 73 | ast = RoadParser.new.parse(input_str) 74 | visitor = RoadVisitor.new 75 | visitor.visit(ast) 76 | visitor.road 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /examples/slow_json.cr: -------------------------------------------------------------------------------- 1 | require "../src/lingo" 2 | 3 | module SlowJSON 4 | def self.parse(input_string) 5 | parse_result = JSONParser.new.parse(input_string) 6 | visitor = JSONVisitor.new 7 | visitor.visit(parse_result) 8 | visitor.values.pop 9 | end 10 | 11 | class JSONParser < Lingo::Parser 12 | root(:main_object) 13 | rule(:main_object) { space? >> object >> space? } 14 | 15 | rule(:object) { 16 | (str("{") >> space? >> 17 | (pair >> (comma >> pair).repeat(0)).maybe >> 18 | space? >> str("}")).named(:object) 19 | } 20 | 21 | rule(:comma) { space? >> str(",") >> space? } 22 | 23 | rule(:pair) { key.named(:key) >> kv_delimiter >> value.named(:value) } 24 | rule(:kv_delimiter) { space? >> str(":") >> space? } 25 | rule(:key) { string } 26 | rule(:value) { 27 | string | float | integer | 28 | object | array | 29 | value_true | value_false | value_null 30 | } 31 | 32 | rule(:array) { 33 | (str("[") >> space? >> 34 | ( 35 | value.named(:value) >> 36 | (comma >> value.named(:value)).repeat(0) 37 | ).maybe >> 38 | space? >> str("]")).named(:array) 39 | } 40 | 41 | rule(:string) { 42 | str('"') >> ( 43 | str("\\") >> any | str('"').absent >> any 44 | ).repeat.named(:string) >> str('"') 45 | } 46 | 47 | rule(:integer) { digits.named(:integer) } 48 | rule(:float) { (digits >> str(".") >> digits).named(:float) } 49 | 50 | rule(:value_true) { str("true").named(:true) } 51 | rule(:value_false) { str("false").named(:false) } 52 | rule(:value_null) { str("null").named(:null) } 53 | 54 | rule(:digits) { match(/[0-9]+/) } 55 | rule(:space?) { match(/\s+/).maybe } 56 | end 57 | 58 | alias JSONKey = String 59 | alias JSONValue = Hash(JSONKey, JSONValue) | Array(JSONValue) | Nil | Int32 | Float64 | String | Bool 60 | alias JSONArray = Array(JSONValue) 61 | alias JSONResult = Hash(JSONKey, JSONValue) 62 | 63 | class JSONVisitor < Lingo::Visitor 64 | getter :objects, :keys, :values 65 | 66 | def initialize 67 | @objects = [] of JSONResult | JSONArray 68 | @keys = [] of JSONKey 69 | @values = [] of JSONValue 70 | end 71 | 72 | exit(:key) { 73 | string = visitor.values.pop 74 | if string.is_a?(JSONKey) 75 | visitor.keys.push(string) 76 | else 77 | raise("Invalid JSON Key: #{string}") 78 | end 79 | } 80 | 81 | exit(:value) { 82 | value = visitor.values.pop 83 | current_object = visitor.objects.last 84 | if current_object.is_a?(JSONResult) 85 | key = visitor.keys.pop 86 | current_object[key] = value 87 | elsif current_object.is_a?(JSONArray) 88 | current_object << value 89 | else 90 | raise("Can't add without current object (#{value})") 91 | end 92 | } 93 | 94 | exit(:object) { 95 | visitor.objects.pop 96 | } 97 | 98 | exit(:array) { 99 | visitor.objects.pop 100 | } 101 | 102 | enter(:object) { 103 | new_obj = JSONResult.new 104 | visitor.objects << new_obj 105 | visitor.values << new_obj 106 | } 107 | 108 | enter(:array) { 109 | new_array = JSONArray.new 110 | visitor.objects << new_array 111 | visitor.values << new_array 112 | } 113 | 114 | enter(:string) { visitor.values << node.full_value } 115 | enter(:integer) { visitor.values << node.full_value.to_i } 116 | enter(:float) { visitor.values << node.full_value.to_f } 117 | enter(:true) { visitor.values << true } 118 | enter(:false) { visitor.values << false } 119 | enter(:null) { visitor.values << nil } 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: lingo 2 | version: 0.2.1 3 | 4 | authors: 5 | - Robert Mosolgo 6 | 7 | license: MIT 8 | 9 | crystal: 1.2.2 10 | -------------------------------------------------------------------------------- /spec/examples/road_names_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe "RoadNames" do 4 | it "finds interstate" do 5 | RoadNames.parse_road("I-95").interstate.should eq(true) 6 | RoadNames.parse_road("101").interstate.should eq(false) 7 | end 8 | 9 | it "finds numbers" do 10 | RoadNames.parse_road("I-95N").number.should eq(95) 11 | end 12 | 13 | it "finds direction" do 14 | RoadNames.parse_road("64W").direction.should eq("W") 15 | RoadNames.parse_road("50").direction.should eq(nil) 16 | end 17 | 18 | it "finds business" do 19 | RoadNames.parse_road("250B").business.should eq(true) 20 | RoadNames.parse_road("250").business.should eq(false) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/examples/slow_json_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe "SlowJSON" do 4 | it "parses JSON" do 5 | input = %{{ 6 | "a" : 1, 7 | "b" : true, 8 | "c" : false, 9 | "d":null, 10 | "e" : 3.321, 11 | "f": { "f1": "f2"}, 12 | "g": [1, null, {"h": "str"}] 13 | }} 14 | res = SlowJSON.parse(input) 15 | 16 | expected = { 17 | "a" => 1, 18 | "b" => true, 19 | "c" => false, 20 | "d" => nil, 21 | "e" => 3.321, 22 | "f" => {"f1" => "f2"}, 23 | "g" => [1, nil, {"h" => "str"}], 24 | } 25 | 26 | res.should eq(expected) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/lingo/not_rule_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | not_plus = Math.parser.plus.absent 4 | 5 | describe "Lingo::NotRule" do 6 | it "is true if the rule is _not_ found" do 7 | not_plus.matches?("-").should eq(true) 8 | not_plus.matches?("+").should eq(false) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/lingo/ordered_choice_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | SIXES_TERMINAL = Lingo::Terminal.new("66") 4 | SEVENS_TERMINAL = Lingo::Terminal.new("77") 5 | EIGHTS_TERMINAL = Lingo::Terminal.new("88") 6 | SEVENS_EIGHTS = Lingo::OrderedChoice.new(SEVENS_TERMINAL, EIGHTS_TERMINAL) 7 | SIXES_SEVENS_EIGHTS = Lingo::OrderedChoice.new(SIXES_TERMINAL, SEVENS_EIGHTS) 8 | 9 | describe "Lingo::OrderedChoice" do 10 | describe "#matches?" do 11 | it "tries to match the first one, then falls back to the second" do 12 | digit = math_parser.digit 13 | plus = math_parser.plus 14 | d_or_p = digit | plus 15 | d_or_p.matches?("00+").should eq(true) 16 | d_or_p.matches?("+1").should eq(true) 17 | d_or_p.matches?("99").should eq(false) 18 | end 19 | end 20 | 21 | describe "#parse" do 22 | it "applies the first successful parse" do 23 | digit = math_parser.digit 24 | plus = math_parser.plus 25 | d_or_p = (digit | plus).named(:d_or_p) 26 | 27 | result = d_or_p.parse("+") 28 | result.full_value.should eq("+") 29 | 30 | result = d_or_p.parse("1") 31 | result.full_value.should eq("1") 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/lingo/parser_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe "Lingo::Parser" do 4 | describe ".rule / rule methods" do 5 | it "exposes Rules" do 6 | plus_rule = Math.parser.plus 7 | result = plus_rule.parse("+") 8 | result.should be_a(Lingo::Node) 9 | end 10 | end 11 | 12 | describe "#parse" do 13 | it "returns named results" do 14 | result = Math.parser.parse("1+1+3") 15 | result.name.should eq(:expression) 16 | end 17 | 18 | describe "on errors" do 19 | it "tells how far it got" do 20 | expect_raises(Lingo::ParseFailedException, "at 3:16") do 21 | Math.parser.parse("1 22 | + 23 | 3 + 3%%") 24 | end 25 | end 26 | 27 | it "tells the nearby string" do 28 | expect_raises(Lingo::ParseFailedException, "%%%") do 29 | Math.parser.parse("1+%%%") 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/lingo/pattern_terminal_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | letter_rule = Lingo::PatternTerminal.new(/[a-zA-Z]+/) 4 | number_rule = Lingo::PatternTerminal.new(/[0-9]+/) 5 | 6 | describe "Lingo::PatternTerminal" do 7 | it "finds Regex matches" do 8 | letters = letter_rule.parse("aBCdE") 9 | letters.full_value.should eq("aBCdE") 10 | end 11 | 12 | it "combines with others" do 13 | numbers_then_letters = number_rule >> letter_rule 14 | numbers_then_letters.matches?("1a").should eq(true) 15 | numbers_then_letters.matches?("771abc").should eq(true) 16 | numbers_then_letters.matches?("xw").should eq(false) 17 | numbers_then_letters.matches?("11").should eq(false) 18 | numbers_then_letters.matches?("df11").should eq(false) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/lingo/repeat_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe "Crystal::Repeat" do 4 | it "matches from -> to repetitions" do 5 | digits = math_parser.digit.repeat(3,6) 6 | digits.parse?("13").should eq(false) 7 | digits.parse?("103").should eq(true) 8 | digits.parse?("1031").should eq(true) 9 | digits.parse?("103555").should eq(true) 10 | digits.parse?("1035115").should eq(false) 11 | end 12 | 13 | it "defaults to: Infinity" do 14 | plusses = math_parser.plus.repeat(2) 15 | plusses.parse?("+").should eq(false) 16 | plusses.parse?("++").should eq(true) 17 | plusses.parse?("++++++++++++++").should eq(true) 18 | end 19 | 20 | it "works with 0 -> 1" do 21 | plusses = math_parser.plus.repeat(0, 1) 22 | plusses.parse?("").should eq(true) 23 | plusses.parse?("+").should eq(true) 24 | plusses.parse?("++").should eq(false) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/lingo/sequence_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | integer = Math.parser.integer 4 | operator = Math.parser.operator 5 | binary_expression = integer.named(:operand) >> operator >> integer.named(:operand) 6 | 7 | describe "Lingo::Sequence" do 8 | describe "#matches?" do 9 | it "only matches if all parts match" do 10 | binary_expression.should be_a(Lingo::Sequence) 11 | 12 | binary_expression.matches?("1+1").should eq(true) 13 | binary_expression.matches?("1+12321").should eq(true) 14 | 15 | binary_expression.matches?("1+").should eq(false) 16 | binary_expression.matches?("+1").should eq(false) 17 | end 18 | end 19 | 20 | describe "#parse" do 21 | it "returns children in the sequence" do 22 | result = binary_expression.parse("-15+1") 23 | result.children.size.should eq(3) 24 | child_names = result.children.map &.name 25 | child_names.should eq([:operand, :plus, :operand]) 26 | result.line.should eq(1) 27 | result.column.should eq(1) 28 | child_columns = result.children.map &.column 29 | child_columns.should eq([1, 4, 5]) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/lingo/string_terminal_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | def alpha_rule 4 | Lingo::StringTerminal.new("alpha") 5 | end 6 | 7 | def beta_rule 8 | Lingo::StringTerminal.new("beta") 9 | end 10 | 11 | def gamma_rule 12 | Lingo::StringTerminal.new("gamma") 13 | end 14 | 15 | describe "Lingo::StringTerminal" do 16 | describe "string rules" do 17 | describe "#parse" do 18 | it "returns a node" do 19 | rule = alpha_rule 20 | result = rule.parse("alpha") 21 | result.value.should eq("alpha") 22 | 23 | fail_result = rule.parse?("google") 24 | fail_result.should eq(false) 25 | end 26 | end 27 | end 28 | 29 | describe "#|" do 30 | it "creates an ordered choice" do 31 | alphabeta_rule = alpha_rule | beta_rule 32 | alphabeta_rule.matches?("alpha").should eq(true) 33 | alphabeta_rule.matches?("beta").should eq(true) 34 | end 35 | end 36 | 37 | describe "#>>" do 38 | it "creates a sequence" do 39 | alphabeta_rule = alpha_rule >> beta_rule 40 | alphabeta_rule.matches?("alpha").should eq(false) 41 | alphabeta_rule.matches?("beta").should eq(false) 42 | alphabeta_rule.matches?("alphabeta").should eq(true) 43 | alphabetagamma_rule = alphabeta_rule >> gamma_rule 44 | alphabetagamma_rule.matches?("alphabetagamma").should eq(true) 45 | end 46 | end 47 | 48 | describe "#maybe" do 49 | it "creates an repeat(0,1)" do 50 | maybe_alpha_rule = alpha_rule.maybe 51 | maybe_alpha_rule.matches?("alpha").should eq(true) 52 | maybe_alpha_rule.matches?("beta").should eq(true) 53 | end 54 | end 55 | 56 | describe "#absent" do 57 | it "creates a NotRule" do 58 | not_alpha_rule = alpha_rule.absent 59 | not_alpha_rule.matches?("alpha").should eq(false) 60 | not_alpha_rule.matches?("beta").should eq(true) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/lingo/visitor_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe "Lingo::Visitor" do 4 | describe "#visit" do 5 | it "transforms parse result, bottom-up" do 6 | return_value = Math.eval("-10+1") 7 | return_value.should eq(-9) 8 | 9 | parse_result = Math.parser.parse("5*3") 10 | visitor = Math.visitor 11 | visitor.visit(parse_result) 12 | return_value = visitor.values.pop 13 | return_value.should eq(15) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/lingo" 3 | require "../examples/slow_json" 4 | require "../examples/road_names" 5 | 6 | def math_parser 7 | Math.parser 8 | end 9 | 10 | module Math 11 | def self.eval(string_of_math) 12 | parse_result = parser.parse(string_of_math) 13 | math_visitor = Math::Visitor.new 14 | math_visitor.visit(parse_result) 15 | math_visitor.values.pop 16 | end 17 | 18 | def self.parser 19 | @@instance ||= Math::Parser.new 20 | end 21 | 22 | def self.visitor 23 | Math::Visitor.new 24 | end 25 | 26 | alias Operand = Int32 27 | alias BinaryOperation = (Int32, Int32) -> Int32 28 | ADDITION = BinaryOperation.new { |left, right| left + right } 29 | MULTIPLICATION = BinaryOperation.new { |left, right| left * right } 30 | 31 | class Parser < Lingo::Parser 32 | root(:expression) 33 | rule(:expression) { 34 | integer.named(:operand) >> 35 | ws.repeat(0) >> 36 | (operator >> ws.repeat(0) >> integer.named(:operand) >> ws.repeat(0)).repeat(0) 37 | } 38 | rule(:operator) { (plus.named(:plus) | times.named(:times)) } 39 | 40 | rule(:sign) { plus.named(:positive) | minus.named(:negative) } 41 | 42 | rule(:plus) { str("+") } 43 | rule(:minus) { str("-") } 44 | rule(:times) { str("*") } 45 | 46 | rule(:integer) { sign.maybe >> digit.repeat } 47 | rule(:digit) { str("0") | str("1") | str("3") | str("5") } 48 | rule(:ws) { match(/[\n\r\t\s]/) } 49 | end 50 | 51 | class Visitor < Lingo::Visitor 52 | alias ValueStack = Array(Operand) 53 | alias OperationStack = Array(BinaryOperation) 54 | getter :values, :operations 55 | 56 | def initialize 57 | @values = ValueStack.new 58 | @operations = OperationStack.new 59 | end 60 | 61 | enter(:operand) { 62 | visitor.values << node.full_value.to_i 63 | } 64 | enter(:plus) { 65 | visitor.operations << ADDITION 66 | } 67 | enter(:times) { 68 | visitor.operations << MULTIPLICATION 69 | } 70 | 71 | exit(:expression) { 72 | op = visitor.operations.pop 73 | right = visitor.values.pop 74 | left = visitor.values.pop 75 | return_value = op.call(left, right) 76 | visitor.values << return_value 77 | } 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /src/lingo.cr: -------------------------------------------------------------------------------- 1 | require "./lingo/*" 2 | 3 | module Lingo 4 | end 5 | -------------------------------------------------------------------------------- /src/lingo/context.cr: -------------------------------------------------------------------------------- 1 | class Lingo::Context 2 | property :remainder, :current_node, :root, :column, :line 3 | 4 | def initialize(@remainder = "", @root = nil.as(Lingo::Node?), @column = 1, @line = 1) 5 | end 6 | 7 | def push_node(parsed_node) 8 | current_root = @root 9 | 10 | if current_root.nil? 11 | @root = parsed_node 12 | elsif current_root.is_a?(Lingo::Node) 13 | current_root.children << parsed_node 14 | end 15 | 16 | nil 17 | end 18 | 19 | def fork(root = nil) 20 | self.class.new(remainder: remainder, root: root, column: column, line: line) 21 | end 22 | 23 | def join(other_context) 24 | @remainder = other_context.remainder 25 | @column = other_context.column 26 | @line = other_context.line 27 | other_root = other_context.root 28 | if other_root.is_a?(Lingo::Node) 29 | push_node(other_root) 30 | else 31 | raise("Can't rejoin a context without a root") 32 | end 33 | end 34 | 35 | def consume(matched_string) 36 | new_lines = matched_string.count("\n") 37 | last_line = matched_string.split("\n").last 38 | @line += new_lines 39 | 40 | if new_lines > 0 41 | @column = last_line.size + 1 42 | else 43 | @column += last_line.size 44 | end 45 | 46 | char_count = matched_string.size 47 | @remainder = @remainder[char_count..-1] 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /src/lingo/lazy_rule.cr: -------------------------------------------------------------------------------- 1 | require "./rule" 2 | 3 | class LazyRule < Lingo::Rule 4 | alias RuleGenerator = Proc(Lingo::Rule) 5 | @inner_rule : Lingo::Rule? 6 | 7 | def initialize(&rule_generator : RuleGenerator) 8 | @rule_generator = rule_generator 9 | end 10 | 11 | def parse?(raw_input) 12 | inner_rule.parse?(raw_input) 13 | end 14 | 15 | def named(name) 16 | inner_rule.named(name) 17 | end 18 | 19 | private def inner_rule 20 | @inner_rule ||= @rule_generator.call 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /src/lingo/named_rule.cr: -------------------------------------------------------------------------------- 1 | require "./rule" 2 | 3 | class Lingo::NamedRule < Lingo::Rule 4 | def initialize(@name : String | Symbol, @inner : Lingo::Rule) 5 | end 6 | 7 | def parse?(context : Lingo::Context) 8 | new_root = Lingo::Node.new(name: @name, line: context.line, column: context.column) 9 | new_context = context.fork(root: new_root) 10 | success = @inner.parse?(new_context) 11 | if success 12 | context.join(new_context) 13 | true 14 | else 15 | false 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /src/lingo/node.cr: -------------------------------------------------------------------------------- 1 | class Lingo::Node 2 | getter :value, :children, :line, :column 3 | property :name 4 | 5 | def initialize(@value = "", @children = [] of Lingo::Node, @name = nil.as(String? | Symbol?), @line = 0, @column = 0) 6 | end 7 | 8 | def to_s 9 | "<#{self.class.name} (#{@name}) value='#{value}' children=(#{children.map { |c| c.name.inspect as String }.join(", ")})>" 10 | end 11 | 12 | def full_value 13 | @value + children.map { |c| c.full_value.as(String) }.join("") 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /src/lingo/not_rule.cr: -------------------------------------------------------------------------------- 1 | require "./rule" 2 | 3 | class Lingo::NotRule < Lingo::Rule 4 | def initialize(@inner : Lingo::Rule) 5 | end 6 | 7 | def parse?(context : Lingo::Context) 8 | new_context = context.fork 9 | success = @inner.parse?(new_context) 10 | 11 | if success 12 | # This rule was matched, but shouldn't have been 13 | false 14 | else 15 | # This rule WASNT matched, 🎉 16 | true 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /src/lingo/ordered_choice.cr: -------------------------------------------------------------------------------- 1 | require "./rule" 2 | 3 | class Lingo::OrderedChoice < Lingo::Rule 4 | alias Choices = Array(Lingo::Rule) 5 | getter :choices 6 | @choices : Choices 7 | 8 | def initialize(incoming_choices = Choices.new) 9 | new_choices = Choices.new 10 | incoming_choices.each do |incoming_choice| 11 | if incoming_choice.responds_to?(:choices) 12 | new_choices += incoming_choice.choices 13 | else 14 | new_choices << incoming_choice 15 | end 16 | end 17 | @choices = new_choices 18 | end 19 | 20 | def parse?(context : Lingo::Context) 21 | next_context = context.fork 22 | choices.each do |choice| 23 | if choice.parse?(next_context) 24 | break 25 | end 26 | end 27 | next_result = next_context.root 28 | if next_result.is_a?(Lingo::Node) 29 | context.join(next_context) 30 | true 31 | else 32 | false 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /src/lingo/parse_failed_exception.cr: -------------------------------------------------------------------------------- 1 | class Lingo::ParseFailedException < Exception 2 | property :context 3 | 4 | def initialize(@context : Lingo::Context) 5 | super("Parse failed at #{position} (\"#{next_text(5)}\")") 6 | end 7 | 8 | private def next_text(length) 9 | next_chars = @context.remainder[0..length] 10 | 11 | if @context.remainder.size > length 12 | next_chars += "..." 13 | end 14 | next_chars 15 | end 16 | 17 | private def position 18 | "#{@context.line}:#{@context.column}" 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /src/lingo/parser.cr: -------------------------------------------------------------------------------- 1 | class Lingo::Parser 2 | def initialize 3 | @rules = {} of Symbol => Lingo::Rule 4 | end 5 | 6 | macro str(match) 7 | Lingo::StringTerminal.new({{match}}) 8 | end 9 | 10 | macro match(patt) 11 | Lingo::PatternTerminal.new({{patt}}) 12 | end 13 | 14 | macro rule(rule_name, &block) 15 | def {{rule_name.id}} 16 | @rules[{{rule_name}}] ||= LazyRule.new { ({{block.body}}).as(Lingo::Rule) } 17 | end 18 | end 19 | 20 | macro root(rule_name) 21 | def root 22 | @root ||= {{rule_name.id}}.named({{rule_name}}).as(Lingo::Rule) 23 | end 24 | 25 | def parse(raw_input) 26 | root.parse(raw_input) 27 | end 28 | end 29 | 30 | def any 31 | @rules[:any] ||= match(/./) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /src/lingo/pattern_terminal.cr: -------------------------------------------------------------------------------- 1 | class Lingo::PatternTerminal < Lingo::Rule 2 | def initialize(pattern : Regex) 3 | pat_string = pattern.to_s 4 | @pattern = Regex.new("^#{pat_string}") 5 | end 6 | 7 | def parse?(context : Lingo::Context) 8 | success = false 9 | match_data = context.remainder.match(@pattern) 10 | if match_data 11 | success = true 12 | match_string = match_data[0] 13 | match_node = Lingo::Node.new(value: match_string, line: context.line, column: context.column) 14 | context.push_node(match_node) 15 | context.consume(match_string) 16 | end 17 | success 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /src/lingo/repeat.cr: -------------------------------------------------------------------------------- 1 | class Lingo::Repeat < Lingo::Rule 2 | def initialize(@inner : Lingo::Rule, @from = 0, @to : (Float32 | Int32) = Float32::INFINITY) 3 | end 4 | 5 | def parse?(context : Lingo::Context) 6 | new_context = context.fork 7 | successes = 0 8 | while @inner.parse?(new_context) 9 | successes += 1 10 | end 11 | 12 | if @from <= successes <= @to 13 | if new_context.root 14 | context.join(new_context) 15 | end 16 | true 17 | else 18 | false 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /src/lingo/rule.cr: -------------------------------------------------------------------------------- 1 | require "./node" 2 | 3 | abstract class Lingo::Rule 4 | abstract def parse?(context : Lingo::Context) 5 | 6 | def parse(raw_input : String) 7 | context = Lingo::Context.new(remainder: raw_input) 8 | parse(context) 9 | end 10 | 11 | def parse?(raw_input : String) 12 | context = Lingo::Context.new(raw_input) 13 | parse?(context) 14 | end 15 | 16 | def parse(context : Lingo::Context) 17 | success = parse?(context) 18 | remainder = context.remainder 19 | result_node = context.root 20 | if success && remainder == "" && result_node.is_a?(Lingo::Node) 21 | result_node 22 | else 23 | raise ParseFailedException.new(context) 24 | end 25 | end 26 | 27 | def matches?(raw_input) 28 | !!parse?(raw_input) 29 | end 30 | 31 | def |(other : Lingo::Rule) 32 | Lingo::OrderedChoice.new([self, other]) 33 | end 34 | 35 | def >>(other : Lingo::Rule) 36 | Lingo::Sequence.new([self, other]) 37 | end 38 | 39 | def maybe 40 | repeat(0, 1) 41 | end 42 | 43 | def repeat(from = 1, to = Float32::INFINITY) 44 | Lingo::Repeat.new(self, from: from, to: to) 45 | end 46 | 47 | def named(name) 48 | Lingo::NamedRule.new(name, self) 49 | end 50 | 51 | def absent 52 | Lingo::NotRule.new(self) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /src/lingo/sequence.cr: -------------------------------------------------------------------------------- 1 | class Lingo::Sequence < Lingo::Rule 2 | alias Parts = Array(Lingo::Rule) 3 | getter :parts 4 | @parts : Parts 5 | 6 | def initialize(incoming_parts = Parts.new, @name = nil.as(String?)) 7 | new_parts = Parts.new 8 | incoming_parts.each do |input| 9 | if input.is_a?(Lingo::Sequence) 10 | new_parts += input.parts 11 | else 12 | new_parts << input 13 | end 14 | end 15 | @parts = new_parts 16 | end 17 | 18 | def parse?(context : Lingo::Context) 19 | sequence_parent = Lingo::Node.new(name: @name, line: context.line, column: context.column) 20 | new_context = context.fork(root: sequence_parent) 21 | 22 | results = parts.map do |matcher| 23 | success = matcher.parse?(new_context) 24 | if !success 25 | break 26 | end 27 | end 28 | 29 | # Break causes it to be nil 30 | if results.is_a?(Array) && parts.size == results.size 31 | context.join(new_context) 32 | true 33 | else 34 | false 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /src/lingo/string_terminal.cr: -------------------------------------------------------------------------------- 1 | class Lingo::StringTerminal < Lingo::Rule 2 | getter :search 3 | @search : String 4 | 5 | def initialize(search : String | Char) 6 | @search = search.to_s 7 | end 8 | 9 | def parse?(context : Lingo::Context) 10 | raw_input = context.remainder 11 | 12 | if raw_input.starts_with?(search) 13 | node = Lingo::Node.new(value: search, line: context.line, column: context.column) 14 | context.consume(search) 15 | context.push_node(node) 16 | true 17 | else 18 | false 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /src/lingo/version.cr: -------------------------------------------------------------------------------- 1 | module Lingo 2 | VERSION = "0.2.0" 3 | end 4 | -------------------------------------------------------------------------------- /src/lingo/visitor.cr: -------------------------------------------------------------------------------- 1 | require "./node" 2 | 3 | class Lingo::Visitor 4 | macro create_registry 5 | HandlerRegistry.new do |h, k| 6 | new_list = HandlerList.new 7 | h[k] = new_list 8 | end 9 | end 10 | 11 | macro inherited 12 | @@enter_handlers : HandlerRegistry 13 | @@exit_handlers : HandlerRegistry 14 | 15 | alias Handler = Lingo::Node, self -> Nil 16 | alias HandlerList = Array(Handler) 17 | alias HandlerRegistry = Hash(Symbol, HandlerList) 18 | 19 | @@enter_handlers = create_registry 20 | @@exit_handlers = create_registry 21 | end 22 | 23 | macro enter(rule_name, &block) 24 | @@enter_handlers[{{rule_name}}] << Handler.new { |node, visitor| 25 | {{block.body}} 26 | next nil 27 | } 28 | end 29 | 30 | macro exit(rule_name, &block) 31 | @@exit_handlers[{{rule_name}}] << Handler.new { |node, visitor| 32 | {{block.body}} 33 | next nil 34 | } 35 | end 36 | 37 | # Depth-first visit this node & children, 38 | # calling handlers along the way. 39 | def visit(node) 40 | apply_visitation(node) 41 | end 42 | 43 | private def apply_visitation(node) 44 | node_name = node.name 45 | 46 | if node_name.is_a?(Symbol) 47 | # p "Enter: #{node_name} : #{node.full_value}" 48 | enter_handlers = @@enter_handlers[node_name] 49 | apply_handers(node, enter_handlers) 50 | end 51 | 52 | node.children.each do |child_node| 53 | apply_visitation(child_node) 54 | end 55 | 56 | if node_name.is_a?(Symbol) 57 | # p "Exit: #{node_name} : #{node.full_value}" 58 | exit_handlers = @@exit_handlers[node_name] 59 | apply_handers(node, exit_handlers) 60 | end 61 | 62 | nil 63 | end 64 | 65 | private def apply_handers(node : Lingo::Node, handlers) 66 | handlers.each do |handler| 67 | handler.call(node, self) 68 | end 69 | end 70 | end 71 | --------------------------------------------------------------------------------