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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +22 -0
- data/README-zh.md +102 -0
- data/README.md +102 -0
- data/docs/architecture-zh.md +273 -0
- data/docs/release.md +13 -0
- data/examples/offline.rb +30 -0
- data/lib/ctx_parser/banner.rb +23 -0
- data/lib/ctx_parser/block.rb +99 -0
- data/lib/ctx_parser/capture.rb +61 -0
- data/lib/ctx_parser/command_catalog.rb +54 -0
- data/lib/ctx_parser/configuration_parser.rb +73 -0
- data/lib/ctx_parser/cursor.rb +177 -0
- data/lib/ctx_parser/document.rb +41 -0
- data/lib/ctx_parser/immutable.rb +18 -0
- data/lib/ctx_parser/input.rb +31 -0
- data/lib/ctx_parser/line.rb +28 -0
- data/lib/ctx_parser/parser.rb +46 -0
- data/lib/ctx_parser/profile.rb +134 -0
- data/lib/ctx_parser/records.rb +36 -0
- data/lib/ctx_parser/registry.rb +39 -0
- data/lib/ctx_parser/result.rb +22 -0
- data/lib/ctx_parser/result_set.rb +36 -0
- data/lib/ctx_parser/values.rb +59 -0
- data/lib/ctx_parser/version.rb +6 -0
- data/lib/ctx_parser.rb +17 -0
- metadata +99 -0
|
@@ -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"
|