ctx_parser 0.1.1

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,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CtxParser
4
+ # 配置节点覆盖整个子树,保留未知命令、空白及原始换行。
5
+ class Block
6
+ Draft = Struct.new(:header, :children, :last_index)
7
+ private_constant :Draft
8
+
9
+ attr_reader :cursor, :header, :children, :last_index
10
+
11
+ # 冻结已校验的来源范围及其子配置块。
12
+ def initialize(cursor:, header:, children:, last_index:)
13
+ unless cursor.lines[header.index].equal?(header) && last_index.is_a?(Integer) &&
14
+ (header.index...cursor.size).cover?(last_index)
15
+ raise ArgumentError, "Invalid source block range"
16
+ end
17
+
18
+ @cursor, @header, @last_index = cursor, header, last_index
19
+ @children = children.dup.freeze
20
+ freeze
21
+ end
22
+
23
+ # 构建缩进结构,同时由调用方根据当前词法状态分类来源行。
24
+ def self.read(cursor, comments: nil, boundaries: nil)
25
+ roots, stack = [], []
26
+ while (line = cursor.read)
27
+ disposition = if block_given?
28
+ yield line
29
+ elsif boundaries&.match?(line.text)
30
+ :boundary
31
+ elsif line.text.empty? || comments&.match?(line.text)
32
+ :ignored
33
+ else
34
+ :entry
35
+ end
36
+ case disposition
37
+ when :boundary
38
+ stack.pop.last_index = line.index - 1 until stack.empty?
39
+ cursor.mark(line.index, state: :ignored)
40
+ when :ignored
41
+ cursor.mark(line.index, state: :ignored)
42
+ when :entry
43
+ while stack.any? && line.indent <= stack.last.header.indent
44
+ stack.pop.last_index = line.index - 1
45
+ end
46
+ draft = Draft.new(line, [], nil)
47
+ (stack.last&.children || roots) << draft
48
+ stack << draft
49
+ else
50
+ raise ArgumentError, "Invalid source line disposition"
51
+ end
52
+ end
53
+ stack.pop.last_index = cursor.position - 1 until stack.empty?
54
+ roots.map { |draft| build(cursor, draft) }.freeze
55
+ end
56
+
57
+ # 迭代冻结配置块树,不依赖 Ruby 递归深度。
58
+ def self.build(cursor, draft)
59
+ # 使用显式栈先构建子节点再构建父节点,保留顺序并避免递归栈增长。
60
+ stack = [ [ draft, 0, [] ] ]
61
+ until stack.empty?
62
+ entry, next_child, children = stack.last
63
+ if (child = entry.children[next_child])
64
+ stack.last[1] += 1
65
+ stack << [ child, 0, [] ]
66
+ else
67
+ block = new(cursor: cursor, header: entry.header, last_index: entry.last_index, children: children)
68
+ stack.pop
69
+ return block if stack.empty?
70
+
71
+ stack.last[2] << block
72
+ end
73
+ end
74
+ end
75
+ private_class_method :build
76
+
77
+ # 返回配置块标题的零起始索引。
78
+ def index = header.index
79
+ # 返回配置块标题的一起始来源行号。
80
+ def line = header.number
81
+ # 返回已去除外围缩进和行结束符的来源内容。
82
+ def text = header.text
83
+ # 返回配置块或捕获事实覆盖的闭合来源区间。
84
+ def line_range = line..(last_index + 1)
85
+ # 返回属于当前配置块的原始来源行。
86
+ def lines = cursor.lines[index..last_index].freeze
87
+ # 返回配置块或捕获事实对应的精确来源文本。
88
+ def content = lines.map(&:raw).join.freeze
89
+ # 返回当前配置块各来源行的处理状态。
90
+ def states = lines.map { |source| cursor.state(source.index) }.freeze
91
+ # 要求块内所有来源行均已解析或明确忽略。
92
+ def complete? = states.all? { |state| Cursor::RESOLVED_STATES.include?(state) }
93
+ # 序列化诊断元数据,不包含原始来源文本。
94
+ def as_json(*) = { first_line: line, last_line: last_index + 1, children: children.size }
95
+ # 展示对象身份与状态,避免泄露原始回显或凭据。
96
+ def inspect = "#<#{self.class.name} lines=#{line_range} children=#{children.size}>"
97
+ alias to_s inspect
98
+ end
99
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "block"
4
+ require_relative "immutable"
5
+
6
+ module CtxParser
7
+ # 保存不可变事实及其来源;路径相对于首条配置结果记录。
8
+ class Capture
9
+ Snapshot = Data.define(:data, :path) do
10
+ # 此边界仅接收调用方原始数据,各快照自行持有深度冻结的副本。
11
+ def initialize(data:, path:)
12
+ unless path.is_a?(Array) && path.all? { |key| key.nil? || key.is_a?(Symbol) || key.is_a?(String) || key.is_a?(Integer) }
13
+ raise ArgumentError, "Capture path must contain record keys or indexes"
14
+ end
15
+
16
+ super(data: Immutable.copy(data), path: Immutable.copy(path))
17
+ end
18
+ end
19
+ private_constant :Snapshot
20
+
21
+ attr_reader :block, :data, :path
22
+
23
+ # 冻结解析事实及其来源块和结果路径。
24
+ def initialize(block:, data:, path:)
25
+ raise ArgumentError, "Capture source must be a block" unless block.is_a?(Block)
26
+
27
+ initialize_snapshot(block, Snapshot.new(data: data, path: path))
28
+ end
29
+
30
+ # 同一事实的多个来源共享已复制的值,不共享未经校验的调用方容器。
31
+ def self.for_blocks(blocks, data:, path:)
32
+ unless blocks.is_a?(Array) && blocks.all? { |block| block.is_a?(Block) }
33
+ raise ArgumentError, "Capture sources must be blocks"
34
+ end
35
+ return [].freeze if blocks.empty?
36
+
37
+ snapshot = Snapshot.new(data: data, path: path)
38
+ blocks.map { |block| allocate.tap { |capture| capture.send(:initialize_snapshot, block, snapshot) } }.freeze
39
+ end
40
+
41
+ # 返回配置块或捕获事实对应的精确来源文本。
42
+ def content = block.content
43
+ # 返回配置块或捕获事实覆盖的闭合来源区间。
44
+ def line_range = block.line_range
45
+ # 判断捕获事实依赖的所有来源行是否均已解决。
46
+ def complete? = block.complete?
47
+ # 序列化诊断元数据,不包含原始来源文本。
48
+ def as_json(*) = { first_line: line_range.begin, last_line: line_range.end, complete: complete? }
49
+ # 展示对象身份与状态,避免泄露原始回显或凭据。
50
+ def inspect = "#<#{self.class.name} lines=#{line_range}>"
51
+ alias to_s inspect
52
+
53
+ private
54
+
55
+ # 私有工厂仅接收由本类完成复制及校验的快照。
56
+ def initialize_snapshot(block, snapshot)
57
+ @block, @data, @path = block, snapshot.data, snapshot.path
58
+ freeze
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parser"
4
+
5
+ module CtxParser
6
+ # 每条命令仅声明一次,并派生不可变维度及解析器索引。
7
+ class CommandCatalog
8
+ attr_reader :entries, :commands, :parsers
9
+
10
+ # 校验并冻结受支持的维度到命令声明。
11
+ def initialize(entries, multi_command: false)
12
+ unless entries.is_a?(Array) && entries.any?
13
+ raise ArgumentError, "Command catalog must contain declarations"
14
+ end
15
+ raise ArgumentError, "Multi-command selection must be boolean" unless multi_command == true || multi_command == false
16
+
17
+ @multi_command = multi_command
18
+ @commands, @parsers = {}, {}
19
+ @entries = entries.map { |entry| register(entry) }.freeze
20
+ @commands.each_value { |value| value.freeze }
21
+ @commands.freeze
22
+ @parsers.freeze
23
+ freeze
24
+ end
25
+
26
+ private
27
+
28
+ # 仅复制声明数据;解析器类保留正常 Ruby 生命周期。
29
+ def register(entry)
30
+ unless entry.is_a?(Hash) && entry.size == 3 && %i[dimensions command parser].all? { |key| entry.key?(key) }
31
+ raise ArgumentError, "Command declaration requires dimensions, command and parser"
32
+ end
33
+
34
+ dimensions, command, parser = entry.values_at(:dimensions, :command, :parser)
35
+ unless dimensions.is_a?(Array) && dimensions.any? && dimensions.all? { |name| name.is_a?(Symbol) && !name.empty? } &&
36
+ dimensions.uniq == dimensions && (@multi_command || dimensions.none? { |name| @commands.key?(name) })
37
+ raise ArgumentError, "Command dimensions must be unique nonempty symbols"
38
+ end
39
+ unless command.is_a?(String) && !command.strip.empty? && !/[\x00-\x1f\x7f]/.match?(command)
40
+ raise ArgumentError, "Command must be nonempty text without control characters"
41
+ end
42
+ raise ArgumentError, "Command is already declared" if @parsers.key?(command)
43
+ raise ArgumentError, "Command parser must inherit from Parser" unless parser.is_a?(Class) && parser < Parser
44
+
45
+ command = command.dup.freeze
46
+ dimensions = dimensions.dup.freeze
47
+ dimensions.each do |name|
48
+ @commands[name] = @commands.key?(name) ? Array(@commands[name]) + [ command ] : command
49
+ end
50
+ @parsers[command] = parser
51
+ { dimensions: dimensions, command: command, parser: parser }.freeze
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parser"
4
+
5
+ module CtxParser
6
+ # 共享来源状态与作用域管理;具体解析器负责识别规则及诊断。
7
+ class ConfigurationParser < Parser
8
+ private
9
+
10
+ # 为新的配置解析建立来源跟踪状态。
11
+ def read_configuration(...)
12
+ @source_path = []
13
+ @invalid_source = @source_error = nil
14
+ super
15
+ end
16
+
17
+ # 仅在解释成功后标记来源行;嵌套转换失败归因到最内层来源。
18
+ def analyze(block)
19
+ value = yield
20
+ cursor.mark(block.index, state: :parsed) if cursor.state(block.index) == :pending
21
+ if cursor.state(block.index) == :parsed
22
+ block.children.each { |child| unparsed_child(child) if cursor.state(child.index) == :pending }
23
+ end
24
+ value
25
+ rescue ArgumentError => error
26
+ # 同一异常向外传播时仅保留最内层出错块,避免把整个父配置误标为无效。
27
+ unless @source_error.equal?(error)
28
+ @source_error, @invalid_source = error, block
29
+ end
30
+ if block.equal?(@invalid_source)
31
+ cursor.mark(block.index, state: :invalid)
32
+ elsif cursor.state(block.index) == :pending
33
+ cursor.mark(block.index, state: :parsed)
34
+ end
35
+ raise
36
+ end
37
+
38
+ # 各子节点独立保留来源状态及错误位置。
39
+ def analyze_children(block, &operation)
40
+ block.children.each { |child| analyze(child) { operation.call(child) } }
41
+ end
42
+
43
+ # 进入临时结果路径,成功或失败后均恢复父作用域。
44
+ def scope(block, path)
45
+ previous_path = @source_path
46
+ @source_path = path
47
+ value = yield
48
+ capture(block, value, path)
49
+ value
50
+ ensure
51
+ @source_path = previous_path
52
+ end
53
+
54
+ # 将归一化事实关联到来源块及结果路径。
55
+ def capture(block, value, path)
56
+ cursor.capture(block, data: value, path: path)
57
+ end
58
+
59
+ # 迭代忽略领域外子树,不宣称其产生已解析事实。
60
+ def ignore(block)
61
+ pending = [ block ]
62
+ until pending.empty?
63
+ entry = pending.pop
64
+ cursor.mark(entry.index, state: :ignored)
65
+ pending.concat(entry.children)
66
+ end
67
+ nil
68
+ end
69
+
70
+ # 未处理的子节点保持待解析;具体解析器可诊断位置错误的相关命令。
71
+ def unparsed_child(_block); end
72
+ end
73
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "line"
5
+ require_relative "capture"
6
+ require_relative "input"
7
+
8
+ module CtxParser
9
+ # 每次解析独立持有游标,区分读取位置、访问状态与语义状态。
10
+ class Cursor
11
+ STATES = %i[pending parsed ignored unsupported invalid].freeze
12
+ RESOLVED_STATES = %i[parsed ignored].freeze
13
+
14
+ attr_reader :content, :lines, :checksum, :position
15
+
16
+ # 为单次解析创建独立的来源行、语义状态及事实索引。
17
+ def initialize(content)
18
+ raise ArgumentError, "Configuration content must be text" unless content.is_a?(String)
19
+
20
+ @content = Input.text(content, encoding: :transcode).freeze
21
+
22
+ @lines = @content.scan(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+\z/).each_with_index.map do |raw, index|
23
+ Line.new(index: index, raw: raw)
24
+ end.freeze
25
+ @checksum = Digest::SHA256.hexdigest(@content).freeze
26
+ reset!
27
+ end
28
+
29
+ # 返回原始来源行数。
30
+ def size = lines.size
31
+ # 判断游标是否到达来源末尾。
32
+ def eof? = position >= size
33
+ # 查看下一来源行,不推进游标也不修改状态。
34
+ def peek = lines[position]
35
+ # 返回来源行处理状态的不可变快照。
36
+ def states = @states.dup.freeze
37
+ # 提供已成功解释的来源行信息。
38
+ def parsed_flags = @states.map { |state| state == :parsed }.freeze
39
+ # 即使采用后序分析,也按文档顺序返回来源,并保留同一行的捕获顺序。
40
+ def captures = @ordered_captures ||= ordered_captures(@captures)
41
+
42
+ # 推进游标并记录访问,不代表该行已成功解析。
43
+ def read
44
+ return nil if eof?
45
+
46
+ line = peek
47
+ @visited[position] = true
48
+ @position += 1
49
+ line
50
+ end
51
+
52
+ # 查找下一待处理行;语义成功或失败仍须由调用方显式记录。
53
+ def read_pending
54
+ while (line = read)
55
+ return line if state(line.index) == :pending
56
+ end
57
+ nil
58
+ end
59
+
60
+ # 校验来源行索引后,返回对应语义处理状态。
61
+ def state(index)
62
+ validate_index!(index)
63
+ @states[index]
64
+ end
65
+
66
+ # 检查是否解释成功;无效行索引保留 nil。
67
+ def parsed?(index) = valid_index?(index) ? @states[index] == :parsed : nil
68
+ # 独立检查游标是否访问过该行,不与解释成功混淆。
69
+ def visited?(index) = valid_index?(index) ? @visited[index] : nil
70
+
71
+ # 为有效来源行记录明确的语义状态。
72
+ def mark(index, state: :parsed)
73
+ validate_index!(index)
74
+ raise ArgumentError, "Invalid line state" unless STATES.include?(state)
75
+
76
+ @states[index] = state
77
+ self
78
+ end
79
+
80
+ # 返回起点开始下一轮处理,同时保留语义状态与来源证据。
81
+ def rewind
82
+ @position = 0
83
+ self
84
+ end
85
+
86
+ # 清空访问、语义状态及捕获事实,重新开始分析。
87
+ def reset!
88
+ @states = Array.new(size, :pending)
89
+ @visited = Array.new(size, false)
90
+ @captures = []
91
+ @captures_by_path, @ordered_by_path = {}, {}
92
+ @ordered_captures = nil
93
+ rewind
94
+ end
95
+
96
+ # 回退一行,不改变语义状态。
97
+ def step_back
98
+ return false if position.zero?
99
+
100
+ @position -= 1
101
+ true
102
+ end
103
+
104
+ # 重新访问前一行,并将其恢复为待解析状态。
105
+ def unread
106
+ return false unless step_back
107
+
108
+ mark(position, state: :pending)
109
+ true
110
+ end
111
+
112
+ # 将最后消费的行恢复为待解析,不移动游标。
113
+ def defer
114
+ return false if position.zero?
115
+
116
+ mark(position - 1, state: :pending)
117
+ true
118
+ end
119
+
120
+ # 返回未解决或无效行,排除已明确忽略的注释及领域外配置。
121
+ def unparsed_lines
122
+ lines.reject { |line| RESOLVED_STATES.include?(state(line.index)) }.freeze
123
+ end
124
+
125
+ # 拼接未解决来源行,并保留原有格式。
126
+ def unparsed_content = unparsed_lines.map(&:raw).join.freeze
127
+
128
+ # 将事实快照关联到来源块,不将整个块标记为解析成功。
129
+ def capture(block, data:, path:)
130
+ capture_many([ block ], data: data, path: path).first
131
+ end
132
+
133
+ # 注册归纳事实的不可变快照前,先校验全部来源。
134
+ def capture_many(blocks, data:, path:)
135
+ unless blocks.is_a?(Array) && blocks.all? { |block| block.is_a?(Block) && block.cursor.equal?(self) }
136
+ raise ArgumentError, "Blocks must belong to this cursor"
137
+ end
138
+
139
+ captured = Capture.for_blocks(blocks, data: data, path: path)
140
+ captured.each do |capture|
141
+ @captures << capture
142
+ (@captures_by_path[capture.path] ||= []) << capture
143
+ @ordered_by_path.delete(capture.path)
144
+ end
145
+ @ordered_captures = nil unless captured.empty?
146
+ captured
147
+ end
148
+
149
+ # 使用捕获时的不可变路径作为缓存键,不使用调用方可变查询数组。
150
+ def captures_for(path)
151
+ return @ordered_by_path[path] if @ordered_by_path.key?(path)
152
+
153
+ entries = @captures_by_path[path]
154
+ entries ? @ordered_by_path[entries.first.path] = ordered_captures(entries) : [].freeze
155
+ end
156
+ # 序列化诊断元数据,不包含原始来源文本。
157
+ def as_json(*) = { position: position, lines: size, states: @states.tally, captures: @captures.size }
158
+ # 展示对象身份与状态,避免泄露原始回显或凭据。
159
+ def inspect = "#<#{self.class.name} position=#{position} lines=#{size} captures=#{@captures.size}>"
160
+ alias to_s inspect
161
+
162
+ private
163
+
164
+ # 同一来源行保持稳定插入顺序,以维持既有来源追溯契约。
165
+ def ordered_captures(entries)
166
+ entries.each_with_index.sort_by { |capture, order| [ capture.block.index, order ] }.map(&:first).freeze
167
+ end
168
+
169
+ # 检查整数索引是否属于当前来源文档。
170
+ def valid_index?(index) = index.is_a?(Integer) && index >= 0 && index < size
171
+
172
+ # 拒绝指向当前文档之外的行引用。
173
+ def validate_index!(index)
174
+ raise IndexError, "Line index is outside the document" unless valid_index?(index)
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cursor"
4
+ require_relative "block"
5
+
6
+ module CtxParser
7
+ # 持有配置树及诊断;分类器负责语法,Block 负责缩进范围。
8
+ class Document
9
+ attr_reader :cursor, :roots, :issues
10
+
11
+ # 使用平台提供的注释及边界规则构建来源文档。
12
+ def initialize(source, comments: nil, boundaries: nil)
13
+ @cursor = source.is_a?(Cursor) ? source : Cursor.new(source)
14
+ @comments, @boundaries, @issues = comments, boundaries, []
15
+ @roots = Block.read(cursor) { |line| classify(line) }
16
+ finish
17
+ @issues.freeze
18
+ end
19
+
20
+ # 选择符合目标命令语法的根配置块。
21
+ def sections(pattern) = roots.select { |entry| pattern.match?(entry.text) }
22
+ # 序列化诊断元数据,不包含原始来源文本。
23
+ def as_json(*) = { sections: roots.size, issues: issues.size, cursor: cursor.as_json }
24
+ # 展示对象身份与状态,避免泄露原始回显或凭据。
25
+ def inspect = "#<#{self.class.name} sections=#{roots.size} issues=#{issues.size}>"
26
+ alias to_s inspect
27
+
28
+ protected
29
+
30
+ # 允许有状态词法分析器将行分类为配置条目、忽略行或边界。
31
+ def classify(line)
32
+ return :boundary if @boundaries&.match?(line.text)
33
+ return :ignored if line.text.empty? || @comments&.match?(line.text)
34
+
35
+ :entry
36
+ end
37
+
38
+ # 允许词法分析器在文档结束时报告未闭合上下文,不向公共配置块添加词法状态。
39
+ def finish; end
40
+ end
41
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CtxParser
4
+ # 复制并冻结普通数据,避免解析快照共享调用方可变状态。
5
+ module Immutable
6
+ # 深度复制并冻结普通数据,拒绝可执行或不支持的对象。
7
+ def self.copy(value)
8
+ # 容器已冻结也可能持有可变子对象,仍须递归复制;冻结字符串可直接复用。
9
+ case value
10
+ when Hash then value.to_h { |key, item| [ copy(key), copy(item) ] }.freeze
11
+ when Array then value.map { |item| copy(item) }.freeze
12
+ when String then value.frozen? ? value : value.dup.freeze
13
+ when Symbol, Numeric, NilClass, TrueClass, FalseClass then value
14
+ else raise ArgumentError, "Values must contain plain data"
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CtxParser
4
+ # 按显式编码、NUL 和大小策略归一化输入字节及换行。
5
+ module Input
6
+ module_function
7
+
8
+ # 校验来源文本,仅按请求调整编码或换行形式。
9
+ def text(content, encoding: :preserve, newlines: :preserve, allow_nul: false, max_bytes: nil)
10
+ raise ArgumentError, "CLI output must be text" unless content.is_a?(String)
11
+ raise ArgumentError, "Invalid encoding policy" unless %i[preserve utf8 transcode].include?(encoding)
12
+ raise ArgumentError, "Invalid newline policy" unless %i[preserve lf delete_cr].include?(newlines)
13
+ raise ArgumentError, "NUL policy must be boolean" unless [ true, false ].include?(allow_nul)
14
+ raise ArgumentError, "Invalid byte limit" unless max_bytes.nil? || (max_bytes.is_a?(Integer) && max_bytes >= 0)
15
+ raise ArgumentError, "CLI output exceeds byte limit" if max_bytes && content.bytesize > max_bytes
16
+
17
+ value = content.dup
18
+ value.force_encoding(Encoding::UTF_8) if encoding == :utf8 || value.encoding == Encoding::BINARY
19
+ raise EncodingError, "CLI output encoding is invalid" unless value.valid_encoding?
20
+
21
+ value = value.encode(Encoding::UTF_8) if encoding == :transcode
22
+ raise ArgumentError, "CLI output contains a NUL byte" if !allow_nul && value.include?("\0")
23
+
24
+ case newlines
25
+ when :lf then value.gsub(/\r\n?/, "\n")
26
+ when :delete_cr then value.delete("\r")
27
+ else value
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CtxParser
4
+ # 不可变来源行保留原始换行,同时提供用于语法匹配的归一化文本。
5
+ Line = Data.define(:index, :raw) do
6
+ # 保留原始来源文本,并生成索引、正文及缩进信息。
7
+ def initialize(index:, raw:)
8
+ raise ArgumentError, "Line index must be nonnegative" unless index.is_a?(Integer) && index >= 0
9
+ raise ArgumentError, "Line content must be text" unless raw.is_a?(String)
10
+
11
+ @text = raw.chomp.strip.freeze
12
+ @indent = raw[/\A[ \t]*/].size
13
+ super(index: index, raw: raw.dup.freeze)
14
+ end
15
+
16
+ # 返回诊断使用的一起始行号。
17
+ def number = index + 1
18
+ # 返回已去除外围缩进和行结束符的来源内容。
19
+ def text = @text
20
+ # 返回构建配置树使用的前导缩进。
21
+ def indent = @indent
22
+ # 序列化诊断元数据,不包含原始来源文本。
23
+ def as_json(*) = { number: number }
24
+ # 展示对象身份与状态,避免泄露原始回显或凭据。
25
+ def inspect = "#<#{self.class.name} number=#{number}>"
26
+ alias to_s inspect
27
+ end
28
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CtxParser
4
+ # 提供公共命令解析契约;厂商语法、编码与完整性由子类负责。
5
+ class Parser
6
+ attr_reader :cursor, :blocks, :document
7
+
8
+ # 每次调用使用新实例解析,避免共享来源状态。
9
+ def self.parse(text, complete: false)
10
+ new.parse(text, complete: complete)
11
+ end
12
+
13
+ # 校验输入及完整性,重置来源状态后执行命令解析器。
14
+ def parse(text, complete: false)
15
+ @document, @cursor, @blocks = nil, nil, [].freeze
16
+ raise ArgumentError, "Runtime output must be text" unless text.is_a?(String)
17
+ raise ArgumentError, "Completeness must be boolean" unless [ true, false ].include?(complete)
18
+
19
+ parse_output(text, complete: complete)
20
+ end
21
+
22
+ # 序列化诊断元数据,不包含原始来源文本。
23
+ def as_json(*) = { parser: self.class.name, cursor: cursor&.as_json, blocks: Array(blocks).size }
24
+ # 展示对象身份与状态,避免泄露原始回显或凭据。
25
+ def inspect = "#<#{self.class.name}>"
26
+
27
+ private
28
+
29
+ # 每次解析创建私有游标与配置树;序列化结果不包含来源正文。
30
+ def read_configuration(text, comments: nil, boundaries: nil, document_class: Document)
31
+ @document = document_class.new(text, comments: comments, boundaries: boundaries)
32
+ @cursor, @blocks = document.cursor, document.roots
33
+ @blocks
34
+ end
35
+
36
+ # 解释当前命令回显,保留不支持或格式错误的证据。
37
+ def parse_output(text, complete:)
38
+ raise NotImplementedError, "Command parsers must implement parse_output"
39
+ end
40
+ end
41
+ end
42
+
43
+ # 在依赖可能触发 Rails 自动加载此命名空间前,先定义基础类。
44
+ require_relative "result"
45
+ require_relative "result_set"
46
+ require_relative "document"