birman 0.2.2 → 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.
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Birman
4
+ class Compiler
5
+ # Values written into the generated parser as Ruby literals: the
6
+ # constants copied actions read, and .means values.
7
+ # [module.Compiler.Literals]
8
+ # purpose = "Which values can be written as literals, and how"
9
+ module Literals
10
+ module_function
11
+
12
+ def literal?(value)
13
+ case value
14
+ when String, Symbol, Integer, true, false, nil then true
15
+ when Float then value.finite?
16
+ when Array then value.all? { literal?(_1) }
17
+ when Hash then value.all? { |k, v| literal?(k) && literal?(v) }
18
+ else false
19
+ end
20
+ end
21
+
22
+ # A frozen Array or Hash stays frozen.
23
+ def source(value)
24
+ frozen = value.frozen? && (value.is_a?(Array) || value.is_a?(Hash))
25
+ frozen ? "#{value.inspect}.freeze" : value.inspect
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Birman
4
+ class Compiler
5
+ # The generated counterpart of each node(...) class a grammar builds:
6
+ # a plain class under BirmanRT::Node (so a rule whose value is one of
7
+ # several node classes keeps a single type), with readers, a span,
8
+ # == ignoring the span, and Birman::Node's s-expression inspect. A
9
+ # node's own initialize can give fields defaults (see Initializer),
10
+ # which are written where each node is built.
11
+ # [class.Compiler.Nodes]
12
+ # purpose = "Collects the grammar's node classes and writes their generated versions"
13
+ class Nodes
14
+ def initialize
15
+ @classes = {}
16
+ @defaults = {}
17
+ end
18
+
19
+ # The generated class's name for node, registering it.
20
+ def name_for(node, rule)
21
+ unless node.is_a?(Class) && node.include?(Birman::Node)
22
+ raise Error, "rule :#{rule} builds #{node.inspect}, which isn't a node(...) class"
23
+ end
24
+
25
+ name = node.name&.split("::")&.last or raise Error, "rule :#{rule} builds an anonymous node class"
26
+ check(node, name, rule)
27
+ other = @classes[name]
28
+ raise Error, "#{other.name} and #{node.name} would both be generated as #{name}" if other && other != node
29
+
30
+ @classes[name] = node
31
+ name
32
+ end
33
+
34
+ def sources = @classes.map { |name, node| source(name, node.members) }
35
+
36
+ def names = @classes.keys
37
+
38
+ # The source of the default for each field node's initialize gives
39
+ # one, by field.
40
+ def defaults(node) = @defaults.fetch(node, {})
41
+
42
+ private
43
+
44
+ def check(node, name, rule)
45
+ if RESERVED_CONSTANTS.include?(name)
46
+ raise Error, "rule :#{rule} builds #{node.name}, but #{name} is a name the generated parser needs"
47
+ end
48
+
49
+ own = node.instance_methods(false) - node.members + node.private_instance_methods(false)
50
+ return if own.empty?
51
+ return @defaults[node] ||= Initializer.new(node, rule).defaults if own == [:initialize]
52
+
53
+ raise Error, "rule :#{rule} builds #{node.name}, whose methods (#{own.join(", ")}) can't be compiled yet"
54
+ end
55
+
56
+ def source(name, fields)
57
+ <<~RUBY
58
+ class #{name} < BirmanRT::Node
59
+ #{"attr_reader #{fields.map(&:inspect).join(", ")}" unless fields.empty?}
60
+
61
+ def initialize(#{[*fields, :span].join(", ")})
62
+ #{[*fields, :span].map { " @#{_1} = #{_1}" }.join("\n")}
63
+ end
64
+
65
+ def ==(other)
66
+ #{["other.is_a?(#{name})", *fields.map { "@#{_1} == other.#{_1}" }].join(" && ")}
67
+ end
68
+
69
+ def inspect
70
+ "(#{[name, *fields.map { "\#{@#{_1}.inspect}" }].join(" ")})"
71
+ end
72
+
73
+ def dump
74
+ "(#{["#{name}[\#{@span.byte_from},\#{@span.byte_to}]", *fields.map { "\#{BirmanRT.dump(@#{_1})}" }].join(" ")})"
75
+ end
76
+ end
77
+ RUBY
78
+ end
79
+ end
80
+
81
+ # A node's own initialize, read from its source with Prism. One that
82
+ # takes exactly its fields as keywords, some with literal defaults,
83
+ # and only calls super, as in
84
+ #
85
+ # node(:name, :args) { def initialize(name:, args: []) = super }
86
+ #
87
+ # gives the defaults' sources: they're written where each node is
88
+ # built, so a [] is a new Array every time, as in Ruby. Anything
89
+ # more is refused.
90
+ class Initializer
91
+ def initialize(node, rule)
92
+ @node = node
93
+ @rule = rule
94
+ end
95
+
96
+ def defaults
97
+ found = find
98
+ check_parameters(found.parameters)
99
+ refuse("does more than call super, so it can't be compiled") unless bare_super?(found.body)
100
+
101
+ (found.parameters&.keywords || []).each_with_object({}) do |keyword, defaults|
102
+ next unless keyword.is_a?(Prism::OptionalKeywordParameterNode)
103
+
104
+ default = keyword.value
105
+ unless literal?(default)
106
+ refuse("gives #{keyword.name} a default (#{default.slice}) that isn't a literal " \
107
+ "(nil, true, false, numbers, strings, symbols, and arrays and hashes of them)")
108
+ end
109
+ defaults[keyword.name] = default.slice
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ def find
116
+ file, line = @node.instance_method(:initialize).source_location
117
+ found = (MethodFinder.new(:initialize, line).find(MethodFinder.parse(file)) if file && File.exist?(file))
118
+ found&.first or refuse("isn't written with def, so it can't be compiled")
119
+ end
120
+
121
+ def check_parameters(parameters)
122
+ keywords = parameters&.keywords || []
123
+ return if others(parameters).empty? && keywords.map(&:name).sort == @node.members.sort
124
+
125
+ refuse("must take exactly its fields (#{@node.members.join(", ")}) as keywords, and nothing else, " \
126
+ "to be compiled")
127
+ end
128
+
129
+ # Every parameter but the keywords.
130
+ def others(parameters)
131
+ return [] unless parameters
132
+
133
+ [*parameters.requireds, *parameters.optionals, parameters.rest, *parameters.posts,
134
+ parameters.keyword_rest, parameters.block].compact
135
+ end
136
+
137
+ def bare_super?(body)
138
+ body.is_a?(Prism::StatementsNode) && body.body.size == 1 &&
139
+ body.body.first.is_a?(Prism::ForwardingSuperNode) && body.body.first.block.nil?
140
+ end
141
+
142
+ def literal?(value)
143
+ case value
144
+ when Prism::NilNode, Prism::TrueNode, Prism::FalseNode, Prism::IntegerNode, Prism::FloatNode,
145
+ Prism::SymbolNode
146
+ true
147
+ when Prism::StringNode then !value.opening&.start_with?("<<") # a heredoc's slice is only its opening
148
+ when Prism::ArrayNode then value.elements.all? { literal?(_1) }
149
+ when Prism::HashNode
150
+ value.elements.all? { _1.is_a?(Prism::AssocNode) && literal?(_1.key) && literal?(_1.value) }
151
+ else false
152
+ end
153
+ end
154
+
155
+ def refuse(problem) = raise(Error, "rule :#{@rule} builds #{@node.name}, whose initialize #{problem}")
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Birman
4
+ class Compiler
5
+ # Regexps for StringScanner#skip at the scan position. The interpreter
6
+ # matches with fixed_anchor: true, so \A, ^, lookbehind, and a \b or
7
+ # \B before anything is matched see the input before the position;
8
+ # the generated parser's scanner starts its subject at the position,
9
+ # where they would silently mean something else. Those are refused.
10
+ # Everything else gets a leading \A: in Spinel an unanchored miss
11
+ # searches the rest of the input.
12
+ # [module.Compiler.Regexps]
13
+ # purpose = "Checking and anchoring the grammar's regexps"
14
+ module Regexps
15
+ module_function
16
+
17
+ # The regexp literal to emit for re.
18
+ def anchored(re)
19
+ extended = re.options.anybits?(Regexp::EXTENDED)
20
+ problem = Check.new(re.source, extended).problem
21
+ raise Error, "#{re.inspect} can't be compiled: it uses #{problem}" if problem
22
+
23
+ # With /x, a trailing comment would swallow the closing paren.
24
+ Regexp.new("\\A(?:#{re.source}#{"\n" if extended})", re.options).inspect
25
+ end
26
+
27
+ # Walks a regexp's source, tracking whether something is certain to
28
+ # have been matched (consumed) at each point. A \b or \B is allowed
29
+ # only where it is. Conservative: what it can't follow, it refuses.
30
+ class Check
31
+ # One open group: whether input was consumed when it opened, in
32
+ # every alternative finished so far, and in the current one; and,
33
+ # for spotting (?>x{m,n}), how many atoms it holds and the wide
34
+ # interval among them.
35
+ Frame = Struct.new(:start, :all, :now, :lookaround, :atomic, :atoms, :wide, :alternated)
36
+
37
+ # A {m,n} that lets more than WIDE lengths be tried.
38
+ Wide = Struct.new(:text, :safe)
39
+ WIDE = 16
40
+
41
+ SPINEL_BACKTRACKING = "%s with a lookaround, atomic group, backreference or lazy interval: Spinel's " \
42
+ "regexp engine tries every length it allows (exponential; it raises past about 18). " \
43
+ "Make the repeat atomic on its own, (?>x%s), if nothing after it needs a shorter " \
44
+ "match, or use + with a separate length check"
45
+
46
+ OPTIONAL = /[?*]|\{,|\{0+[,}]/
47
+ QUANTIFIER = /(?:[?*+]|\{\d*,?\d*\})[?+]?/
48
+
49
+ def initialize(source, extended)
50
+ @scanner = StringScanner.new(source)
51
+ @extended = extended
52
+ @frames = [Frame.new(false, true, false, false, false, 0)]
53
+ @backtracks = false
54
+ @wide = []
55
+ end
56
+
57
+ def problem
58
+ until @scanner.eos?
59
+ problem = step
60
+ return problem if problem
61
+ end
62
+ spinel_problem
63
+ end
64
+
65
+ private
66
+
67
+ def frame = @frames.last
68
+
69
+ # Spinel runs a regexp on its backtracking engine if it has any of
70
+ # these, and there a wide {m,n} goes exponential when every length
71
+ # fails. Only a group that is (?>x{m,n}) and nothing else is safe.
72
+ def spinel_problem
73
+ wide = @wide.find { !_1.safe }
74
+ format(SPINEL_BACKTRACKING, wide.text, wide.text) if @backtracks && wide
75
+ end
76
+
77
+ def step # rubocop:disable Metrics
78
+ s = @scanner
79
+ if s.skip(/\\A/) then "\\A"
80
+ elsif s.skip(/\\[bB]/) then frame.now ? nil : "\\b or \\B before anything is matched"
81
+ elsif s.skip(/\\[zZG]/) then nil
82
+ elsif s.skip(/\\(?:\d|k<\w+>|k'\w+')/) then backreference
83
+ elsif s.skip(/\\[pPu]\{[^}]*\}|\\./m) then atom
84
+ elsif s.skip(/\(\?<[=!]/) then "lookbehind"
85
+ elsif s.skip(/\(\?#[^)]*\)/) then nil
86
+ elsif s.skip(/\(\?[=!]/) then open_group(lookaround: true)
87
+ elsif s.skip(/\(\?>/) then open_group(lookaround: false, atomic: true)
88
+ elsif s.skip(/\(\?[imx]*(?:-[imx]*)?\)/) then inline_options(s.matched)
89
+ elsif s.skip(/\((?:\?(?:[imx]*(?:-[imx]*)?:|>|<\w+>|'\w+'))?/) then open_group(lookaround: false)
90
+ elsif s.skip(/\|/) then alternative
91
+ elsif s.skip(/\)/) then close
92
+ elsif s.skip(/\^/) then "^"
93
+ elsif s.skip(/\[/) then character_class
94
+ elsif @extended && s.skip(/\s+|#[^\n]*/) then nil
95
+ else
96
+ s.getch
97
+ atom
98
+ end
99
+ end
100
+
101
+ # Something that matches at least one character, unless a
102
+ # quantifier after it makes it optional.
103
+ def atom = quantified(true)
104
+
105
+ def quantified(consumes)
106
+ optional = @scanner.match?(OPTIONAL)
107
+ quantifier(@scanner.scan(QUANTIFIER))
108
+ frame.now ||= consumes && !optional
109
+ nil
110
+ end
111
+
112
+ def backreference
113
+ @backtracks = true
114
+ quantified(false)
115
+ end
116
+
117
+ # Counts the atom just read, and notes its quantifier if it's a
118
+ # wide or lazy interval.
119
+ def quantifier(text)
120
+ frame.atoms += 1
121
+ frame.wide = nil
122
+ bounds = text&.match(/\A\{(\d*),(\d+)\}(\?)?/) or return
123
+ @backtracks ||= !bounds[3].nil?
124
+ return unless bounds[2].to_i - bounds[1].to_i > WIDE
125
+
126
+ @wide << (frame.wide = Wide.new(text, false))
127
+ end
128
+
129
+ def open_group(lookaround:, atomic: false)
130
+ @backtracks ||= lookaround || atomic
131
+ @frames << Frame.new(frame.now, true, frame.now, lookaround, atomic, 0)
132
+ nil
133
+ end
134
+
135
+ def alternative
136
+ frame.all &&= frame.now
137
+ frame.now = frame.start
138
+ frame.alternated = true
139
+ nil
140
+ end
141
+
142
+ def close
143
+ return "an unmatched )" if @frames.size == 1
144
+
145
+ group = @frames.pop
146
+ group.wide.safe = true if atomic_interval?(group)
147
+ consumed = !group.lookaround && group.all && group.now
148
+ optional = @scanner.match?(OPTIONAL)
149
+ quantifier(@scanner.scan(QUANTIFIER))
150
+ frame.now ||= consumed && !optional
151
+ nil
152
+ end
153
+
154
+ # (?>x{m,n}) and nothing else in the group.
155
+ def atomic_interval?(group)
156
+ group.atomic && group.wide && group.atoms == 1 && !group.alternated
157
+ end
158
+
159
+ # (?x) turns on extended mode for the rest (conservatively, even
160
+ # past the end of its group).
161
+ def inline_options(text)
162
+ @extended ||= text[/\A\(\?([imx]*)/, 1].include?("x")
163
+ nil
164
+ end
165
+
166
+ # A character class (nested ones included), after its "[".
167
+ def character_class
168
+ depth = 1
169
+ @scanner.skip(/\^?\]?/)
170
+ while depth.positive? && !@scanner.eos?
171
+ if @scanner.skip(/\\./m) then next
172
+ elsif @scanner.skip(/\[/) then depth += 1
173
+ elsif @scanner.skip(/\]/) then depth -= 1
174
+ else @scanner.getch
175
+ end
176
+ end
177
+ atom
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end