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.
@@ -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