birman 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # [file]
4
+ # purpose = "The rule and lexeme macros"
5
+ # responsibility = "Turning rule blocks into methods, and guarding rule names"
6
+ # pattern = "Class-level DSL (extend)"
3
7
  module Birman
4
8
  # The `rule` and `lexeme` macros. Extend any module or class with it.
5
9
  #
@@ -7,7 +11,18 @@ module Birman
7
11
  # private `__rule_name` builds the matcher. Because rules are just
8
12
  # methods, they travel through `include` and can be overridden like
9
13
  # any other Ruby method.
14
+ # [module.RuleSet]
15
+ # purpose = "Defines rules; extend a module with it to write a reusable rule module"
16
+ # collaborators = ["Grammar (extends its classes with this)", "Ref"]
10
17
  module RuleSet
18
+ # [method.rule]
19
+ # params = ["name: Symbol", "block: returns the rule's matcher (String, Regexp or Matcher)"]
20
+ # returns = "nil"
21
+ # side_effects = [
22
+ # "Defines name (returns a Ref) and a private __rule_name",
23
+ # "Resets a grammar class's cached instance"
24
+ # ]
25
+ # raises = ["ArgumentError if name would replace a method the grammar relies on (send, match, token...)"]
11
26
  def rule(name, &)
12
27
  check_rule_name!(name)
13
28
  define_method(:"__rule_#{name}", &)
@@ -18,22 +33,39 @@ module Birman
18
33
 
19
34
  # A rule for a token (a number, a string, an identifier): it skips
20
35
  # trailing spacing when the grammar declares `ignore`.
36
+ # [method.lexeme]
37
+ # params = ["name: Symbol", "body: block returning the token's matcher"]
38
+ # returns = "nil"
39
+ # side_effects = ["Defines a rule that skips trailing spacing via the grammar's ignore rule"]
21
40
  def lexeme(name, &body)
22
- rule(name) { Lexeme.new(Birman.coerce(instance_exec(&body)), ignore_rule) }
41
+ rule(name) { Lexeme.new(Birman.coerce(instance_exec(&body))) }
23
42
  end
24
43
 
25
44
  private
26
45
 
27
46
  # A rule named after a method the grammar relies on (send, class,
28
- # match, token...) would silently replace it. Redefining a rule is
29
- # fine, and so are private Kernel names like print or select.
47
+ # match, token, or a private helper in any of Birman's modules) would
48
+ # silently replace it. Redefining a rule is fine, and so are private
49
+ # Kernel names like print or select, and the grammar's own private
50
+ # helpers: those are the grammar's to replace.
30
51
  def check_rule_name!(name)
31
52
  return if private_method_defined?(:"__rule_#{name}")
32
53
 
33
- owner = [Object, Combinators, Grammar].find { _1.public_method_defined?(name) } ||
34
- [Combinators, Grammar].find { _1.private_method_defined?(name) } ||
54
+ birman = [Object, Combinators, Grammar] | ancestors.select { _1.name&.start_with?("Birman::") }
55
+ owner = birman.find { _1.public_method_defined?(name) } ||
56
+ (birman - [Object]).find { _1.private_method_defined?(name, false) } ||
35
57
  (instance_method(name).owner if method_defined?(name))
36
- raise ArgumentError, "rule :#{name} would replace #{owner}##{name}; give the rule another name" if owner
58
+ return unless owner
59
+
60
+ raise ArgumentError, "rule :#{name} would replace #{owner}##{name}, #{why_taken(owner, birman)}, " \
61
+ "since rules are methods too; give the rule another name"
62
+ end
63
+
64
+ def why_taken(owner, birman)
65
+ if owner == Object then "a method every Ruby object has"
66
+ elsif birman.include?(owner) then "which Birman relies on"
67
+ else "a method of this grammar"
68
+ end
37
69
  end
38
70
  end
39
71
  end
data/lib/birman/rules.rb CHANGED
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # [file]
4
+ # purpose = "Ready-made rule modules"
5
+ # responsibility = "Whitespace, comments, numbers, strings and identifiers"
6
+ # usage = "include the modules a grammar needs; later includes override earlier ones"
3
7
  module Birman
4
8
  # A standard library of rule modules. Include the ones you need;
5
9
  # later includes override earlier ones (Comments replaces
@@ -10,6 +14,9 @@ module Birman
10
14
  module Rules
11
15
  # Whitespace as the `spacing` rule, plus a `token` helper for grammars
12
16
  # that don't declare `ignore`.
17
+ # [module.Rules.Whitespace]
18
+ # rules = ["spacing: zero or more whitespace characters (quiet)"]
19
+ # helpers = ["token(m): m then spacing, for grammars without ignore"]
13
20
  module Whitespace
14
21
  extend RuleSet
15
22
 
@@ -22,6 +29,9 @@ module Birman
22
29
 
23
30
  # Whitespace including comments. Works on its own or after
24
31
  # Whitespace. Override comment_pattern for other comment syntaxes.
32
+ # [module.Rules.Comments]
33
+ # rules = ["spacing: whitespace and comments (quiet)", "comment: one comment"]
34
+ # helpers = ["comment_pattern: override for other syntaxes (default /#[^\\n]*/)", "token(m)"]
25
35
  module Comments
26
36
  extend RuleSet
27
37
 
@@ -33,35 +43,47 @@ module Birman
33
43
  end
34
44
 
35
45
  # Integers and floats, as Ruby numbers.
46
+ # [module.Rules.Numbers]
47
+ # rules = ["integer: Integer", "float: Float", "number: float or integer"]
36
48
  module Numbers
37
49
  extend RuleSet
38
50
 
39
- lexeme(:integer) { match(/\d+/).map(&:to_i).label("integer") }
40
- lexeme(:float) { match(/\d+\.\d+/).map(&:to_f).label("float") }
51
+ lexeme(:integer) { match(/\d+/).map(:to_i).label("integer") }
52
+ lexeme(:float) { match(/\d+\.\d+/).map(:to_f).label("float") }
41
53
  lexeme(:number) { (float | integer).label("number") }
42
54
  end
43
55
 
44
56
  # Double-quoted strings with backslash escapes. The opening quote
45
57
  # commits, so an unterminated string is reported where it breaks.
58
+ # [module.Rules.Strings]
59
+ # rules = ["string: double-quoted, escapes \\n \\t \\r \\0, value is the String"]
60
+ # helpers = ["decode_escape(text): the character a backslash escape stands for"]
61
+ # notes = "The opening quote commits, so an unterminated string is reported where it breaks"
46
62
  module Strings
47
63
  extend RuleSet
48
64
 
49
65
  ESCAPES = { "n" => "\n", "t" => "\t", "r" => "\r", "0" => "\0" }.freeze
50
66
 
51
67
  lexeme(:string) do
52
- (str('"') > (string_part.rep >> str('"').label("closing quote")))
53
- .map { |(_, parts, _)| parts.join }
68
+ seq(str('"'), commit, only(string_part.rep), str('"').label("closing quote"))
69
+ .map(:join)
54
70
  .label("string")
55
71
  end
56
72
 
57
73
  rule(:string_part) do
58
- (match(/\\./).map { ESCAPES.fetch(_1[1], _1[1]) } |
74
+ (match(/\\./).action(:decode_escape) |
59
75
  match(/[^"\\\n]+/)).label("string character")
60
76
  end
77
+
78
+ private
79
+
80
+ def decode_escape(text) = ESCAPES.fetch(text[1], text[1])
61
81
  end
62
82
 
63
83
  # Identifiers that can't be reserved words. Declare those in the
64
84
  # grammar with `keywords "if", "then", ...`.
85
+ # [module.Rules.Identifiers]
86
+ # rules = ["identifier: [A-Za-z_]\\w*, not one of the grammar's keywords"]
65
87
  module Identifiers
66
88
  extend RuleSet
67
89
 
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ # [file]
4
+ # purpose = "Running a matcher without writing a grammar class"
5
+ # responsibility = "Birman.parse and Birman.parse!, via a cached anonymous grammar per matcher"
6
+ # dependencies = ["Birman::Grammar", "Birman::AST"]
7
+ module Birman
8
+ # Parses input with matcher as the whole grammar: Success or Failure,
9
+ # exactly as Grammar.parse, and bad input never raises.
10
+ #
11
+ # Birman.parse(Birman.seq("let", Birman.match(/\w+/)), "let x", ignore: /\s*/)
12
+ # # => #<data Birman::Success value=["let", "x"]>
13
+ #
14
+ # ignore is a matcher (a String, Regexp or Matcher), not a rule name:
15
+ # there is no grammar here for a name to refer to. Literals skip it
16
+ # after themselves, and it's skipped at the start of input.
17
+ # [method."Birman.parse"]
18
+ # params = ["matcher: String | Regexp | Matcher", "input: String", "ignore: String | Regexp | Matcher | nil"]
19
+ # returns = "Birman::Success(value) when all of input parses, else Birman::Failure"
20
+ # raises = ["ArgumentError if matcher or ignore refers to a rule, or an .action/.check method Birman lacks"]
21
+ def self.parse(matcher, input, ignore: nil) = Standalone.grammar(matcher, ignore).parse(input)
22
+
23
+ # [method."Birman.parse!"]
24
+ # params = ["matcher, input, ignore: as Birman.parse", "formatter: #call(failure) = ErrorFormatter.new"]
25
+ # returns = "The parsed value"
26
+ # raises = ["Birman::ParseError", "ArgumentError, as Birman.parse"]
27
+ def self.parse!(matcher, input, ignore: nil, **) = Standalone.grammar(matcher, ignore).parse!(input, **)
28
+
29
+ # The anonymous grammar classes behind Birman.parse: the matcher is
30
+ # the root rule and ignore the ignore rule. Matchers are immutable
31
+ # values, so equal ones share a class, and with it the per-grammar
32
+ # caches (literal matchers, rule matchers). The cache is bounded, so
33
+ # matchers built afresh for each parse (with a new .map block, say)
34
+ # don't pile up.
35
+ # [module.Standalone]
36
+ # purpose = "Builds and caches the anonymous grammar Birman.parse runs"
37
+ module Standalone
38
+ ROOT = :__standalone_root
39
+ IGNORE = :__standalone_ignore
40
+ LIMIT = 256
41
+
42
+ @grammars = {}
43
+ @lock = Mutex.new
44
+
45
+ # [method."Standalone.grammar"]
46
+ # params = ["matcher: String | Regexp | Matcher", "ignore: String | Regexp | Matcher | nil"]
47
+ # returns = "A grammar class whose root rule is matcher (to_ebnf, to_ruby and parse work on it)"
48
+ # raises = ["ArgumentError if matcher or ignore refers to a rule, or an .action/.check method Birman lacks"]
49
+ def self.grammar(matcher, ignore)
50
+ key = [matcher, ignore].freeze
51
+ @lock.synchronize do
52
+ @grammars[key] ||= begin
53
+ @grammars.shift if @grammars.size >= LIMIT
54
+ build(matcher, ignore)
55
+ end
56
+ end
57
+ end
58
+
59
+ def self.build(matcher, ignore)
60
+ grammar = Class.new do
61
+ include Grammar
62
+
63
+ root ROOT
64
+ rule(ROOT) { matcher }
65
+ next unless ignore
66
+
67
+ rule(IGNORE) { ignore }
68
+ self.ignore IGNORE
69
+ end
70
+ check!(grammar, [matcher, ignore].compact.map { Birman.coerce(_1) })
71
+ grammar
72
+ end
73
+
74
+ # A rule reference, or an action or check calling a method only a
75
+ # grammar of your own would have, raises only when the parse reaches
76
+ # it (and the method as a NoMethodError on an anonymous class), so a
77
+ # mistake could hide in an untried alternative. Checked up front.
78
+ def self.check!(grammar, matchers)
79
+ matchers.each { each_matcher(_1) { |m| check_matcher!(grammar, m) } }
80
+ end
81
+
82
+ def self.check_matcher!(grammar, matcher)
83
+ case matcher
84
+ when Ref
85
+ raise ArgumentError, "undefined rule #{matcher.name.inspect}: " \
86
+ "a matcher run by Birman.parse can't refer to rules; define them in a grammar class"
87
+ when Action, Check
88
+ name = matcher.method_name
89
+ return if grammar.method_defined?(name) || grammar.private_method_defined?(name)
90
+
91
+ raise ArgumentError, "undefined method #{name.inspect} for .#{matcher.is_a?(Action) ? "action" : "check"}: " \
92
+ "a matcher run by Birman.parse can only use Birman's own methods there; " \
93
+ "define yours in a grammar class"
94
+ end
95
+ end
96
+
97
+ # Every matcher inside root, root included, once each.
98
+ def self.each_matcher(root)
99
+ queue = [root]
100
+ seen = {}.compare_by_identity
101
+ while (m = queue.shift)
102
+ next if seen[m]
103
+
104
+ seen[m] = true
105
+ yield m
106
+ queue.concat(m.to_h.values.flat_map { Array(_1) }.grep(Matcher)) if m.is_a?(Data)
107
+ end
108
+ end
109
+ private_class_method :build, :check!, :check_matcher!, :each_matcher
110
+ end
111
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Birman
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/birman.rb CHANGED
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # [file]
4
+ # purpose = "Entry point: require \"birman\" loads the whole library"
5
+ # dependencies = ["strscan (standard library)"]
6
+ # requires_ruby = ">= 3.2"
7
+
3
8
  # A small PEG parser library for Ruby 3.2+.
4
9
  #
5
10
  # Greeting = Birman.node(:name)
@@ -22,9 +27,31 @@ module Birman
22
27
  # The character offset of a byte offset into input. Free for ASCII
23
28
  # input (Ruby caches ascii_only?), linear otherwise; used once per
24
29
  # parse for a Failure, and only on demand for a Span.
30
+ # [method."Birman.char_offset"]
31
+ # params = ["input: String", "byte_pos: Integer"]
32
+ # returns = "Integer: the character offset of byte_pos"
25
33
  def self.char_offset(input, byte_pos)
26
34
  input.ascii_only? ? byte_pos : input.byteslice(0, byte_pos).length
27
35
  end
36
+
37
+ # The byte offset of the first byte in input that doesn't start a
38
+ # valid character, or nil if input is valid. Grammar#parse fails
39
+ # there before a regexp can raise on it. Ruby splits invalid bytes
40
+ # into characters of their own, so everything before it is valid,
41
+ # and a Failure's line and column count as usual.
42
+ # [method."Birman.invalid_byte"]
43
+ # params = ["input: String"]
44
+ # returns = "Integer | nil: the byte offset of the first invalid byte"
45
+ def self.invalid_byte(input)
46
+ return if input.valid_encoding?
47
+
48
+ pos = 0
49
+ input.each_char do |char|
50
+ return pos unless char.valid_encoding?
51
+
52
+ pos += char.bytesize
53
+ end
54
+ end
28
55
  end
29
56
 
30
57
  require "strscan"
@@ -37,6 +64,9 @@ require_relative "birman/matchers"
37
64
  require_relative "birman/rule_set"
38
65
  require_relative "birman/combinators"
39
66
  require_relative "birman/grammar"
67
+ require_relative "birman/standalone"
40
68
  require_relative "birman/error_formatter"
41
69
  require_relative "birman/ast"
70
+ require_relative "birman/left_recursion"
42
71
  require_relative "birman/rules"
72
+ require_relative "birman/compiler"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: birman
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tad Thorley
@@ -23,14 +23,24 @@ files:
23
23
  - lib/birman.rb
24
24
  - lib/birman/ast.rb
25
25
  - lib/birman/combinators.rb
26
+ - lib/birman/compiler.rb
27
+ - lib/birman/compiler/actions.rb
28
+ - lib/birman/compiler/emitter.rb
29
+ - lib/birman/compiler/literals.rb
30
+ - lib/birman/compiler/nodes.rb
31
+ - lib/birman/compiler/regexps.rb
32
+ - lib/birman/compiler/runtime.rb
33
+ - lib/birman/compiler/types.rb
26
34
  - lib/birman/context.rb
27
35
  - lib/birman/error_formatter.rb
28
36
  - lib/birman/grammar.rb
37
+ - lib/birman/left_recursion.rb
29
38
  - lib/birman/matchers.rb
30
39
  - lib/birman/nodes.rb
31
40
  - lib/birman/result.rb
32
41
  - lib/birman/rule_set.rb
33
42
  - lib/birman/rules.rb
43
+ - lib/birman/standalone.rb
34
44
  - lib/birman/version.rb
35
45
  homepage: https://github.com/phaedryx/birman
36
46
  licenses: