textfsm 0.2.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.
- checksums.yaml +7 -0
- data/LICENSE +202 -0
- data/NOTICE +15 -0
- data/README.md +289 -0
- data/examples/cisco_bgp_summary_example +15 -0
- data/examples/cisco_bgp_summary_template +16 -0
- data/examples/cisco_ipv6_interface_example +53 -0
- data/examples/cisco_ipv6_interface_template +27 -0
- data/examples/cisco_version_example +40 -0
- data/examples/cisco_version_template +17 -0
- data/examples/f10_ip_bgp_summary_example +16 -0
- data/examples/f10_ip_bgp_summary_template +15 -0
- data/examples/f10_version_example +25 -0
- data/examples/f10_version_template +10 -0
- data/examples/index +14 -0
- data/examples/juniper_bgp_summary_example +12 -0
- data/examples/juniper_bgp_summary_template +22 -0
- data/examples/juniper_version_example +10 -0
- data/examples/juniper_version_template +21 -0
- data/examples/unix_ifcfg_example +16 -0
- data/examples/unix_ifcfg_template +18 -0
- data/exe/textfsm +6 -0
- data/lib/textfsm/cli.rb +79 -0
- data/lib/textfsm/cli_table.rb +92 -0
- data/lib/textfsm/data.rb +23 -0
- data/lib/textfsm/errors.rb +8 -0
- data/lib/textfsm/field.rb +83 -0
- data/lib/textfsm/index_table.rb +88 -0
- data/lib/textfsm/options.rb +93 -0
- data/lib/textfsm/parser.rb +233 -0
- data/lib/textfsm/pattern.rb +322 -0
- data/lib/textfsm/rule.rb +72 -0
- data/lib/textfsm/table.rb +96 -0
- data/lib/textfsm/version.rb +5 -0
- data/lib/textfsm.rb +5 -0
- metadata +105 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Ruby port of Google TextFSM options. Copyright 2010 Google Inc.
|
|
4
|
+
# Modified in 2026. Licensed under Apache-2.0; see LICENSE.
|
|
5
|
+
module TextFSM
|
|
6
|
+
module Options
|
|
7
|
+
class Base
|
|
8
|
+
attr_reader :field
|
|
9
|
+
|
|
10
|
+
def initialize(field)
|
|
11
|
+
@field = field
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def after_initialize
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def after_assign
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def after_clear
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def after_reset
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def before_record
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def visible?
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class Required < Base
|
|
35
|
+
def before_record
|
|
36
|
+
throw :skip_record if field.empty?
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class Filldown < Base
|
|
41
|
+
def after_assign
|
|
42
|
+
@saved_value = field.value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def after_clear
|
|
46
|
+
field.value = @saved_value
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def after_reset
|
|
50
|
+
@saved_value = nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class Fillup < Base
|
|
55
|
+
def after_assign
|
|
56
|
+
field.parser.fill_up(field) unless field.empty?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class Key < Base; end
|
|
61
|
+
|
|
62
|
+
class List < Base
|
|
63
|
+
def after_initialize
|
|
64
|
+
@items = []
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def after_assign
|
|
68
|
+
match = field.pattern.match(field.value) if field.value && !field.pattern.names.empty?
|
|
69
|
+
@items << (match ? field.pattern.named_captures(match) : field.value)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def after_clear
|
|
73
|
+
@items = [] unless field.option?("Filldown")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def after_reset
|
|
77
|
+
@items = []
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def before_record
|
|
81
|
+
field.value = @items.dup
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
BUILTINS = {
|
|
86
|
+
"Required" => Required,
|
|
87
|
+
"Filldown" => Filldown,
|
|
88
|
+
"Fillup" => Fillup,
|
|
89
|
+
"List" => List,
|
|
90
|
+
"Key" => Key
|
|
91
|
+
}.freeze
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Ruby port of Google TextFSM parser. Copyright 2010 Google Inc.
|
|
4
|
+
# Modified in 2026. Licensed under Apache-2.0; see LICENSE.
|
|
5
|
+
require_relative "field"
|
|
6
|
+
require_relative "rule"
|
|
7
|
+
require_relative "data"
|
|
8
|
+
|
|
9
|
+
module TextFSM
|
|
10
|
+
class Parser
|
|
11
|
+
LINE_SEPARATOR = /\r\n|[\n\r\v\f\x1c-\x1e\u0085\u2028\u2029]/
|
|
12
|
+
private_constant :LINE_SEPARATOR
|
|
13
|
+
|
|
14
|
+
attr_reader :header, :states, :current_state
|
|
15
|
+
|
|
16
|
+
def initialize(template, options: {})
|
|
17
|
+
@states = {}
|
|
18
|
+
@fields = {}
|
|
19
|
+
@option_types = Options::BUILTINS.merge(options.transform_keys(&:to_s)).freeze
|
|
20
|
+
@option_types.each_value do |type|
|
|
21
|
+
raise ArgumentError, "options must inherit from TextFSM::Options::Base" unless type.is_a?(Class) && type < Options::Base
|
|
22
|
+
end
|
|
23
|
+
parse_template(read_template(template))
|
|
24
|
+
@fields.freeze
|
|
25
|
+
@states.each_value(&:freeze)
|
|
26
|
+
@states.freeze
|
|
27
|
+
@output_fields = @fields.each_value.select(&:visible?).freeze
|
|
28
|
+
@header = @output_fields.map(&:name).freeze
|
|
29
|
+
reset
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.from_file(path, **options)
|
|
33
|
+
new(File.read(path, encoding: "UTF-8"), **options)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def state_names
|
|
37
|
+
@states.keys
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def rows
|
|
41
|
+
Data.copy(@rows, immutable: true)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def to_a
|
|
45
|
+
Data.copy(@rows)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_hashes
|
|
49
|
+
to_a.map { |row| @header.zip(row).to_h }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def reset
|
|
53
|
+
@current_state = "Start"
|
|
54
|
+
@rows = []
|
|
55
|
+
@fields.each_value(&:reset)
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Calls accumulate state until reset. Chunks must end on a line boundary;
|
|
60
|
+
# eof: false defers the implicit final Record. Results are frozen snapshots.
|
|
61
|
+
def parse(text, eof: true)
|
|
62
|
+
consume_input(text, eof: eof)
|
|
63
|
+
rows
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def parse_hashes(text, eof: true)
|
|
67
|
+
consume_input(text, eof: eof)
|
|
68
|
+
to_hashes
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def fields_with_option(name)
|
|
72
|
+
name = name.to_s
|
|
73
|
+
raise ArgumentError, "Unknown option #{name.inspect}" unless @option_types.key?(name)
|
|
74
|
+
|
|
75
|
+
@fields.each_value.filter_map { |field| field.name if field.option?(name) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Option callbacks use the same visible-column mapping as record output.
|
|
79
|
+
def fill_up(field)
|
|
80
|
+
column = @output_fields.index(field)
|
|
81
|
+
return unless column
|
|
82
|
+
|
|
83
|
+
@rows.reverse_each do |row|
|
|
84
|
+
value = row[column]
|
|
85
|
+
break if value && !(value.respond_to?(:empty?) && value.empty?)
|
|
86
|
+
|
|
87
|
+
row[column] = Data.copy(field.value)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def to_s
|
|
92
|
+
output = "#{@fields.values.join("\n")}\n"
|
|
93
|
+
@states.each do |state, rules|
|
|
94
|
+
output << "\n#{state}\n"
|
|
95
|
+
rules.each do |rule|
|
|
96
|
+
output << "#{rule}\n"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
output
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def terminal?
|
|
105
|
+
@current_state == "End" || @current_state == "EOF"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def consume_input(text, eof:)
|
|
109
|
+
return if terminal?
|
|
110
|
+
|
|
111
|
+
text = text.read if text.respond_to?(:read)
|
|
112
|
+
raise ArgumentError, "text must be a String or readable IO" unless text.is_a?(String)
|
|
113
|
+
|
|
114
|
+
# StringScanner advances by bytes, including for multibyte input, without
|
|
115
|
+
# allocating all lines or repeatedly finding character offsets.
|
|
116
|
+
scanner = StringScanner.new(text)
|
|
117
|
+
while (line = scanner.scan_until(LINE_SEPARATOR))
|
|
118
|
+
process_line(line.byteslice(0, line.bytesize - scanner.matched_size))
|
|
119
|
+
break if terminal?
|
|
120
|
+
end
|
|
121
|
+
process_line(scanner.rest) unless terminal? || scanner.eos?
|
|
122
|
+
append_record if eof && @current_state != "End" && !@states.key?("EOF")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def read_template(template)
|
|
126
|
+
template = template.read if template.respond_to?(:read)
|
|
127
|
+
raise TemplateError, "Template must be a String or readable IO" unless template.is_a?(String)
|
|
128
|
+
|
|
129
|
+
template
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def parse_template(template)
|
|
133
|
+
reading_fields = true
|
|
134
|
+
state = nil
|
|
135
|
+
template.each_line.with_index(1) do |raw, line_number|
|
|
136
|
+
line = raw.rstrip
|
|
137
|
+
next if line.lstrip.start_with?("#")
|
|
138
|
+
|
|
139
|
+
if line.empty?
|
|
140
|
+
reading_fields = false
|
|
141
|
+
state = nil
|
|
142
|
+
elsif reading_fields
|
|
143
|
+
parse_field(line, line_number)
|
|
144
|
+
elsif state.nil?
|
|
145
|
+
state = parse_state(line, line_number)
|
|
146
|
+
else
|
|
147
|
+
raise TemplateError, "Missing whitespace or '^' before rule. Line: #{line_number}." unless line.start_with?(" ^", " ^", "\t^")
|
|
148
|
+
|
|
149
|
+
@states.fetch(state) << Rule.new(line, line_number: line_number, fields: @fields)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
validate_states
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def parse_field(line, line_number)
|
|
156
|
+
unless line.start_with?("Value ")
|
|
157
|
+
message = @fields.empty? ? "No Value definitions found" : "Expected blank line after last Value entry"
|
|
158
|
+
raise TemplateError, message
|
|
159
|
+
end
|
|
160
|
+
field = Field.new(line, parser: self, options: @option_types)
|
|
161
|
+
raise TemplateError, "Duplicate Value #{field.name.inspect}" if @fields.key?(field.name)
|
|
162
|
+
|
|
163
|
+
@fields[field.name] = field
|
|
164
|
+
rescue TemplateError => e
|
|
165
|
+
raise TemplateError, "#{e.message}. Line: #{line_number}."
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def parse_state(line, line_number)
|
|
169
|
+
if !line.match?(/\A[\p{L}\p{N}_]+\z/) || line.length > 48 || Rule::OPERATIONS.include?(line)
|
|
170
|
+
raise TemplateError, "Invalid state name #{line.inspect}. Line: #{line_number}."
|
|
171
|
+
end
|
|
172
|
+
raise TemplateError, "Duplicate state #{line.inspect}. Line: #{line_number}." if @states.key?(line)
|
|
173
|
+
|
|
174
|
+
@states[line.freeze] = []
|
|
175
|
+
line
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def validate_states
|
|
179
|
+
raise TemplateError, "Missing state 'Start'." unless @states.key?("Start")
|
|
180
|
+
|
|
181
|
+
%w[End EOF].each do |state|
|
|
182
|
+
raise TemplateError, "Non-empty '#{state}' state." if @states.key?(state) && !@states[state].empty?
|
|
183
|
+
end
|
|
184
|
+
@states.delete("End")
|
|
185
|
+
@states.each do |state, rules|
|
|
186
|
+
rules.each do |rule|
|
|
187
|
+
next if rule.line_action == :error || rule.next_state.nil? || %w[End EOF].include?(rule.next_state)
|
|
188
|
+
next if @states.key?(rule.next_state)
|
|
189
|
+
|
|
190
|
+
raise TemplateError, "State #{rule.next_state.inspect} not found, referenced in #{state.inspect}. Line: #{rule.line_number}."
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def process_line(line)
|
|
196
|
+
@states.fetch(@current_state).each do |rule|
|
|
197
|
+
match = rule.pattern.match(line)
|
|
198
|
+
next unless match
|
|
199
|
+
|
|
200
|
+
rule.pattern.named_captures(match).each do |name, value|
|
|
201
|
+
@fields[name]&.assign(value)
|
|
202
|
+
end
|
|
203
|
+
case rule.record_action
|
|
204
|
+
when :record
|
|
205
|
+
append_record
|
|
206
|
+
when :clear
|
|
207
|
+
@fields.each_value(&:clear)
|
|
208
|
+
when :clear_all
|
|
209
|
+
@fields.each_value(&:reset)
|
|
210
|
+
end
|
|
211
|
+
if rule.line_action == :error
|
|
212
|
+
message = rule.error_message ? "Error: #{rule.error_message}" : "State Error raised"
|
|
213
|
+
raise ParseError, "#{message}. Rule Line: #{rule.line_number}. Input Line: #{line}."
|
|
214
|
+
end
|
|
215
|
+
next if rule.line_action == :continue
|
|
216
|
+
|
|
217
|
+
@current_state = rule.next_state if rule.next_state
|
|
218
|
+
break
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def append_record
|
|
223
|
+
catch(:skip_record) do
|
|
224
|
+
@fields.each_value(&:prepare_record)
|
|
225
|
+
record = @output_fields.map(&:value)
|
|
226
|
+
unless record.all? { |value| value.nil? || value == [] }
|
|
227
|
+
@rows << Data.copy(record.map { |value| value.nil? ? "" : value })
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
@fields.each_value(&:clear)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
|
|
6
|
+
module TextFSM
|
|
7
|
+
# Compile Python-style template patterns into immutable Ruby matchers.
|
|
8
|
+
class Pattern
|
|
9
|
+
attr_reader :source, :regexp, :names
|
|
10
|
+
|
|
11
|
+
def initialize(source)
|
|
12
|
+
raise ArgumentError, "pattern must be a String" unless source.is_a?(String)
|
|
13
|
+
|
|
14
|
+
@source = source.dup.freeze
|
|
15
|
+
expression, @group_names = Compiler.new(@source).compile
|
|
16
|
+
@regexp = Regexp.new("\\A(?:#{expression})").freeze
|
|
17
|
+
@names = @group_names.keys.freeze
|
|
18
|
+
freeze
|
|
19
|
+
rescue RegexpError, ArgumentError, RangeError => e
|
|
20
|
+
raise TemplateError, "Invalid regular expression #{source.inspect}: #{e.message}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def match(text)
|
|
24
|
+
@regexp.match(text)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def match?(text)
|
|
28
|
+
@regexp.match?(text)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def named_captures(match)
|
|
32
|
+
@group_names.transform_values { |internal_name| match[internal_name] }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Ruby drops unnamed captures when named captures are present. Giving every
|
|
36
|
+
# capture an internal name preserves Python's numeric backreferences.
|
|
37
|
+
class Compiler
|
|
38
|
+
ASSERTIONS = %w[A Z z b B].freeze
|
|
39
|
+
CHARACTER_CLASSES = { "d" => "\\p{Nd}", "w" => "\\p{L}\\p{N}_", "s" => "\\p{Space}\\x1c-\\x1f" }.freeze
|
|
40
|
+
FLAG_NAMES = { "m" => :multiline, "s" => :dotall, "x" => :extended, "a" => :ascii }.freeze
|
|
41
|
+
|
|
42
|
+
def initialize(source)
|
|
43
|
+
@scanner = StringScanner.new(source)
|
|
44
|
+
@output = []
|
|
45
|
+
@group_names = {}
|
|
46
|
+
@captures = []
|
|
47
|
+
@groups = []
|
|
48
|
+
@flags = { multiline: false, dotall: false, extended: false, ascii: false }
|
|
49
|
+
@has_expression = false
|
|
50
|
+
@atom_start = nil
|
|
51
|
+
@repeatable = false
|
|
52
|
+
@quantified = false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def compile
|
|
56
|
+
until @scanner.eos?
|
|
57
|
+
character = @scanner.getch
|
|
58
|
+
next if @flags[:extended] && character.match?(/[ \t\n\r\f\v]/)
|
|
59
|
+
|
|
60
|
+
compile_token(character)
|
|
61
|
+
end
|
|
62
|
+
raise ArgumentError, "unbalanced parenthesis" unless @groups.empty?
|
|
63
|
+
|
|
64
|
+
[@output.join, @group_names.freeze]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def compile_token(character)
|
|
70
|
+
case character
|
|
71
|
+
when "\\"
|
|
72
|
+
assertion = ASSERTIONS.include?(@scanner.peek(1))
|
|
73
|
+
emit_atom(escape(in_class: false), repeatable: !assertion)
|
|
74
|
+
when "[" then emit_atom(character_class)
|
|
75
|
+
when "(" then open_group
|
|
76
|
+
when ")" then close_group
|
|
77
|
+
when "^", "$" then emit_atom(anchor(character), repeatable: false)
|
|
78
|
+
when "." then emit_atom(@flags[:dotall] ? "(?m:.)" : ".")
|
|
79
|
+
when "|"
|
|
80
|
+
@output << character
|
|
81
|
+
@atom_start = nil
|
|
82
|
+
@has_expression = true
|
|
83
|
+
when "*", "+", "?" then quantifier(character)
|
|
84
|
+
when "{"
|
|
85
|
+
count = @scanner.scan(/(?:\d+(?:,\d*)?|,\d*)\}/)
|
|
86
|
+
count ? quantifier("{#{count}") : emit_atom("\\{")
|
|
87
|
+
when "#"
|
|
88
|
+
@flags[:extended] ? @scanner.skip(/[^\n]*/) : emit_atom("\\#")
|
|
89
|
+
else emit_atom(Regexp.escape(character))
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def emit_atom(expression, repeatable: true)
|
|
94
|
+
@atom_start = @output.length
|
|
95
|
+
@repeatable = repeatable
|
|
96
|
+
@quantified = false
|
|
97
|
+
@has_expression = true
|
|
98
|
+
@output << expression
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def anchor(character)
|
|
102
|
+
return character if @flags[:multiline]
|
|
103
|
+
|
|
104
|
+
character == "^" ? "\\A" : "\\Z"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def quantifier(expression)
|
|
108
|
+
raise ArgumentError, "nothing to repeat" unless @atom_start && @repeatable
|
|
109
|
+
raise ArgumentError, "multiple repeat" if @quantified
|
|
110
|
+
|
|
111
|
+
expression = counted_quantifier(expression) if expression.start_with?("{")
|
|
112
|
+
modifier = @scanner.scan(/[?+]/)
|
|
113
|
+
if modifier == "+"
|
|
114
|
+
atom = @output.slice!(@atom_start..).join
|
|
115
|
+
@output << "(?>#{atom}#{expression})"
|
|
116
|
+
else
|
|
117
|
+
@output << expression
|
|
118
|
+
@output << modifier if modifier
|
|
119
|
+
end
|
|
120
|
+
@quantified = true
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def counted_quantifier(expression)
|
|
124
|
+
minimum, maximum = expression[1...-1].split(",", -1)
|
|
125
|
+
minimum = minimum.empty? ? 0 : Integer(minimum, 10)
|
|
126
|
+
maximum = if maximum.nil?
|
|
127
|
+
minimum
|
|
128
|
+
elsif !maximum.empty?
|
|
129
|
+
Integer(maximum, 10)
|
|
130
|
+
end
|
|
131
|
+
raise ArgumentError, "minimum repeat exceeds maximum" if maximum && minimum > maximum
|
|
132
|
+
|
|
133
|
+
# Ruby treats {n}? as an optional n-character block. An explicit range
|
|
134
|
+
# retains Python's required count when the lazy suffix is appended.
|
|
135
|
+
"{#{minimum},#{maximum}}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def escape(in_class:)
|
|
139
|
+
character = @scanner.getch
|
|
140
|
+
raise ArgumentError, "trailing backslash" unless character
|
|
141
|
+
|
|
142
|
+
return octal(character + @scanner.scan(/[0-7]{0,2}/)) if character == "0" || (in_class && character.match?(/[0-7]/))
|
|
143
|
+
raise ArgumentError, "invalid character class escape \\#{character}" if in_class && %w[A B Z z 8 9].include?(character)
|
|
144
|
+
return numeric_reference(character) if !in_class && character.match?(/[1-9]/)
|
|
145
|
+
return "\\z" if !in_class && %w[Z z].include?(character)
|
|
146
|
+
return word_boundary(character) if !in_class && %w[b B].include?(character)
|
|
147
|
+
return unicode_escape(character) if %w[x u U].include?(character)
|
|
148
|
+
|
|
149
|
+
character_class_escape(character, in_class: in_class) || literal_escape(character)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def character_class_escape(character, in_class:)
|
|
153
|
+
return if @flags[:ascii]
|
|
154
|
+
|
|
155
|
+
if (body = CHARACTER_CLASSES[character.downcase])
|
|
156
|
+
return body if in_class && character == character.downcase
|
|
157
|
+
|
|
158
|
+
"[#{'^' if character == character.upcase}#{body}]"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def literal_escape(character)
|
|
163
|
+
raise ArgumentError, "\\N is not supported; use literal Unicode characters" if character == "N"
|
|
164
|
+
if character.match?(/[A-Za-z]/) && !"AbBdDsSwWZzfnrtvuxUa".include?(character)
|
|
165
|
+
raise ArgumentError, "unsupported escape \\#{character}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
"\\#{character}"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def numeric_reference(character)
|
|
172
|
+
digits = character + @scanner.scan(/[0-9]?/)
|
|
173
|
+
if digits.match?(/\A[0-7]{2}\z/) && (last_digit = @scanner.scan(/[0-7]/))
|
|
174
|
+
return octal(digits + last_digit)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
internal_name = @captures[digits.to_i - 1]
|
|
178
|
+
raise ArgumentError, "invalid group reference #{digits}" unless internal_name
|
|
179
|
+
|
|
180
|
+
backreference(internal_name)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def backreference(internal_name)
|
|
184
|
+
raise ArgumentError, "cannot refer to an open group" if @groups.any? { |group| group[:capture] == internal_name }
|
|
185
|
+
|
|
186
|
+
"\\k<#{internal_name}>"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def word_boundary(character)
|
|
190
|
+
word = @flags[:ascii] ? "[a-zA-Z0-9_]" : "[\\p{L}\\p{N}_]"
|
|
191
|
+
boundary = "(?:(?<!#{word})(?=#{word})|(?<=#{word})(?!#{word}))"
|
|
192
|
+
character == "b" ? boundary : "(?!#{boundary})"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def unicode_escape(character)
|
|
196
|
+
length = { "x" => 2, "u" => 4, "U" => 8 }.fetch(character)
|
|
197
|
+
digits = @scanner.scan(/[0-9a-fA-F]{#{length}}/)
|
|
198
|
+
raise ArgumentError, "incomplete Unicode or hexadecimal escape" unless digits
|
|
199
|
+
|
|
200
|
+
Regexp.escape(digits.to_i(16).chr(Encoding::UTF_8))
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def octal(digits)
|
|
204
|
+
codepoint = digits.to_i(8)
|
|
205
|
+
raise ArgumentError, "octal escape outside range 0-0o377" if codepoint > 255
|
|
206
|
+
|
|
207
|
+
Regexp.escape(codepoint.chr(Encoding::UTF_8))
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def character_class
|
|
211
|
+
expression = +"["
|
|
212
|
+
expression << @scanner.getch if @scanner.peek(1) == "^"
|
|
213
|
+
expression << "\\]" if @scanner.scan(/\]/)
|
|
214
|
+
until @scanner.eos?
|
|
215
|
+
character = @scanner.getch
|
|
216
|
+
return "#{expression}]" if character == "]"
|
|
217
|
+
|
|
218
|
+
expression << case character
|
|
219
|
+
when "\\" then escape(in_class: true)
|
|
220
|
+
when "[" then "\\["
|
|
221
|
+
when "&" then "\\&"
|
|
222
|
+
else character
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
raise ArgumentError, "unterminated character class"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def open_group
|
|
229
|
+
return capture unless @scanner.scan(/\?/)
|
|
230
|
+
|
|
231
|
+
if @scanner.scan(/P<([^>]+)>/)
|
|
232
|
+
name = @scanner[1]
|
|
233
|
+
unless name.match?(/\A[\p{L}_][\p{L}\p{N}_]*\z/) && !@group_names.key?(name)
|
|
234
|
+
raise ArgumentError, "invalid or duplicate group name #{name.inspect}"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
return capture(name.freeze)
|
|
238
|
+
end
|
|
239
|
+
if @scanner.scan(/P=([^)]*)\)/)
|
|
240
|
+
name = @scanner[1]
|
|
241
|
+
internal_name = @group_names[name]
|
|
242
|
+
raise ArgumentError, "unknown group name #{name.inspect}" unless internal_name
|
|
243
|
+
|
|
244
|
+
return emit_atom(backreference(internal_name))
|
|
245
|
+
end
|
|
246
|
+
return if @scanner.skip(/#(?:\\.|[^)])*\)/)
|
|
247
|
+
return inline_flags if @scanner.scan(/([aimsux]*)(?:-([imsx]+))?([:)])/)
|
|
248
|
+
if (extension = @scanner.scan(/<=|<!|=|!|>/))
|
|
249
|
+
return push_group("(?#{extension}")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
raise ArgumentError, "unsupported group extension at byte offset #{@scanner.pos - 2}"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def capture(name = nil)
|
|
256
|
+
internal_name = "tfsm_capture_#{@captures.length + 1}".freeze
|
|
257
|
+
@captures << internal_name
|
|
258
|
+
@group_names[name] = internal_name if name
|
|
259
|
+
push_group("(?<#{internal_name}>", capture: internal_name)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def push_group(expression, capture: nil)
|
|
263
|
+
@groups << { flags: @flags.dup, start: @output.length, capture: capture }
|
|
264
|
+
@output << expression
|
|
265
|
+
@atom_start = nil
|
|
266
|
+
@has_expression = true
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def close_group
|
|
270
|
+
group = @groups.pop
|
|
271
|
+
raise ArgumentError, "unbalanced parenthesis" unless group
|
|
272
|
+
|
|
273
|
+
@flags = group[:flags]
|
|
274
|
+
@output << ")"
|
|
275
|
+
@atom_start = group[:start]
|
|
276
|
+
@repeatable = true
|
|
277
|
+
@quantified = false
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def inline_flags
|
|
281
|
+
enabled = @scanner[1]
|
|
282
|
+
disabled = @scanner[2]
|
|
283
|
+
ending = @scanner[3]
|
|
284
|
+
validate_flags(enabled, disabled, ending)
|
|
285
|
+
|
|
286
|
+
ruby_on = enabled.delete("amsu")
|
|
287
|
+
ruby_off = disabled.to_s.delete("ms")
|
|
288
|
+
flags = ruby_on + (ruby_off.empty? ? "" : "-#{ruby_off}")
|
|
289
|
+
if ending == ":"
|
|
290
|
+
push_group("(?#{flags}:")
|
|
291
|
+
elsif !flags.empty?
|
|
292
|
+
@output << "(?#{flags})"
|
|
293
|
+
end
|
|
294
|
+
enabled.each_char do |flag|
|
|
295
|
+
@flags[FLAG_NAMES[flag]] = true if FLAG_NAMES.key?(flag)
|
|
296
|
+
end
|
|
297
|
+
disabled.to_s.each_char do |flag|
|
|
298
|
+
@flags[FLAG_NAMES[flag]] = false if FLAG_NAMES.key?(flag)
|
|
299
|
+
end
|
|
300
|
+
@flags[:ascii] = false if enabled.include?("u")
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def validate_flags(enabled, disabled, ending)
|
|
304
|
+
if (enabled.include?("a") && enabled.include?("u")) || enabled.each_char.any? { |flag| disabled.to_s.include?(flag) }
|
|
305
|
+
raise ArgumentError, "incompatible inline flags"
|
|
306
|
+
end
|
|
307
|
+
return unless ending == ")"
|
|
308
|
+
|
|
309
|
+
raise ArgumentError, "global flags must occur at the start of the expression" if @has_expression || enabled.empty? || disabled
|
|
310
|
+
|
|
311
|
+
character_mode = enabled[/[au]/]
|
|
312
|
+
return unless character_mode
|
|
313
|
+
|
|
314
|
+
raise ArgumentError, "incompatible global character flags" if @global_character_mode && @global_character_mode != character_mode
|
|
315
|
+
|
|
316
|
+
@global_character_mode = character_mode
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
private_constant :Compiler
|
|
321
|
+
end
|
|
322
|
+
end
|