ctx_parser 0.4.0 → 0.5.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 +4 -4
- data/CHANGELOG.md +19 -0
- data/README-zh.md +7 -3
- data/README.md +8 -4
- data/docs/contracts-0.5.md +70 -0
- data/docs/performance-0.5.md +64 -0
- data/examples/declarations.rb +26 -0
- data/lib/ctx_parser/block.rb +4 -3
- data/lib/ctx_parser/capture.rb +12 -6
- data/lib/ctx_parser/commands.rb +57 -8
- data/lib/ctx_parser/configuration.rb +17 -0
- data/lib/ctx_parser/cursor.rb +31 -0
- data/lib/ctx_parser/document.rb +40 -2
- data/lib/ctx_parser/immutable.rb +12 -27
- data/lib/ctx_parser/input.rb +2 -2
- data/lib/ctx_parser/line.rb +4 -1
- data/lib/ctx_parser/profile.rb +60 -173
- data/lib/ctx_parser/records.rb +2 -1
- data/lib/ctx_parser/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dfdec0a6366bcb4f65cca5025dacb968ec838de632dbed9b02424b1eadfe7ba9
|
|
4
|
+
data.tar.gz: c7b1eadc4fc4f6baa24b0e9c2c94e40080097e60da3ba25a6b4693c5d4735a42
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aa1e066001a118d610be8fd777844ddd53dab4627ab976eefaf076218d7ec77d62d15011ae203804cb8c3117f03cee079aa83315cd909bb2eaae192817201d4f
|
|
7
|
+
data.tar.gz: 0e1f197af6d134e9fa86989b62b4e82e6b4cecc103336a213128b211f1c35060e337903c04b91cd8f365729ce602a8f18acdba5cb1d7409efef01eb2ef07ca33
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@ Changes to the public contract are recorded here. Versions follow Semantic Versi
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.5.1 (2026-10-30)
|
|
8
|
+
|
|
9
|
+
- Add `Document#build_blocks` for native syntax trees and `Document::Flat` for independent command lines, sharing initialization, finalization and root validation.
|
|
10
|
+
- Validate Block children by source ownership, containment and order; indentation remains the responsibility of the default tree builder.
|
|
11
|
+
- Add cached `Cursor#block(index)` source ranges whose state stays live across rewind and reset.
|
|
12
|
+
|
|
13
|
+
## 0.5.0 (2026-10-30)
|
|
14
|
+
|
|
15
|
+
- Add Cursor#resolved? with a lazy, invalidated prefix index; Block/Capture completeness remains live after state changes and reset.
|
|
16
|
+
- Snapshot declaration, request, child, capture-source and record arrays before validation or callbacks without inheriting caller copy hooks.
|
|
17
|
+
|
|
18
|
+
- Make immutable `Commands` the single declaration boundary; add query grouping/selection and accept empty catalogs.
|
|
19
|
+
- Add `Profile#declarations`, constructor injection and `with_declarations`; remove the independent `COMMANDS`/`PARSERS` declaration protocol and mutable-directory caches.
|
|
20
|
+
- Reject foreign configuration blocks before callbacks; snapshot scope paths before entering callbacks and preserve live source completeness.
|
|
21
|
+
- Strip string subclasses, attached state and singleton behavior at snapshot, command, input and source-line boundaries.
|
|
22
|
+
- Rebuild frozen containers instead of reusing external object identity; preserve sharing within each copy and use Ruby string deduplication without an application-level cache.
|
|
23
|
+
- Keep selected model overrides stable in derived profiles; retain Result/Results fields, projection isolation and Records ownership semantics.
|
|
24
|
+
- Provide standalone configuration benchmarks and a 0.5 migration guide.
|
|
25
|
+
|
|
7
26
|
## 0.4.0 (2026-10-29)
|
|
8
27
|
|
|
9
28
|
- Add immutable `covered_scopes` to Result, including equality, hashing, pattern matching and `with` semantics.
|
data/README-zh.md
CHANGED
|
@@ -72,6 +72,8 @@ result.records.each { |row| puts "#{row[:name]}: #{row[:state]}" }
|
|
|
72
72
|
|
|
73
73
|
`complete: true` 只表示调用方确认输入完整接收,不证明所有语法都已理解。未知行、身份冲突及空表证据不足必须明确表达。结果的记录和诊断均须为 Hash 数组;结果及构建完成的文档诊断持有不可变快照。`inspect`、`to_s` 和 pretty printing 不输出结果正文与诊断内容,显式读取记录仍返回原始事实。来源游标有意保留输入原文,含秘密时不可写入日志。
|
|
74
74
|
|
|
75
|
+
`Cursor#resolved?(first_index, last_index)` 查询零起始闭区间;Block/Capture 完整性继续读取实时来源状态。惰性前缀索引在解决状态变化后失效,reset 时清空,详见 [0.5 契约](docs/contracts-0.5.md)。
|
|
76
|
+
|
|
75
77
|
本 gem 不提供厂商命令目录、传输、持久化或健康策略。参数和注册表配置错误保持异常,由调用方在对应边界处理。
|
|
76
78
|
|
|
77
79
|
详细厂商语义、历史验证记录和 Motor 专用集成说明保留在[架构参考](docs/architecture-zh.md)。其中宿主集成代码需要 Motor,不属于独立 gem API。适配层位于 [Motor 仓库](https://github.com/gatework/motor/tree/main/backend/lib) 的 `backend/lib/baseline`、`backend/lib/vxlan` 和 `backend/lib/wireless`。
|
|
@@ -86,11 +88,13 @@ result.records.each { |row| puts "#{row[:name]}: #{row[:state]}" }
|
|
|
86
88
|
|
|
87
89
|
`Cursor.new(text, max_bytes: limit)` 和 `Document.new(source, max_bytes: limit)` 可在构建或消费来源行前限制输入字节数。传入已有游标时,限制针对其归一化内容。非法编码仍抛出错误;已知为 Latin-1 的输入可先标记 `Encoding::ISO_8859_1`,随后自动转为 UTF-8,不静默替换字节并生成事实。
|
|
88
90
|
|
|
89
|
-
`MODEL_PARSERS` 的型号键与请求型号一样去除两侧空白并转为小写,归一化冲突和无效覆盖均被拒绝。`Registry`
|
|
91
|
+
`MODEL_PARSERS` 的型号键与请求型号一样去除两侧空白并转为小写,归一化冲突和无效覆盖均被拒绝。`Registry` 在注册时校验 Commands 声明及型号覆盖;Profile 创建时快照选中的覆盖,派生实例继续持有原先的型号设置。
|
|
92
|
+
|
|
93
|
+
`Commands` 独立持有不可变查询序列及命令到视图的索引。静态声明使用 `DECLARATIONS = CtxParser::Commands.new(...)`,也可向 `Profile.new` 传入 `declarations:`。发现阶段通过 `profile.with_declarations(new_catalog)` 返回副本,原实例保留原目录。`commands`、`parsers` 只是派生查询接口,旧的 `COMMANDS/PARSERS` 双目录声明会被拒绝。空目录合法,查询不存在的维度仍抛出 `ArgumentError`。详见 [0.5 契约与迁移](docs/contracts-0.5.md)。`benchmark/profile.rb` 覆盖 32–4096 条命令,`benchmark/commands.rb` 同时测量声明构造成本。
|
|
90
94
|
|
|
91
|
-
|
|
95
|
+
`Records` 持有自己的筛选数组,行对象仍属于调用方;使用期间应保持行不变,需要快照时传入 `Immutable.copy(rows)`。`Immutable.copy` 即使遇到冻结容器也重建普通容器,单次复制中保留共享引用并拒绝循环;不扫描方法表,也不引入应用级全局缓存。字符串经 `String.new` 和 Ruby 去重后冻结,保留编码与字节。不同次复制不保证容器对象身份相同;`Immutable.copy_string` 提供相同的文本边界并省去容器遍历表。整数转换仅在 `min` 允许负数或为 `nil` 时接受负号,默认仍只接受非负数。
|
|
92
96
|
|
|
93
|
-
`
|
|
97
|
+
`Document` 默认按缩进建树;原生语法可以覆盖受保护的 `build_blocks`,返回同一游标内按来源排序、互不重叠的根 Block,随后仍执行 `finish`。子块必须处于父范围内并保持顺序,不要求更深缩进。`Document::Flat` 将命令行作为独立来源块;`cursor.block(index)` 复用单行范围,rewind/reset 保留其身份,完整性始终读取当前游标状态。这些 API 由配套 0.5.1 候选提供,厂商语法由消费模块拥有。
|
|
94
98
|
|
|
95
99
|
## 开发与测试
|
|
96
100
|
|
data/README.md
CHANGED
|
@@ -72,6 +72,8 @@ result.records.each { |row| puts "#{row[:name]}: #{row[:state]}" }
|
|
|
72
72
|
|
|
73
73
|
`complete: true` confirms that the caller received the entire input; it does not certify that all syntax was understood. Unknown rows, identity conflicts, and insufficient empty-table evidence must remain explicit. Result records and issues must be arrays of hashes. Results and completed document diagnostics own immutable snapshots. `inspect`, `to_s`, and pretty printing omit result facts and diagnostics; explicit record access still returns the original facts. Source cursors intentionally retain original input and must not be logged when it contains secrets.
|
|
74
74
|
|
|
75
|
+
`Cursor#resolved?(first_index, last_index)` queries a zero-based inclusive source range. Block and Capture completeness stays live; a lazy prefix index is invalidated when resolved state changes and cleared by reset. See the [0.5 contracts](docs/contracts-0.5.md).
|
|
76
|
+
|
|
75
77
|
This gem supplies no vendor command declarations, transport, persistence, or health policy. Parser/registry argument errors remain exceptions for callers to handle at their own boundary.
|
|
76
78
|
|
|
77
79
|
Detailed vendor behavior, historical validation notes, and Motor-only integration are preserved in [the architecture reference](docs/architecture-zh.md). Host integration examples in that reference require Motor; they are not standalone gem APIs. Motor adapters live under `backend/lib/baseline`, `backend/lib/vxlan`, and `backend/lib/wireless` in the [Motor repository](https://github.com/gatework/motor/tree/main/backend/lib).
|
|
@@ -86,11 +88,13 @@ Address conversion uses `Values.ip_address`, `Values.ip_address?`, and `Values.m
|
|
|
86
88
|
|
|
87
89
|
`Cursor.new(text, max_bytes: limit)` and `Document.new(source, max_bytes: limit)` enforce an optional input byte budget before constructing or consuming lines. For an existing cursor, the budget applies to its normalized content. Invalid encoding remains an error: known Latin-1 input can be tagged as `Encoding::ISO_8859_1` before parsing and is transcoded to UTF-8; bytes are never silently scrubbed into facts.
|
|
88
90
|
|
|
89
|
-
`MODEL_PARSERS` model names are stripped and lowercased just like the requested model. Normalization collisions and invalid overrides are rejected. `Registry` validates
|
|
91
|
+
`MODEL_PARSERS` model names are stripped and lowercased just like the requested model. Normalization collisions and invalid overrides are rejected. `Registry` validates Commands declarations and model overrides when registering a profile. `Profile` snapshots the selected overrides; a derived profile retains those overrides even if the original model mapping is later changed.
|
|
92
|
+
|
|
93
|
+
`Commands` owns immutable query lists and a command-to-view index. Declare `DECLARATIONS = CtxParser::Commands.new(...)`, or pass `declarations:` to `Profile.new`. Discovery returns `profile.with_declarations(new_catalog)`; existing profiles keep their original catalog. `commands` and `parsers` are read-only derived views. Independent `COMMANDS`/`PARSERS` declarations are rejected. Empty catalogs are valid, while selecting an absent dimension raises `ArgumentError`. See [the 0.5 contract and migration guide](docs/contracts-0.5.md). `benchmark/profile.rb` exercises 32, 128, 512 and 4096 commands; `benchmark/commands.rb` also measures catalog construction.
|
|
90
94
|
|
|
91
|
-
|
|
95
|
+
`Records` owns selection arrays, while row objects remain caller-owned: treat those rows as stable during use, or pass `Immutable.copy(rows)` when a snapshot is needed. `Immutable.copy` rebuilds containers even when frozen, preserves shared references within each copy, and rejects cycles. This removes attached state, Hash defaults and singleton behavior without scanning method tables or maintaining a global cache. String snapshots use `String.new` followed by Ruby string deduplication; encoding and bytes are preserved. Container identity is not preserved across separate copies. `Immutable.copy_string` provides the same text boundary without allocating a container traversal map. Integer conversion accepts a minus sign only when `min` permits negative values (or is `nil`); the default remains nonnegative.
|
|
92
96
|
|
|
93
|
-
`
|
|
97
|
+
`Document` builds indentation-based trees by default. Native grammars may override protected `build_blocks` and return ordered, non-overlapping root Blocks from the same cursor; `finish` still runs afterward. Block children must be contained and ordered, but need not have greater indentation. `Document::Flat` treats each command as an independent source block. `cursor.block(index)` reuses a one-line range; rewind/reset preserve its identity while completeness follows current cursor state. These APIs are provided by the paired 0.5.1 candidate; vendor grammar remains in consumers.
|
|
94
98
|
|
|
95
99
|
## Development and tests
|
|
96
100
|
|
|
@@ -126,4 +130,4 @@ MIT. See [LICENSE](LICENSE).
|
|
|
126
130
|
|
|
127
131
|
`Result#partial?` identifies partial parsing; `#available?` is true for complete or partial results. Failed and unsupported results cannot contain records. Availability does not establish completeness or network health.
|
|
128
132
|
|
|
129
|
-
|
|
133
|
+
The 0.5 series changes profile declarations; Result fields, including `covered_scopes`, remain unchanged. Cross-module contracts are described in the [Runner migration guide](https://github.com/gatework/ctx_runner/blob/main/docs/contracts-2026-10-29.md).
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# ctx_parser 0.5 契约与迁移
|
|
2
|
+
|
|
3
|
+
本轮保持现有生产类和模块,收敛声明入口及状态边界。最低运行时为 Ruby 4.0.6。
|
|
4
|
+
|
|
5
|
+
## 职责与所有权
|
|
6
|
+
|
|
7
|
+
| 抽象 | 职责及所有权 |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `Commands` | 校验一次声明,拥有冻结的命令、视图和解析器索引;解析器类本身由调用方维护 |
|
|
10
|
+
| `Profile` | 持有一份声明和已选型号覆盖,选择解析器并隔离视图投影错误 |
|
|
11
|
+
| `Parser` | 文本与传输完整性入口;厂商子类负责解释语法 |
|
|
12
|
+
| `Configuration` | 每次解析独立维护文档、来源状态和临时结果路径 |
|
|
13
|
+
| `Cursor` / `Document` / `Block` | 来源行、缩进范围及实时语义状态;读过不等于解析成功 |
|
|
14
|
+
| `Capture` | 事实和路径是不可变快照,完整性继续查询来源状态 |
|
|
15
|
+
| `Result` / `Results` | 不可变事实与诊断;共享回显的每个视图独立保留状态 |
|
|
16
|
+
| `Immutable` / `Values` / `Records` | 纯数据快照、严格值转换、逻辑身份分组;Records 借用行对象 |
|
|
17
|
+
|
|
18
|
+
## 单一声明入口
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
class DeviceProfile < CtxParser::Profile
|
|
22
|
+
DECLARATIONS = CtxParser::Commands.new([
|
|
23
|
+
{ dimensions: %i[interfaces counters], command: "show interfaces", parser: InterfacesParser },
|
|
24
|
+
{ dimensions: [ :counters ], command: "show counters", parser: CountersParser }
|
|
25
|
+
], multi_command: true)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
profile = DeviceProfile.new(model: "fixture")
|
|
29
|
+
profile.commands_for(:counters) # 两条独立命令,不能拼接其正文
|
|
30
|
+
profile.queries(%i[interfaces counters]) # 命令 => 请求视图,保持顺序
|
|
31
|
+
profile.parser_for(:counters, command: "show counters")
|
|
32
|
+
|
|
33
|
+
expanded = profile.with_declarations(CtxParser::Commands.new(
|
|
34
|
+
profile.declarations.entries + additional_entries, multi_command: true
|
|
35
|
+
))
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`Profile.new(model: nil, declarations: self.class::DECLARATIONS)` 允许显式注入目录。`Commands.new([])` 表示无查询;`queries([])` 返回冻结空映射,选择不存在的维度或从空请求推断命令仍抛出 `ArgumentError`。
|
|
39
|
+
|
|
40
|
+
删除子类的 `COMMANDS`、`PARSERS` 常量,改用 `DECLARATIONS`。读取旧常量的代码迁移到 `Profile::DECLARATIONS.by_dimension` / `.parsers`,或实例的 `commands` / `parsers`。旧的双目录声明会报告迁移错误;覆盖 `commands` 或 `parsers` 不再改变解析器选择。
|
|
41
|
+
|
|
42
|
+
`Commands#commands_for` 总是返回冻结数组;`by_dimension` 保持单命令为 String、多命令为 Array 的既有读取形状。`select_command(dimensions, command: nil)` 要求非空、唯一且已声明的维度;只有唯一公共命令才能隐式推断。显式命令必须提供全部请求视图,选择结果返回目录中已经校验并冻结的命令文本。
|
|
43
|
+
|
|
44
|
+
每次 Profile 操作只读取一次目录,命令与实现来自同一快照。型号键仍去除外围空白并转为小写;所有声明的型号覆盖在注册或实例创建时校验。`with_declarations` 保留已经选定的覆盖和子类状态,不重跑初始化;若新目录删除了选中覆盖需要的命令,则拒绝替换。改变类常量仅影响新实例。
|
|
45
|
+
|
|
46
|
+
## 来源与快照边界
|
|
47
|
+
|
|
48
|
+
`analyze`、`analyze_children`、`ignore`、`scope` 在回调前拒绝其他游标的块。参数失败不修改来源状态。`scope` 进入前固定路径,结束时复制产生的事实;嵌套异常仍恢复父路径。
|
|
49
|
+
|
|
50
|
+
`Immutable.copy` 和 `copy_string` 不保留字符串子类、附加状态或单例行为。输入、命令和来源行采用同一纯文本原则;编码及原始换行策略不变。循环容器仍拒绝。容器即使冻结也重建,单次复制通过身份索引保留共享引用;不同次复制不保证同一容器对象身份。字符串通过 `String.new` 后调用 Ruby 的一元 `-` 去重并冻结。该策略隔离所有可见性的单例行为,也避免枚举每个值的方法表;不新增应用级缓存。
|
|
51
|
+
|
|
52
|
+
这是对原计划复用冻结容器策略的调整:全量反射检查在本机让重复包装冻结记录慢约 9 倍,且空扩展模块后续新增方法仍可能改变已冻结输入的行为。重建容器消除了这两类问题。记录的值、顺序和一次快照内部的共享引用关系不变。
|
|
53
|
+
|
|
54
|
+
未更改 Result 字段 `status/records/issues/covered_scopes`、complete/partial/failed/unsupported 状态、共享视图失败隔离、来源完整性的实时查询或 Records 的借用约定。`complete: true` 仍只是调用方的接收完整性声明。
|
|
55
|
+
|
|
56
|
+
## 消费模块与验证
|
|
57
|
+
|
|
58
|
+
候选版本为 ctx_parser 0.5.0、ctx_driver/ctx_runner 0.5.1、ctx_checker 0.7.0、vxlan 0.9.0、wireless-rb 0.10.0。Driver/Runner 接受 parser 0.4 与 0.5;迁移后的三个领域模块要求 parser 0.5 及相应 Driver/Runner 补丁版本。报告 schema 不变。
|
|
59
|
+
|
|
60
|
+
运行 `bundle exec rake`、`bundle exec rake build` 和 `ruby script/verify_package.rb pkg/ctx_parser-0.5.0.gem`。`examples/declarations.rb` 演示新入口;`benchmark/commands.rb`、`profile.rb`、`snapshots.rb`、`source.rb`、`configuration.rb` 分别覆盖声明、选择、事实快照与来源追溯。性能比较必须同时包含构建、首次使用与复用成本,并校验结果相同。
|
|
61
|
+
|
|
62
|
+
本地测量与正确性成本见 [性能记录](performance-0.5.md)。
|
|
63
|
+
|
|
64
|
+
## 后续打磨:容器与来源区间
|
|
65
|
+
|
|
66
|
+
声明、解析请求、子块、捕获来源列表和 Records 行列表在校验及回调前通过 Array.new 固定普通数组。列表由库拥有,解析器类、来源块与 Records 行仍按原契约借用;不继承调用方的 dup/map 行为。
|
|
67
|
+
|
|
68
|
+
`Cursor#resolved?(first_index, last_index)` 接受零起始闭区间。越界参数抛出 IndexError,逆序区间抛出 ArgumentError;空文档没有可查询的有效闭区间。Block#complete? 委托该查询,Capture 的完整性仍跟随当前来源状态。
|
|
69
|
+
|
|
70
|
+
Cursor 按需构建未解决行数量的前缀索引,只延伸到已查询末行。mark 在 resolved/unresolved 分组变化时将有效边界退回修改位置,parsed 与 ignored 互换不使索引失效。unread/defer 通过 mark 更新,reset! 释放索引;读取、回退和 rewind 不改变语义状态。稳定查询 O(1),失效登记 O(1),重建成本与需要重算的前缀区间长度成正比,空间最多 O(n)。索引属于单次解析游标,不承诺线程共享或缓存旧完整性结论。
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# ctx_parser 0.5 本地性能记录
|
|
2
|
+
|
|
3
|
+
环境:RVM Ruby 4.0.6,Linux x86_64;基线为 ctx_parser 0.4.0(提交 `0aacc77a7b247f00ad59350517849474aca49726`)。上一轮候选生产源码摘要:`a6cada37ed67e8048a1ea9a41bfa126b1474af4494135d4daa2964929be0d4c1`。
|
|
4
|
+
|
|
5
|
+
声明与快照基准按 baseline/candidate/candidate/baseline 顺序运行两轮,取四个独立进程样本中位数;来源基准采用一轮交错顺序,各进程执行三次。测量时没有并行测试。所有场景校验命令顺序、解析值或来源范围。以下是合成数据结果,不代表设备或网络吞吐。
|
|
6
|
+
|
|
7
|
+
## 声明、选择与快照
|
|
8
|
+
|
|
9
|
+
| 场景 | 工作量 | 基线 ms | 候选 ms | 基线分配 | 候选分配 |
|
|
10
|
+
| --- | --- | ---: | ---: | ---: | ---: |
|
|
11
|
+
| 构建完整目录 | 4096 条目录 × 10 次 | 178.155 | 204.748 | 245813 | 286793 |
|
|
12
|
+
| 构建目录并解析前 128 个命令 | 4096 条目录 × 10 次 | 195.480 | 189.548 | 354813 | 312443 |
|
|
13
|
+
| 复用目录创建 Profile 并首次选择 | 100 个 Profile | 342.825 | 0.420 | 821403 | 603 |
|
|
14
|
+
| 重复显式选择 | 12800 次选择 | 17.618 | 17.192 | 38503 | 25703 |
|
|
15
|
+
| 重复封装冻结记录 | 10 万条记录 × 3 次 | 580.662 | 804.373 | 300053 | 600043 |
|
|
16
|
+
|
|
17
|
+
查询索引在 Commands 中构建并跨 Profile 共享,首次选择不再重复校验整个目录。重复选择分配减少约 33%;完整构建加解析分配减少约 12%,该场景耗时基本持平。单独目录构建分配增加约 17%,耗时增加约 15%,因此不能把首次选择的降幅视为整条流程的收益。
|
|
18
|
+
|
|
19
|
+
最终快照策略重建普通容器,按来源身份保留一次复制内部的共享引用,并通过 Ruby 去重字符串。它隔离私有单例方法及后来改变的扩展模块,无需方法表反射或应用级缓存。外部冻结容器不再保证同一对象身份,记录值、编码和顺序保持不变。
|
|
20
|
+
|
|
21
|
+
重复包装 10 万条冻结记录的样本耗时增加约 39%,分配约为基线两倍。这是本轮保留的隔离成本,不宣称所有场景性能提升。曾尝试逐个对象反射检查后复用,实测该方案慢约 9.4 倍、分配约 10 倍,已移除。
|
|
22
|
+
|
|
23
|
+
## 来源处理
|
|
24
|
+
|
|
25
|
+
| 场景 | 基线 ms | 候选 ms | 基线分配 | 候选分配 |
|
|
26
|
+
| --- | ---: | ---: | ---: | ---: |
|
|
27
|
+
| configuration | 571.201 | 718.363 | 1080210 | 1170467 |
|
|
28
|
+
| cursor | 258.137 | 336.925 | 600045 | 680045 |
|
|
29
|
+
| document | 480.366 | 552.519 | 1050066 | 1130067 |
|
|
30
|
+
| fact_sources | 10.803 | 14.535 | 23078 | 26080 |
|
|
31
|
+
|
|
32
|
+
cursor/document 使用 2 万行输入各构造三次;configuration 使用 2 万余行合成语法解析三次;fact_sources 将一份事实关联到 1000 个来源。来源状态与范围断言全部通过,这些来源场景的处理耗时增加约 15–35%,分配增加约 7–13%。
|
|
33
|
+
|
|
34
|
+
复现入口为 `benchmark/commands.rb`、`profile.rb`、`snapshots.rb`、`source.rb` 和自包含的 `configuration.rb`。本轮交错比较脚本、各次原始输出及摘要保存在工作区 `.parser-review-20261030/`。文本处理使用 Ruby 的 [String.new](https://docs.ruby-lang.org/en/4.0/String.html#method-c-new) 与 [String#-@](https://docs.ruby-lang.org/en/4.0/String.html#method-i-2D-40),不依赖 VM 内部对象标志。
|
|
35
|
+
|
|
36
|
+
## 后续打磨:来源区间索引与普通列表快照
|
|
37
|
+
|
|
38
|
+
本节基线为上一轮未提交的 0.5.0 候选,完整保存在 `.foundation-review-20261030/baseline/ctx_parser/`;不是已发布 0.4.0。使用 RVM Ruby 4.0.6、同一脚本和输入,按 baseline/candidate/candidate/baseline 串行执行,每进程三个样本,取六个样本中位数。构造和初始标记不计入完整性查询时间,首次惰性索引构建计入查询时间;逐次结果均在计时外校验。
|
|
39
|
+
|
|
40
|
+
当前查询实现源码摘要:`3fcc73fc55adccb651ef171120f0165eac98b6667a86fb8a8ca22b0d5961148b`。
|
|
41
|
+
|
|
42
|
+
| 场景 | 基线 ms | 候选 ms | 基线分配 | 候选分配 |
|
|
43
|
+
| --- | ---: | ---: | ---: | ---: |
|
|
44
|
+
| 首次完整区间查询(1 次) | 3.244 | 1.249 | 3 | 1 |
|
|
45
|
+
| 稳定完整区间查询(1000 次) | 3616.764 | 1.920 | 3000 | 1 |
|
|
46
|
+
| 首行未解决(1000 次) | 1.141 | 0.592 | 4000 | 0 |
|
|
47
|
+
| 单行区间(1000 次) | 1.198 | 0.577 | 3000 | 0 |
|
|
48
|
+
| 17 行区间(1000 次) | 6.523 | 0.863 | 3000 | 1 |
|
|
49
|
+
| 末行状态交替(200 次) | 700.055 | 1.467 | 700 | 1 |
|
|
50
|
+
| 首行状态交替(200 次) | 346.471 | 131.373 | 700 | 1 |
|
|
51
|
+
| 只修改状态(1000 次) | 0.443 | 0.453 | 0 | 0 |
|
|
52
|
+
| 500 层树的全部节点 | 43.618 | 0.495 | 1500 | 1 |
|
|
53
|
+
| 封装 10 万条冻结记录 | 249.516 | 252.717 | 200013 | 200013 |
|
|
54
|
+
| Cursor 构造 | 355.201 | 336.104 | 680045 | 680045 |
|
|
55
|
+
| Document 构造 | 594.314 | 581.803 | 1130067 | 1130067 |
|
|
56
|
+
| 配置解析 | 737.292 | 749.106 | 1168628 | 1160241 |
|
|
57
|
+
| 1000 个事实来源 | 14.355 | 13.802 | 26080 | 26082 |
|
|
58
|
+
| 来源索引查找 | 2.457 | 3.780 | 1 | 1 |
|
|
59
|
+
|
|
60
|
+
普通区间场景使用 10,001 行。完整索引包含 10,002 个计数,ObjectSpace 实测占用 80,464 字节;17 行区间仅建 18 个计数、占 192 字节。未查询、单行和首行未解决的场景不分配索引。Ruby Array 的容量按 VM 策略增长,字节数不属于公共保证。
|
|
61
|
+
|
|
62
|
+
稳定查询与末行反复修改得到明显改善;首行变更后仍须重建后续计数,最坏重建成本仍为 O(n),不宣称任意状态更新后的查询都是 O(1)。只修改状态和完整配置处理总体接近基线。严格快照复制策略未放宽,10 万记录封装的对象分配不变。来源查找样本从约 2.46 ms 波动至 3.78 ms,该路径没有算法修改,保留原始数据而不将其解释成优化收益。
|
|
63
|
+
|
|
64
|
+
可用 `ruby -I/path/to/lib benchmark/completeness.rb` 比较不同源码;快照及来源基准仍使用 snapshots/source/configuration 脚本。全部原始样本及源码摘要保存在本次工作区验证目录;这些合成结果不代表设备或网络吞吐。
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ctx_parser"
|
|
4
|
+
|
|
5
|
+
# 合成解析器只演示声明与投影,不连接设备。
|
|
6
|
+
class DeclarationStatusParser < CtxParser::Parser
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def parse_output(text, complete:)
|
|
10
|
+
CtxParser::Result.new(status: complete ? :complete : :partial, records: [ { value: text.strip } ])
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
catalog = CtxParser::Commands.new([
|
|
15
|
+
{ dimensions: %i[inventory counters], command: "show inventory", parser: DeclarationStatusParser }
|
|
16
|
+
])
|
|
17
|
+
profile = CtxParser::Profile.new(declarations: catalog)
|
|
18
|
+
views = profile.parse_many(%i[inventory counters], "synthetic\n", complete: true)
|
|
19
|
+
abort "Shared view selection changed" unless views.keys == %i[inventory counters] && views.values.all?(&:complete?)
|
|
20
|
+
|
|
21
|
+
expanded = profile.with_declarations(CtxParser::Commands.new(catalog.entries + [
|
|
22
|
+
{ dimensions: [ :alarms ], command: "show alarms", parser: DeclarationStatusParser }
|
|
23
|
+
]))
|
|
24
|
+
abort "Original catalog changed" if profile.commands.key?(:alarms)
|
|
25
|
+
abort "Expanded query missing" unless expanded.command(:alarms) == "show alarms"
|
|
26
|
+
puts "Commands: shared views and independent profile expansion passed"
|
data/lib/ctx_parser/block.rb
CHANGED
|
@@ -17,10 +17,11 @@ module CtxParser
|
|
|
17
17
|
raise ArgumentError, "Invalid source block range"
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
children = Array.new(children) if children.is_a?(Array)
|
|
20
21
|
previous_index = header.index
|
|
21
22
|
unless children.is_a?(Array) && children.all? do |child|
|
|
22
23
|
next false unless child.is_a?(Block) && child.cursor.equal?(cursor) &&
|
|
23
|
-
child.index > previous_index && child.last_index <= last_index
|
|
24
|
+
child.index > previous_index && child.last_index <= last_index
|
|
24
25
|
|
|
25
26
|
previous_index = child.last_index
|
|
26
27
|
true
|
|
@@ -29,7 +30,7 @@ module CtxParser
|
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
@cursor, @header, @last_index = cursor, header, last_index
|
|
32
|
-
@children = children.
|
|
33
|
+
@children = children.freeze
|
|
33
34
|
freeze
|
|
34
35
|
end
|
|
35
36
|
|
|
@@ -102,7 +103,7 @@ module CtxParser
|
|
|
102
103
|
# 返回当前配置块各来源行的处理状态。
|
|
103
104
|
def states = lines.map { |source| cursor.state(source.index) }.freeze
|
|
104
105
|
# 要求块内所有来源行均已解析或明确忽略。
|
|
105
|
-
def complete? =
|
|
106
|
+
def complete? = cursor.resolved?(index, last_index)
|
|
106
107
|
# 序列化诊断元数据,不包含原始来源文本。
|
|
107
108
|
def as_json(*) = { first_line: line, last_line: last_index + 1, children: children.size }
|
|
108
109
|
# 展示对象身份与状态,避免泄露原始回显或凭据。
|
data/lib/ctx_parser/capture.rb
CHANGED
|
@@ -8,17 +8,22 @@ module CtxParser
|
|
|
8
8
|
Snapshot = Data.define(:data, :path) do
|
|
9
9
|
# 此边界仅接收调用方原始数据,各快照自行持有深度冻结的副本。
|
|
10
10
|
def initialize(data:, path:)
|
|
11
|
-
|
|
12
|
-
raise ArgumentError, "Capture path must contain record keys or indexes"
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
super(data: Immutable.copy(data), path: Immutable.copy(path))
|
|
11
|
+
super(data: Immutable.copy(data), path: Capture.path_snapshot(path))
|
|
16
12
|
end
|
|
17
13
|
end
|
|
18
14
|
private_constant :Snapshot
|
|
19
15
|
|
|
20
16
|
attr_reader :block, :data, :path
|
|
21
17
|
|
|
18
|
+
# 作用域进入前与事实捕获时共用路径边界,不保留调用方可变键。
|
|
19
|
+
def self.path_snapshot(path)
|
|
20
|
+
unless path.is_a?(Array) && path.all? { |key| key.nil? || key.is_a?(Symbol) || key.is_a?(String) || key.is_a?(Integer) }
|
|
21
|
+
raise ArgumentError, "Capture path must contain record keys or indexes"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
Immutable.copy(path)
|
|
25
|
+
end
|
|
26
|
+
|
|
22
27
|
# 冻结解析事实及其来源块和结果路径。
|
|
23
28
|
def initialize(block:, data:, path:)
|
|
24
29
|
require_relative "block" unless defined?(Block)
|
|
@@ -30,13 +35,14 @@ module CtxParser
|
|
|
30
35
|
# 同一事实的多个来源共享已复制的值,不共享未经校验的调用方容器。
|
|
31
36
|
def self.for_blocks(blocks, data:, path:)
|
|
32
37
|
require_relative "block" unless defined?(Block)
|
|
38
|
+
blocks = Array.new(blocks) if blocks.is_a?(Array)
|
|
33
39
|
unless blocks.is_a?(Array) && blocks.all? { |block| block.is_a?(Block) }
|
|
34
40
|
raise ArgumentError, "Capture sources must be blocks"
|
|
35
41
|
end
|
|
36
42
|
return [].freeze if blocks.empty?
|
|
37
43
|
|
|
38
44
|
snapshot = Snapshot.new(data: data, path: path)
|
|
39
|
-
blocks.map { |block| allocate.tap { |capture| capture.send(:initialize_snapshot, block, snapshot) } }.freeze
|
|
45
|
+
blocks.map! { |block| allocate.tap { |capture| capture.send(:initialize_snapshot, block, snapshot) } }.freeze
|
|
40
46
|
end
|
|
41
47
|
|
|
42
48
|
# 返回配置块或捕获事实对应的精确来源文本。
|
data/lib/ctx_parser/commands.rb
CHANGED
|
@@ -9,30 +9,78 @@ module CtxParser
|
|
|
9
9
|
|
|
10
10
|
# 查询文本的唯一值边界;复制后冻结,不冻结调用方提供的字符串。
|
|
11
11
|
def self.command_text(value)
|
|
12
|
-
unless value.is_a?(String)
|
|
12
|
+
raise ArgumentError, "One command without control characters is required" unless value.is_a?(String)
|
|
13
|
+
|
|
14
|
+
value = String.new(value).freeze
|
|
15
|
+
if value.strip.empty? || /[\x00-\x1f\x7f]/.match?(value)
|
|
13
16
|
raise ArgumentError, "One command without control characters is required"
|
|
14
17
|
end
|
|
15
|
-
value
|
|
18
|
+
value
|
|
16
19
|
end
|
|
17
20
|
|
|
18
21
|
# 校验并冻结受支持的维度到命令声明。
|
|
19
22
|
def initialize(entries, multi_command: false)
|
|
20
|
-
unless entries.is_a?(Array)
|
|
23
|
+
unless entries.is_a?(Array)
|
|
21
24
|
raise ArgumentError, "Commands must contain declarations"
|
|
22
25
|
end
|
|
23
26
|
raise ArgumentError, "Multi-command selection must be boolean" unless multi_command == true || multi_command == false
|
|
24
27
|
|
|
25
28
|
@multi_command = multi_command
|
|
26
|
-
@by_dimension, @parsers = {}, {}
|
|
27
|
-
@entries = entries.map { |entry| register(entry) }.freeze
|
|
28
|
-
@by_dimension.
|
|
29
|
+
@by_dimension, @parsers, @entries_by_command = {}, {}, {}
|
|
30
|
+
@entries = Array.new(entries).map! { |entry| register(entry) }.freeze
|
|
31
|
+
@queries = @by_dimension.transform_values { |value| Array(value).freeze }.freeze
|
|
32
|
+
@entries_by_command.freeze
|
|
29
33
|
@by_dimension.freeze
|
|
30
34
|
@parsers.freeze
|
|
31
35
|
freeze
|
|
32
36
|
end
|
|
33
37
|
|
|
38
|
+
# 始终返回同一份已校验的查询序列,无需在每个策略实例重新编译。
|
|
39
|
+
def commands_for(name)
|
|
40
|
+
@queries.fetch(name) { raise ArgumentError, "Unsupported parser dimension" }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# 保留请求与声明顺序;共享命令只计划一次。
|
|
44
|
+
def queries(dimensions)
|
|
45
|
+
validate_dimensions!(dimensions)
|
|
46
|
+
grouped = dimensions.each_with_object({}) do |name, result|
|
|
47
|
+
commands_for(name).each { |command| (result[command] ||= []) << name }
|
|
48
|
+
end
|
|
49
|
+
grouped.each_value(&:freeze)
|
|
50
|
+
grouped.freeze
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# 显式选择通过命令索引核对视图,成本不随整个目录大小增长。
|
|
54
|
+
def select_command(dimensions, command: nil)
|
|
55
|
+
validate_dimensions!(dimensions)
|
|
56
|
+
raise ArgumentError, "Parser dimensions must not be empty" if dimensions.empty?
|
|
57
|
+
|
|
58
|
+
unless command.nil?
|
|
59
|
+
raise ArgumentError, "Selected command must be text" unless command.is_a?(String)
|
|
60
|
+
|
|
61
|
+
# 只借用输入字节查找,返回目录内已校验的文本,不重复编译或保留调用方行为。
|
|
62
|
+
entry = @entries_by_command[String.new(command)]
|
|
63
|
+
unless entry && dimensions.all? { |name| entry[:dimensions].include?(name) }
|
|
64
|
+
raise ArgumentError, "Selected command does not provide all parser dimensions"
|
|
65
|
+
end
|
|
66
|
+
entry[:command]
|
|
67
|
+
else
|
|
68
|
+
candidates = dimensions.map { |name| commands_for(name) }.reduce { |common, queries| common & queries }
|
|
69
|
+
raise ArgumentError, "One CLI output must belong to one command" unless candidates.one?
|
|
70
|
+
|
|
71
|
+
candidates.first
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
34
75
|
private
|
|
35
76
|
|
|
77
|
+
# 空请求可产生空查询计划;命令选择另行要求至少一个视图。
|
|
78
|
+
def validate_dimensions!(dimensions)
|
|
79
|
+
unless dimensions.is_a?(Array) && (dimensions.size < 2 || dimensions.uniq == dimensions)
|
|
80
|
+
raise ArgumentError, "Query dimensions must be a unique array"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
36
84
|
# 仅复制声明数据;解析器类保留正常 Ruby 生命周期。
|
|
37
85
|
def register(entry)
|
|
38
86
|
unless entry.is_a?(Hash) && entry.size == 3 && %i[dimensions command parser].all? { |key| entry.key?(key) }
|
|
@@ -40,6 +88,7 @@ module CtxParser
|
|
|
40
88
|
end
|
|
41
89
|
|
|
42
90
|
dimensions, command, parser = entry.values_at(:dimensions, :command, :parser)
|
|
91
|
+
dimensions = Array.new(dimensions) if dimensions.is_a?(Array)
|
|
43
92
|
unless dimensions.is_a?(Array) && dimensions.any? && dimensions.all? { |name| name.is_a?(Symbol) && !name.empty? } &&
|
|
44
93
|
dimensions.uniq == dimensions && (@multi_command || dimensions.none? { |name| @by_dimension.key?(name) })
|
|
45
94
|
raise ArgumentError, "Command dimensions must be unique nonempty symbols"
|
|
@@ -48,13 +97,13 @@ module CtxParser
|
|
|
48
97
|
raise ArgumentError, "Command is already declared" if @parsers.key?(command)
|
|
49
98
|
raise ArgumentError, "Command parser must inherit from Parser" unless parser.is_a?(Class) && parser < Parser
|
|
50
99
|
|
|
51
|
-
dimensions
|
|
100
|
+
dimensions.freeze
|
|
52
101
|
dimensions.each do |name|
|
|
53
102
|
# 索引数组由本实例持有,构建完成后统一冻结;追加不复制已有命令。
|
|
54
103
|
@by_dimension[name] = @by_dimension.key?(name) ? (Array(@by_dimension[name]) << command) : command
|
|
55
104
|
end
|
|
56
105
|
@parsers[command] = parser
|
|
57
|
-
{ dimensions: dimensions, command: command, parser: parser }.freeze
|
|
106
|
+
@entries_by_command[command] = { dimensions: dimensions, command: command, parser: parser }.freeze
|
|
58
107
|
end
|
|
59
108
|
end
|
|
60
109
|
end
|
|
@@ -32,6 +32,12 @@ module CtxParser
|
|
|
32
32
|
|
|
33
33
|
# 仅在解释成功后标记来源行;嵌套转换失败归因到最内层来源。
|
|
34
34
|
def analyze(block)
|
|
35
|
+
validate_source!(block)
|
|
36
|
+
analyze_source(block) { yield }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# 来源参数错误不进入语义异常处理,避免污染当前文档或遮盖原异常。
|
|
40
|
+
def analyze_source(block)
|
|
35
41
|
value = yield
|
|
36
42
|
cursor.mark(block.index, state: :parsed) if cursor.state(block.index) == :pending
|
|
37
43
|
if cursor.state(block.index) == :parsed
|
|
@@ -53,12 +59,15 @@ module CtxParser
|
|
|
53
59
|
|
|
54
60
|
# 各子节点独立保留来源状态及错误位置。
|
|
55
61
|
def analyze_children(block, &operation)
|
|
62
|
+
validate_source!(block)
|
|
56
63
|
block.children.each { |child| analyze(child) { operation.call(child) } }
|
|
57
64
|
end
|
|
58
65
|
|
|
59
66
|
# 进入临时结果路径,成功或失败后均恢复父作用域。
|
|
60
67
|
def scope(block, path)
|
|
61
68
|
previous_path = @source_path
|
|
69
|
+
validate_source!(block)
|
|
70
|
+
path = Capture.path_snapshot(path)
|
|
62
71
|
@source_path = path
|
|
63
72
|
value = yield
|
|
64
73
|
capture(block, value, path)
|
|
@@ -74,6 +83,7 @@ module CtxParser
|
|
|
74
83
|
|
|
75
84
|
# 迭代忽略领域外子树,不宣称其产生已解析事实。
|
|
76
85
|
def ignore(block)
|
|
86
|
+
validate_source!(block)
|
|
77
87
|
pending = [ block ]
|
|
78
88
|
until pending.empty?
|
|
79
89
|
entry = pending.pop
|
|
@@ -83,6 +93,13 @@ module CtxParser
|
|
|
83
93
|
nil
|
|
84
94
|
end
|
|
85
95
|
|
|
96
|
+
# 每次分析只接受当前文档的来源块,校验必须早于外部回调。
|
|
97
|
+
def validate_source!(block)
|
|
98
|
+
unless block.is_a?(Block) && block.cursor.equal?(cursor)
|
|
99
|
+
raise ArgumentError, "Source block must belong to this cursor"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
86
103
|
# 未处理的子节点保持待解析;具体解析器可诊断位置错误的相关命令。
|
|
87
104
|
def unparsed_child(_block); end
|
|
88
105
|
end
|
data/lib/ctx_parser/cursor.rb
CHANGED
|
@@ -25,12 +25,20 @@ module CtxParser
|
|
|
25
25
|
@lines << Line.new(index: @lines.size, raw: raw.freeze)
|
|
26
26
|
end
|
|
27
27
|
@lines.freeze
|
|
28
|
+
@line_blocks = {}
|
|
28
29
|
@checksum = Digest::SHA256.hexdigest(@content).freeze
|
|
29
30
|
reset!
|
|
30
31
|
end
|
|
31
32
|
|
|
32
33
|
# 返回原始来源行数。
|
|
33
34
|
def size = lines.size
|
|
35
|
+
|
|
36
|
+
# 单行范围按需复用;回退和重置只改变状态,不改变不可变来源块。
|
|
37
|
+
def block(index)
|
|
38
|
+
validate_index!(index)
|
|
39
|
+
require_relative "block" unless defined?(Block)
|
|
40
|
+
@line_blocks[index] ||= Block.new(cursor: self, header: lines[index], children: [], last_index: index)
|
|
41
|
+
end
|
|
34
42
|
# 判断游标是否到达来源末尾。
|
|
35
43
|
def eof? = position >= size
|
|
36
44
|
# 查看下一来源行,不推进游标也不修改状态。
|
|
@@ -71,11 +79,32 @@ module CtxParser
|
|
|
71
79
|
# 独立检查游标是否访问过该行,不与解释成功混淆。
|
|
72
80
|
def visited?(index) = valid_index?(index) ? @visited[index] : nil
|
|
73
81
|
|
|
82
|
+
# 查询零起始闭区间的实时解决状态;索引按需延伸,重复查询不再扫描每行。
|
|
83
|
+
def resolved?(first_index, last_index)
|
|
84
|
+
validate_index!(first_index)
|
|
85
|
+
validate_index!(last_index)
|
|
86
|
+
raise ArgumentError, "Source range must be ordered" if first_index > last_index
|
|
87
|
+
return false unless RESOLVED_STATES.include?(@states[first_index])
|
|
88
|
+
return true if first_index == last_index
|
|
89
|
+
|
|
90
|
+
@unresolved_prefix ||= [ 0 ]
|
|
91
|
+
while @indexed_states <= last_index
|
|
92
|
+
unresolved = RESOLVED_STATES.include?(@states[@indexed_states]) ? 0 : 1
|
|
93
|
+
@unresolved_prefix[@indexed_states + 1] = @unresolved_prefix[@indexed_states] + unresolved
|
|
94
|
+
@indexed_states += 1
|
|
95
|
+
end
|
|
96
|
+
@unresolved_prefix[last_index + 1] == @unresolved_prefix[first_index]
|
|
97
|
+
end
|
|
98
|
+
|
|
74
99
|
# 为有效来源行记录明确的语义状态。
|
|
75
100
|
def mark(index, state: :parsed)
|
|
76
101
|
validate_index!(index)
|
|
77
102
|
raise ArgumentError, "Invalid line state" unless STATES.include?(state)
|
|
78
103
|
|
|
104
|
+
# 只有跨越解决/未解决分组才使后缀失效;未查询的来源不承担索引构建成本。
|
|
105
|
+
if index < @indexed_states && RESOLVED_STATES.include?(@states[index]) != RESOLVED_STATES.include?(state)
|
|
106
|
+
@indexed_states = index
|
|
107
|
+
end
|
|
79
108
|
@states[index] = state
|
|
80
109
|
self
|
|
81
110
|
end
|
|
@@ -89,6 +118,7 @@ module CtxParser
|
|
|
89
118
|
# 清空访问、语义状态及捕获事实,重新开始分析。
|
|
90
119
|
def reset!
|
|
91
120
|
@states = Array.new(size, :pending)
|
|
121
|
+
@unresolved_prefix, @indexed_states = nil, 0
|
|
92
122
|
@visited = Array.new(size, false)
|
|
93
123
|
@captures = []
|
|
94
124
|
@captures_by_path, @ordered_by_path = {}, {}
|
|
@@ -136,6 +166,7 @@ module CtxParser
|
|
|
136
166
|
# 注册归纳事实的不可变快照前,先校验全部来源。
|
|
137
167
|
def capture_many(blocks, data:, path:)
|
|
138
168
|
require_relative "block" unless defined?(Block)
|
|
169
|
+
blocks = Array.new(blocks) if blocks.is_a?(Array)
|
|
139
170
|
unless blocks.is_a?(Array) && blocks.all? { |block| block.is_a?(Block) && block.cursor.equal?(self) }
|
|
140
171
|
raise ArgumentError, "Blocks must belong to this cursor"
|
|
141
172
|
end
|
data/lib/ctx_parser/document.rb
CHANGED
|
@@ -4,7 +4,7 @@ require_relative "cursor"
|
|
|
4
4
|
require_relative "block"
|
|
5
5
|
|
|
6
6
|
module CtxParser
|
|
7
|
-
#
|
|
7
|
+
# 持有配置树及诊断;构建器解释语法,Block 只约束来源范围。
|
|
8
8
|
class Document
|
|
9
9
|
attr_reader :cursor, :roots, :issues
|
|
10
10
|
|
|
@@ -18,7 +18,8 @@ module CtxParser
|
|
|
18
18
|
end
|
|
19
19
|
@cursor = source.is_a?(Cursor) ? source : Cursor.new(source, max_bytes: max_bytes)
|
|
20
20
|
@comments, @boundaries, @issues = comments, boundaries, []
|
|
21
|
-
|
|
21
|
+
cursor.rewind
|
|
22
|
+
@roots = validate_roots(build_blocks)
|
|
22
23
|
finish
|
|
23
24
|
@issues = Immutable.copy(@issues)
|
|
24
25
|
end
|
|
@@ -33,6 +34,9 @@ module CtxParser
|
|
|
33
34
|
|
|
34
35
|
protected
|
|
35
36
|
|
|
37
|
+
# 默认按缩进构建;原生语法可覆盖此步骤,继续共享来源初始化和校验。
|
|
38
|
+
def build_blocks = Block.read(cursor) { |line| classify(line) }
|
|
39
|
+
|
|
36
40
|
# 允许有状态词法分析器将行分类为配置条目、忽略行或边界。
|
|
37
41
|
def classify(line)
|
|
38
42
|
return :boundary if @boundaries&.match?(line.text)
|
|
@@ -43,5 +47,39 @@ module CtxParser
|
|
|
43
47
|
|
|
44
48
|
# 允许词法分析器在文档结束时报告未闭合上下文,不向公共配置块添加词法状态。
|
|
45
49
|
def finish; end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# 根块可以留出注释或边界行,但不能重叠、倒序或引用别的来源。
|
|
54
|
+
def validate_roots(entries)
|
|
55
|
+
entries = Array.new(entries) if entries.is_a?(Array)
|
|
56
|
+
previous = -1
|
|
57
|
+
unless entries.is_a?(Array) && entries.all? do |entry|
|
|
58
|
+
next false unless entry.is_a?(Block) && entry.cursor.equal?(cursor) && entry.index > previous
|
|
59
|
+
|
|
60
|
+
previous = entry.last_index
|
|
61
|
+
true
|
|
62
|
+
end
|
|
63
|
+
raise ArgumentError, "Root blocks must be ordered within this document"
|
|
64
|
+
end
|
|
65
|
+
entries.freeze
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# set 等逐行配置没有缩进父子关系,每行独立保留来源状态。
|
|
69
|
+
class Flat < Document
|
|
70
|
+
protected
|
|
71
|
+
|
|
72
|
+
def build_blocks
|
|
73
|
+
entries = []
|
|
74
|
+
while (line = cursor.read)
|
|
75
|
+
case classify(line)
|
|
76
|
+
when :entry then entries << cursor.block(line.index)
|
|
77
|
+
when :ignored, :boundary then cursor.mark(line.index, state: :ignored)
|
|
78
|
+
else raise ArgumentError, "Invalid source line disposition"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
entries
|
|
82
|
+
end
|
|
83
|
+
end
|
|
46
84
|
end
|
|
47
85
|
end
|
data/lib/ctx_parser/immutable.rb
CHANGED
|
@@ -11,6 +11,13 @@ module CtxParser
|
|
|
11
11
|
copy_value(value, {}.compare_by_identity, 0)
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
# 重建纯文本后交给 Ruby 去重,不继承任何可见性的方法或附加状态。
|
|
15
|
+
def self.copy_string(value)
|
|
16
|
+
raise ArgumentError, "Value must be text" unless value.is_a?(String)
|
|
17
|
+
|
|
18
|
+
-String.new(value)
|
|
19
|
+
end
|
|
20
|
+
|
|
14
21
|
# 普通常量浅树沿用 Ruby 的映射;递归深度有界,深链交给显式后序栈。
|
|
15
22
|
def self.copy_value(value, copies, depth)
|
|
16
23
|
case value
|
|
@@ -23,18 +30,14 @@ module CtxParser
|
|
|
23
30
|
return copy_deep(value, copies) if depth == 64
|
|
24
31
|
|
|
25
32
|
copies[value] = COPYING
|
|
26
|
-
if reusable?(value, copies, depth)
|
|
27
|
-
copies[value] = value
|
|
28
|
-
return value
|
|
29
|
-
end
|
|
30
33
|
copies[value] = if value.is_a?(Hash)
|
|
31
34
|
result = {}
|
|
32
35
|
value.each_pair { |key, item| result[copy_value(key, copies, depth + 1)] = copy_value(item, copies, depth + 1) }
|
|
33
36
|
result.freeze
|
|
34
37
|
else
|
|
35
|
-
value.map { |item| copy_value(item, copies, depth + 1) }.freeze
|
|
38
|
+
Array.new(value).map! { |item| copy_value(item, copies, depth + 1) }.freeze
|
|
36
39
|
end
|
|
37
|
-
when String then value
|
|
40
|
+
when String then copy_string(value)
|
|
38
41
|
when Symbol, Integer, Float, Rational, NilClass, TrueClass, FalseClass then value
|
|
39
42
|
when Complex
|
|
40
43
|
# Complex 自身冻结,但可包含调用方定义的可变 Numeric 分量。
|
|
@@ -46,36 +49,18 @@ module CtxParser
|
|
|
46
49
|
end
|
|
47
50
|
private_class_method :copy_value
|
|
48
51
|
|
|
49
|
-
# 仅复用无特殊 Hash 行为的深冻结普通容器;逐层验证,不信任外层 frozen?。
|
|
50
|
-
def self.reusable?(value, copies, depth)
|
|
51
|
-
return false unless value.frozen? && value.instance_variables.empty?
|
|
52
|
-
if value.instance_of?(Array)
|
|
53
|
-
value.all? { |item| copy_value(item, copies, depth + 1).equal?(item) }
|
|
54
|
-
elsif value.instance_of?(Hash) && value.default.nil? && !value.default_proc && !value.compare_by_identity?
|
|
55
|
-
value.each_pair do |key, item|
|
|
56
|
-
return false unless copy_value(key, copies, depth + 1).equal?(key) && copy_value(item, copies, depth + 1).equal?(item)
|
|
57
|
-
end
|
|
58
|
-
true
|
|
59
|
-
else
|
|
60
|
-
false
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
private_class_method :reusable?
|
|
64
|
-
|
|
65
52
|
# 容器缓存按来源身份查找,普通 Hash 的键仍沿用值相等语义。
|
|
66
53
|
def self.copy_deep(value, copies)
|
|
67
54
|
pending = [ [ value, false ] ]
|
|
68
55
|
until pending.empty?
|
|
69
56
|
source, finishing = pending.pop
|
|
70
57
|
if finishing
|
|
71
|
-
copies[source] = if
|
|
72
|
-
source
|
|
73
|
-
elsif source.is_a?(Hash)
|
|
58
|
+
copies[source] = if source.is_a?(Hash)
|
|
74
59
|
result = {}
|
|
75
60
|
source.each_pair { |key, item| result[snapshot(key, copies)] = snapshot(item, copies) }
|
|
76
61
|
result.freeze
|
|
77
62
|
else
|
|
78
|
-
source.map { |item| snapshot(item, copies) }.freeze
|
|
63
|
+
Array.new(source).map! { |item| snapshot(item, copies) }.freeze
|
|
79
64
|
end
|
|
80
65
|
next
|
|
81
66
|
end
|
|
@@ -93,7 +78,7 @@ module CtxParser
|
|
|
93
78
|
pending << [ key, false ] if container?(key)
|
|
94
79
|
end
|
|
95
80
|
else
|
|
96
|
-
source.each { |item| pending << [ item, false ] if container?(item) }
|
|
81
|
+
Array.new(source).each { |item| pending << [ item, false ] if container?(item) }
|
|
97
82
|
end
|
|
98
83
|
end
|
|
99
84
|
copies.fetch(value)
|
data/lib/ctx_parser/input.rb
CHANGED
|
@@ -8,13 +8,13 @@ module CtxParser
|
|
|
8
8
|
# 校验来源文本,仅按请求调整编码或换行形式。
|
|
9
9
|
def text(content, encoding: :preserve, newlines: :preserve, allow_nul: false, max_bytes: nil)
|
|
10
10
|
raise ArgumentError, "CLI output must be text" unless content.is_a?(String)
|
|
11
|
+
value = String.new(content)
|
|
11
12
|
raise ArgumentError, "Invalid encoding policy" unless %i[preserve utf8 transcode].include?(encoding)
|
|
12
13
|
raise ArgumentError, "Invalid newline policy" unless %i[preserve lf delete_cr].include?(newlines)
|
|
13
14
|
raise ArgumentError, "NUL policy must be boolean" unless [ true, false ].include?(allow_nul)
|
|
14
15
|
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 &&
|
|
16
|
+
raise ArgumentError, "CLI output exceeds byte limit" if max_bytes && value.bytesize > max_bytes
|
|
16
17
|
|
|
17
|
-
value = content.dup
|
|
18
18
|
value.force_encoding(Encoding::UTF_8) if encoding == :utf8 || value.encoding == Encoding::BINARY
|
|
19
19
|
raise EncodingError, "CLI output encoding is invalid" unless value.valid_encoding?
|
|
20
20
|
|
data/lib/ctx_parser/line.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "immutable"
|
|
4
|
+
|
|
3
5
|
module CtxParser
|
|
4
6
|
# 不可变来源行保留原始换行,同时提供用于语法匹配的归一化文本。
|
|
5
7
|
Line = Data.define(:index, :raw) do
|
|
@@ -8,10 +10,11 @@ module CtxParser
|
|
|
8
10
|
raise ArgumentError, "Line index must be nonnegative" unless index.is_a?(Integer) && index >= 0
|
|
9
11
|
raise ArgumentError, "Line content must be text" unless raw.is_a?(String)
|
|
10
12
|
|
|
13
|
+
raw = Immutable.copy_string(raw)
|
|
11
14
|
# strip 已移除行结束空白,避免 chomp 依赖宿主进程的 $/。
|
|
12
15
|
@text = raw.strip.freeze
|
|
13
16
|
@indent = raw[/\A[ \t]*/].size
|
|
14
|
-
super(index: index, raw: raw
|
|
17
|
+
super(index: index, raw: raw)
|
|
15
18
|
end
|
|
16
19
|
|
|
17
20
|
# 返回诊断使用的从 1 开始的行号。
|
data/lib/ctx_parser/profile.rb
CHANGED
|
@@ -3,85 +3,72 @@
|
|
|
3
3
|
require_relative "commands"
|
|
4
4
|
|
|
5
5
|
module CtxParser
|
|
6
|
-
#
|
|
6
|
+
# 组合一份不可变命令声明、型号选择与独立视图投影。
|
|
7
7
|
class Profile
|
|
8
|
-
|
|
9
|
-
PARSERS = {}.freeze
|
|
8
|
+
DECLARATIONS = Commands.new([])
|
|
10
9
|
MODEL_PARSERS = {}.freeze
|
|
11
10
|
|
|
12
|
-
attr_reader :model
|
|
11
|
+
attr_reader :model, :declarations
|
|
13
12
|
|
|
14
|
-
#
|
|
15
|
-
def initialize(model: nil)
|
|
13
|
+
# 静态与发现目录使用同一边界;每个实例持有完整声明快照。
|
|
14
|
+
def initialize(model: nil, declarations: self.class::DECLARATIONS)
|
|
16
15
|
raise ArgumentError, "Device model must be text" unless model.nil? || model.is_a?(String)
|
|
17
16
|
|
|
18
|
-
@model = model
|
|
19
|
-
@model_parsers =
|
|
20
|
-
|
|
17
|
+
@model = model && String.new(model).strip.downcase.freeze
|
|
18
|
+
@model_parsers = self.class.send(:normalized_model_parsers, declarations).fetch(@model, {}.freeze)
|
|
19
|
+
@declarations = declarations
|
|
21
20
|
end
|
|
22
21
|
|
|
23
|
-
#
|
|
24
|
-
def
|
|
25
|
-
|
|
26
|
-
unless
|
|
27
|
-
|
|
28
|
-
raise ArgumentError, "Profile commands and parsers must be named mappings"
|
|
22
|
+
# 复制策略实例后一次替换声明;校验失败不影响原实例或型号覆盖。
|
|
23
|
+
def with_declarations(declarations)
|
|
24
|
+
raise ArgumentError, "Profile declarations must be Commands" unless declarations.is_a?(Commands)
|
|
25
|
+
unless @model_parsers.each_key.all? { |command| declarations.parsers.key?(command) }
|
|
26
|
+
raise ArgumentError, "Model parsers must override declared commands"
|
|
29
27
|
end
|
|
30
28
|
|
|
31
|
-
|
|
32
|
-
entries = value.is_a?(Array) ? value : [ value ]
|
|
33
|
-
unless entries.any? && entries.uniq == entries && entries.all? { |entry| valid_command?(entry) }
|
|
34
|
-
raise ArgumentError, "Parser dimension must declare one or more unique commands"
|
|
35
|
-
end
|
|
36
|
-
entries
|
|
37
|
-
end.uniq
|
|
38
|
-
unless (queries - implementations.keys).empty? && (implementations.keys - queries).empty? &&
|
|
39
|
-
implementations.values.all? { |parser| parser.is_a?(Class) && parser < Parser }
|
|
40
|
-
raise ArgumentError, "Profile commands must match Parser implementations"
|
|
41
|
-
end
|
|
42
|
-
normalized_model_parsers
|
|
43
|
-
true
|
|
29
|
+
dup.tap { |copy| copy.replace_declarations(declarations) }
|
|
44
30
|
end
|
|
45
31
|
|
|
46
|
-
#
|
|
47
|
-
def self.
|
|
48
|
-
|
|
32
|
+
# 注册与实例创建共同校验目录和全部型号覆盖,拒绝旧的双目录声明。
|
|
33
|
+
def self.validate_declarations!(declarations = self::DECLARATIONS)
|
|
34
|
+
normalized_model_parsers(declarations)
|
|
49
35
|
true
|
|
50
|
-
rescue ArgumentError
|
|
51
|
-
false
|
|
52
36
|
end
|
|
53
|
-
private_class_method :valid_command?
|
|
54
|
-
|
|
55
|
-
# 返回当前平台策略声明的命令目录。
|
|
56
|
-
def commands = self.class::COMMANDS
|
|
57
37
|
|
|
58
|
-
#
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
unless dimensions.is_a?(Array) && dimensions.uniq == dimensions && (dimensions - commands.keys).empty?
|
|
64
|
-
raise ArgumentError, "Query dimensions must be unique supported names"
|
|
38
|
+
# 型号目录在实例创建时快照;后续派生实例继承已经选定的覆盖。
|
|
39
|
+
def self.normalized_model_parsers(declarations)
|
|
40
|
+
owners = ancestors.take_while { |ancestor| ancestor != Object }
|
|
41
|
+
if owners.any? { |owner| owner.const_defined?(:COMMANDS, false) || owner.const_defined?(:PARSERS, false) }
|
|
42
|
+
raise ArgumentError, "Declare Commands through DECLARATIONS instead of COMMANDS/PARSERS"
|
|
65
43
|
end
|
|
44
|
+
raise ArgumentError, "Profile declarations must be Commands" unless declarations.is_a?(Commands)
|
|
45
|
+
raise ArgumentError, "Model parsers must be a hash" unless self::MODEL_PARSERS.is_a?(Hash)
|
|
66
46
|
|
|
67
|
-
|
|
68
|
-
|
|
47
|
+
normalized = {}
|
|
48
|
+
self::MODEL_PARSERS.each do |name, overrides|
|
|
49
|
+
unless name.nil? || name.is_a?(String) && !name.strip.empty?
|
|
50
|
+
raise ArgumentError, "Model parser keys must be device model names"
|
|
51
|
+
end
|
|
52
|
+
key = name && String.new(name).strip.downcase.freeze
|
|
53
|
+
raise ArgumentError, "Model parser names must be unique after normalization" if normalized.key?(key)
|
|
54
|
+
unless overrides.is_a?(Hash) && overrides.all? do |command, parser|
|
|
55
|
+
declarations.parsers.key?(command) && parser.is_a?(Class) && parser < Parser
|
|
56
|
+
end
|
|
57
|
+
raise ArgumentError, "Model parsers must override declared commands with Parser subclasses"
|
|
58
|
+
end
|
|
59
|
+
normalized[key] = overrides.to_h { |command, parser| [ Commands.command_text(command), parser ] }.freeze
|
|
69
60
|
end
|
|
70
|
-
|
|
71
|
-
dimensions_by_command.freeze
|
|
61
|
+
normalized.freeze
|
|
72
62
|
end
|
|
63
|
+
private_class_method :normalized_model_parsers
|
|
73
64
|
|
|
74
|
-
#
|
|
75
|
-
def
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return cached[:queries].fetch(name) if cached
|
|
80
|
-
|
|
81
|
-
validated_queries(declaration)
|
|
82
|
-
end
|
|
65
|
+
# 只读索引是声明的投影,不作为独立扩展入口。
|
|
66
|
+
def commands = declarations.by_dimension
|
|
67
|
+
def parsers = declarations.parsers
|
|
68
|
+
def queries(dimensions) = declarations.queries(dimensions)
|
|
69
|
+
def commands_for(name) = declarations.commands_for(name)
|
|
83
70
|
|
|
84
|
-
#
|
|
71
|
+
# 单维度仍可能需要多条命令;只允许唯一命令隐式选择。
|
|
85
72
|
def command(name)
|
|
86
73
|
queries = commands_for(name)
|
|
87
74
|
raise ArgumentError, "Parser dimension has multiple commands; select one explicitly" unless queries.one?
|
|
@@ -89,10 +76,11 @@ module CtxParser
|
|
|
89
76
|
queries.first
|
|
90
77
|
end
|
|
91
78
|
|
|
92
|
-
#
|
|
79
|
+
# 命令及其实现从同一份目录读取,再应用已校验的型号覆盖。
|
|
93
80
|
def parser_for(name, command: nil)
|
|
94
|
-
|
|
95
|
-
|
|
81
|
+
catalog = declarations
|
|
82
|
+
query = catalog.select_command([ name ], command: command)
|
|
83
|
+
@model_parsers.fetch(query) { catalog.parsers.fetch(query) }
|
|
96
84
|
end
|
|
97
85
|
|
|
98
86
|
# 选择目标命令解析器,并投影单个请求维度。
|
|
@@ -100,127 +88,26 @@ module CtxParser
|
|
|
100
88
|
parse_many([ name ], text, complete: complete, command: command).fetch(name)
|
|
101
89
|
end
|
|
102
90
|
|
|
103
|
-
#
|
|
91
|
+
# 共享视图仅解析一次,扩展回调不能改变已经固定的请求或目录。
|
|
104
92
|
def parse_many(dimensions, text, complete: false, command: nil)
|
|
93
|
+
dimensions = Array.new(dimensions) if dimensions.is_a?(Array)
|
|
105
94
|
unless dimensions.is_a?(Array) && dimensions.any? && dimensions.uniq == dimensions
|
|
106
95
|
raise ArgumentError, "Parser dimensions must be a nonempty unique array"
|
|
107
96
|
end
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
query = select_command(dimensions, command)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
result = implementation_for(query).parse(text, complete: complete)
|
|
97
|
+
dimensions.freeze
|
|
98
|
+
catalog = declarations
|
|
99
|
+
query = catalog.select_command(dimensions, command: command)
|
|
100
|
+
implementation = @model_parsers.fetch(query) { catalog.parsers.fetch(query) }
|
|
101
|
+
result = implementation.parse(text, complete: complete)
|
|
114
102
|
dimensions.to_h { |name| [ name, project_independently(name, result) ] }
|
|
115
103
|
end
|
|
116
104
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
# 发现阶段可替换实例目录;只缓存当前深冻结目录,不继承旧目录的选择结果。
|
|
120
|
-
def cache_commands(directory, strict: false)
|
|
121
|
-
cached = @command_cache
|
|
122
|
-
return cached if cached && directory.equal?(cached[:directory])
|
|
123
|
-
return if frozen?
|
|
124
|
-
|
|
125
|
-
queries = compile_commands(directory)
|
|
126
|
-
return unless queries
|
|
127
|
-
|
|
128
|
-
membership = queries.transform_values { |entries| entries.to_h { |query| [ query, true ] }.freeze }.freeze
|
|
129
|
-
# 一次发布完整索引;并发查找只读取自己取得的快照,不混合两份目录。
|
|
130
|
-
@command_cache = { directory: directory, queries: queries, membership: membership }.freeze
|
|
131
|
-
rescue ArgumentError
|
|
132
|
-
# 动态目录仍按请求维度报错,未使用的无效声明不能阻断其他维度。
|
|
133
|
-
raise if strict
|
|
134
|
-
|
|
135
|
-
nil
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
# 可变容器或文本仍逐次校验;普通深冻结目录允许共享验证后的顺序与成员索引。
|
|
139
|
-
def compile_commands(directory)
|
|
140
|
-
return unless directory.instance_of?(Hash) && directory.frozen? && !directory.compare_by_identity? &&
|
|
141
|
-
directory.keys.all? { |name| name.is_a?(Symbol) && !name.empty? } && directory.values.all? do |value|
|
|
142
|
-
entries = value.is_a?(Array) ? value : [ value ]
|
|
143
|
-
(!value.is_a?(Array) || value.instance_of?(Array) && value.frozen?) &&
|
|
144
|
-
entries.all? { |query| query.instance_of?(String) && query.frozen? }
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
directory.to_h { |name, declaration| [ name, validated_queries(declaration) ] }.freeze
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
# 查询快照不冻结调用方的数组或字符串。
|
|
151
|
-
def validated_queries(declaration)
|
|
152
|
-
queries = declaration.is_a?(Array) ? declaration : [ declaration ]
|
|
153
|
-
unless queries.any? && queries.uniq == queries
|
|
154
|
-
raise ArgumentError, "Parser dimension must declare one or more unique commands"
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
queries.map { |query| Commands.command_text(query) }.freeze
|
|
158
|
-
end
|
|
105
|
+
protected
|
|
159
106
|
|
|
160
|
-
#
|
|
161
|
-
def
|
|
162
|
-
declarations = dimensions.map do |name|
|
|
163
|
-
queries = commands_for(name)
|
|
164
|
-
cached = @command_cache
|
|
165
|
-
membership = cached[:membership][name] if cached && queries.equal?(cached[:queries][name])
|
|
166
|
-
[ queries, membership ]
|
|
167
|
-
end
|
|
168
|
-
if selected.nil?
|
|
169
|
-
candidates = declarations.map(&:first).reduce { |common, queries| common & queries }
|
|
170
|
-
raise ArgumentError, "One CLI output must belong to one command" unless candidates.one?
|
|
171
|
-
|
|
172
|
-
candidates.first
|
|
173
|
-
else
|
|
174
|
-
supported = declarations.all? do |queries, membership|
|
|
175
|
-
if selected.instance_of?(String) && membership
|
|
176
|
-
membership.key?(selected)
|
|
177
|
-
else
|
|
178
|
-
queries.include?(selected)
|
|
179
|
-
end
|
|
180
|
-
end
|
|
181
|
-
raise ArgumentError, "Selected command does not provide all parser dimensions" unless supported
|
|
107
|
+
# 仅在副本上安装已经校验的完整目录,不重跑子类初始化。
|
|
108
|
+
def replace_declarations(declarations) = @declarations = declarations
|
|
182
109
|
|
|
183
|
-
|
|
184
|
-
end
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
# 采集前校验所选型号,并隔离后续修改对型号查找的影响。
|
|
188
|
-
def self.normalized_model_parsers
|
|
189
|
-
declarations = self::MODEL_PARSERS
|
|
190
|
-
raise ArgumentError, "Model parsers must be a hash" unless declarations.is_a?(Hash)
|
|
191
|
-
|
|
192
|
-
normalized = {}
|
|
193
|
-
declarations.each do |name, overrides|
|
|
194
|
-
unless name.nil? || name.is_a?(String) && !name.strip.empty?
|
|
195
|
-
raise ArgumentError, "Model parser keys must be device model names"
|
|
196
|
-
end
|
|
197
|
-
key = name&.strip&.downcase
|
|
198
|
-
raise ArgumentError, "Model parser names must be unique after normalization" if normalized.key?(key)
|
|
199
|
-
unless overrides.is_a?(Hash) && overrides.all? do |command, parser|
|
|
200
|
-
self::PARSERS.key?(command) && parser.is_a?(Class) && parser < Parser
|
|
201
|
-
end
|
|
202
|
-
raise ArgumentError, "Model parsers must override declared commands with Parser subclasses"
|
|
203
|
-
end
|
|
204
|
-
normalized[key] = overrides
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
normalized
|
|
208
|
-
end
|
|
209
|
-
private_class_method :normalized_model_parsers
|
|
210
|
-
|
|
211
|
-
# 注册与实例选择共用声明校验,所选覆盖仍独立于后续调用方修改。
|
|
212
|
-
def model_parser_overrides
|
|
213
|
-
self.class.send(:normalized_model_parsers).fetch(model, {}).dup.freeze
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
# 动态目录的错误仍在调用边界报告,不暴露底层 Hash 的 KeyError。
|
|
217
|
-
def implementation_for(query)
|
|
218
|
-
implementation = @model_parsers.fetch(query) { parsers.fetch(query) { raise ArgumentError, "Missing command parser" } }
|
|
219
|
-
unless implementation.is_a?(Class) && implementation < Parser
|
|
220
|
-
raise ArgumentError, "Command parser must inherit from Parser"
|
|
221
|
-
end
|
|
222
|
-
implementation
|
|
223
|
-
end
|
|
110
|
+
private
|
|
224
111
|
|
|
225
112
|
# 投影失败仅影响单个视图;命令选择及解析错误仍正常抛出。
|
|
226
113
|
def project_independently(name, result)
|
data/lib/ctx_parser/records.rb
CHANGED
|
@@ -7,6 +7,7 @@ module CtxParser
|
|
|
7
7
|
|
|
8
8
|
# 冻结筛选结果数组,记录值的所有权仍属于调用方。
|
|
9
9
|
def initialize(rows, identity: nil, &identity_key)
|
|
10
|
+
rows = Array.new(rows) if rows.is_a?(Array)
|
|
10
11
|
unless rows.is_a?(Array) && rows.all? { |row| row.is_a?(Hash) }
|
|
11
12
|
raise ArgumentError, "Records must be hashes"
|
|
12
13
|
end
|
|
@@ -15,7 +16,7 @@ module CtxParser
|
|
|
15
16
|
identity_key ||= ->(row) { row.values_at(*identity) } if identity.is_a?(Array) && identity.any?
|
|
16
17
|
raise ArgumentError, "Identity must be a nonempty array or callable" unless identity_key
|
|
17
18
|
|
|
18
|
-
@rows = rows.
|
|
19
|
+
@rows = rows.freeze
|
|
19
20
|
positions = {}
|
|
20
21
|
first_occurrences, unambiguous = [], []
|
|
21
22
|
@duplicate_identity_count = 0
|
data/lib/ctx_parser/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ctx_parser
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gatework contributors
|
|
@@ -76,8 +76,11 @@ files:
|
|
|
76
76
|
- README-zh.md
|
|
77
77
|
- README.md
|
|
78
78
|
- docs/architecture-zh.md
|
|
79
|
+
- docs/contracts-0.5.md
|
|
79
80
|
- docs/input-performance.md
|
|
81
|
+
- docs/performance-0.5.md
|
|
80
82
|
- docs/release.md
|
|
83
|
+
- examples/declarations.rb
|
|
81
84
|
- examples/offline.rb
|
|
82
85
|
- lib/ctx_parser.rb
|
|
83
86
|
- lib/ctx_parser/banner.rb
|