@aryacode/dsh-engineering 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +115 -0
- package/LICENSE +21 -0
- package/README.md +228 -2
- package/cordis.patch.yml +122 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +77 -3
- package/vendor/ftview/README.md +55 -0
- package/vendor/ftview/SKILL.md +74 -0
- package/vendor/ftview/lib/checks.d.ts +27 -0
- package/vendor/ftview/lib/checks.js +39 -0
- package/vendor/ftview/lib/core.d.ts +208 -0
- package/vendor/ftview/lib/core.js +160 -0
- package/vendor/ftview/lib/edit/drafts.d.ts +49 -0
- package/vendor/ftview/lib/edit/drafts.js +84 -0
- package/vendor/ftview/lib/edit/operations.d.ts +62 -0
- package/vendor/ftview/lib/edit/operations.js +126 -0
- package/vendor/ftview/lib/edit/prepare.d.ts +38 -0
- package/vendor/ftview/lib/edit/prepare.js +133 -0
- package/vendor/ftview/lib/edit/tools.d.ts +25 -0
- package/vendor/ftview/lib/edit/tools.js +198 -0
- package/vendor/ftview/lib/edit.d.ts +30 -0
- package/vendor/ftview/lib/edit.js +38 -0
- package/vendor/ftview/lib/index.d.ts +31 -0
- package/vendor/ftview/lib/index.js +36 -0
- package/vendor/ftview/lib/model.d.ts +80 -0
- package/vendor/ftview/lib/model.js +267 -0
- package/vendor/ftview/lib/session.d.ts +48 -0
- package/vendor/ftview/lib/session.js +98 -0
- package/vendor/ftview/lib/skill.d.ts +29 -0
- package/vendor/ftview/lib/skill.js +43 -0
- package/vendor/ftview/lib/studio/discover.d.ts +36 -0
- package/vendor/ftview/lib/studio/discover.js +87 -0
- package/vendor/ftview/lib/studio/preflight.d.ts +51 -0
- package/vendor/ftview/lib/studio/preflight.js +64 -0
- package/vendor/ftview/lib/studio/tools.d.ts +15 -0
- package/vendor/ftview/lib/studio/tools.js +83 -0
- package/vendor/ftview/lib/studio.d.ts +30 -0
- package/vendor/ftview/lib/studio.js +37 -0
- package/vendor/ftview/lib/tools.d.ts +14 -0
- package/vendor/ftview/lib/tools.js +202 -0
- package/vendor/l5x/README.md +62 -0
- package/vendor/l5x/SKILL.md +81 -0
- package/vendor/l5x/lib/approvals.d.ts +50 -0
- package/vendor/l5x/lib/approvals.js +93 -0
- package/vendor/l5x/lib/client.js +412 -0
- package/vendor/l5x/lib/client.js.map +1 -0
- package/vendor/l5x/lib/core/errors.d.ts +38 -0
- package/vendor/l5x/lib/core/errors.js +48 -0
- package/vendor/l5x/lib/core/params.d.ts +49 -0
- package/vendor/l5x/lib/core/params.js +39 -0
- package/vendor/l5x/lib/core/result.d.ts +46 -0
- package/vendor/l5x/lib/core/result.js +21 -0
- package/vendor/l5x/lib/core/tool.d.ts +102 -0
- package/vendor/l5x/lib/core/tool.js +19 -0
- package/vendor/l5x/lib/drafts.d.ts +66 -0
- package/vendor/l5x/lib/drafts.js +98 -0
- package/vendor/l5x/lib/editing.d.ts +30 -0
- package/vendor/l5x/lib/editing.js +42 -0
- package/vendor/l5x/lib/engine/acd-edit.d.ts +13 -0
- package/vendor/l5x/lib/engine/acd-edit.js +115 -0
- package/vendor/l5x/lib/engine/analysis.d.ts +22 -0
- package/vendor/l5x/lib/engine/analysis.js +258 -0
- package/vendor/l5x/lib/engine/capabilities.d.ts +9 -0
- package/vendor/l5x/lib/engine/capabilities.js +40 -0
- package/vendor/l5x/lib/engine/change-set.d.ts +122 -0
- package/vendor/l5x/lib/engine/change-set.js +446 -0
- package/vendor/l5x/lib/engine/configuration.d.ts +21 -0
- package/vendor/l5x/lib/engine/configuration.js +42 -0
- package/vendor/l5x/lib/engine/details.d.ts +47 -0
- package/vendor/l5x/lib/engine/details.js +173 -0
- package/vendor/l5x/lib/engine/direct-polling.d.ts +91 -0
- package/vendor/l5x/lib/engine/direct-polling.js +193 -0
- package/vendor/l5x/lib/engine/download-review.d.ts +15 -0
- package/vendor/l5x/lib/engine/download-review.js +63 -0
- package/vendor/l5x/lib/engine/logic-syntax.d.ts +60 -0
- package/vendor/l5x/lib/engine/logic-syntax.js +237 -0
- package/vendor/l5x/lib/engine/observations.d.ts +41 -0
- package/vendor/l5x/lib/engine/observations.js +67 -0
- package/vendor/l5x/lib/engine/project-analysis.d.ts +131 -0
- package/vendor/l5x/lib/engine/project-analysis.js +314 -0
- package/vendor/l5x/lib/engine/review-evidence.d.ts +8 -0
- package/vendor/l5x/lib/engine/review-evidence.js +49 -0
- package/vendor/l5x/lib/engine/review.d.ts +18 -0
- package/vendor/l5x/lib/engine/review.js +123 -0
- package/vendor/l5x/lib/engine/studio-sdk.d.ts +101 -0
- package/vendor/l5x/lib/engine/studio-sdk.js +571 -0
- package/vendor/l5x/lib/engine/tag-resolution.d.ts +20 -0
- package/vendor/l5x/lib/engine/tag-resolution.js +169 -0
- package/vendor/l5x/lib/engine/usage.d.ts +16 -0
- package/vendor/l5x/lib/engine/usage.js +92 -0
- package/vendor/l5x/lib/engine/xml-regions.d.ts +23 -0
- package/vendor/l5x/lib/engine/xml-regions.js +77 -0
- package/vendor/l5x/lib/fs.d.ts +17 -0
- package/vendor/l5x/lib/fs.js +36 -0
- package/vendor/l5x/lib/index.d.ts +36 -0
- package/vendor/l5x/lib/index.js +42 -0
- package/vendor/l5x/lib/live.d.ts +48 -0
- package/vendor/l5x/lib/live.js +48 -0
- package/vendor/l5x/lib/model/aois.d.ts +39 -0
- package/vendor/l5x/lib/model/aois.js +112 -0
- package/vendor/l5x/lib/model/errors.d.ts +15 -0
- package/vendor/l5x/lib/model/errors.js +18 -0
- package/vendor/l5x/lib/model/index.d.ts +20 -0
- package/vendor/l5x/lib/model/index.js +19 -0
- package/vendor/l5x/lib/model/module.d.ts +37 -0
- package/vendor/l5x/lib/model/module.js +54 -0
- package/vendor/l5x/lib/model/modules.d.ts +24 -0
- package/vendor/l5x/lib/model/modules.js +55 -0
- package/vendor/l5x/lib/model/parse.d.ts +32 -0
- package/vendor/l5x/lib/model/parse.js +116 -0
- package/vendor/l5x/lib/model/project.d.ts +15 -0
- package/vendor/l5x/lib/model/project.js +31 -0
- package/vendor/l5x/lib/model/routines.d.ts +71 -0
- package/vendor/l5x/lib/model/routines.js +183 -0
- package/vendor/l5x/lib/model/tags.d.ts +51 -0
- package/vendor/l5x/lib/model/tags.js +119 -0
- package/vendor/l5x/lib/model/types.d.ts +304 -0
- package/vendor/l5x/lib/model/types.js +12 -0
- package/vendor/l5x/lib/model/udts.d.ts +34 -0
- package/vendor/l5x/lib/model/udts.js +81 -0
- package/vendor/l5x/lib/model/xml.d.ts +139 -0
- package/vendor/l5x/lib/model/xml.js +291 -0
- package/vendor/l5x/lib/model/xref.d.ts +28 -0
- package/vendor/l5x/lib/model/xref.js +154 -0
- package/vendor/l5x/lib/session.d.ts +119 -0
- package/vendor/l5x/lib/session.js +236 -0
- package/vendor/l5x/lib/skill.d.ts +30 -0
- package/vendor/l5x/lib/skill.js +44 -0
- package/vendor/l5x/lib/studio-state.d.ts +77 -0
- package/vendor/l5x/lib/studio-state.js +79 -0
- package/vendor/l5x/lib/studio.d.ts +29 -0
- package/vendor/l5x/lib/studio.js +40 -0
- package/vendor/l5x/lib/tools/checks.d.ts +19 -0
- package/vendor/l5x/lib/tools/checks.js +140 -0
- package/vendor/l5x/lib/tools/common.d.ts +41 -0
- package/vendor/l5x/lib/tools/common.js +84 -0
- package/vendor/l5x/lib/tools/controller.d.ts +21 -0
- package/vendor/l5x/lib/tools/controller.js +170 -0
- package/vendor/l5x/lib/tools/drafts.d.ts +23 -0
- package/vendor/l5x/lib/tools/drafts.js +270 -0
- package/vendor/l5x/lib/tools/live.d.ts +21 -0
- package/vendor/l5x/lib/tools/live.js +175 -0
- package/vendor/l5x/lib/tools/read.d.ts +19 -0
- package/vendor/l5x/lib/tools/read.js +245 -0
- package/vendor/l5x/lib/tools/references.d.ts +20 -0
- package/vendor/l5x/lib/tools/references.js +269 -0
- package/vendor/l5x/lib/tools/studio.d.ts +21 -0
- package/vendor/l5x/lib/tools/studio.js +193 -0
- package/vendor/l5x/poll-worker.mjs +77 -0
- package/vendor/l5x/studio-bridge/NuGet.Config +1 -0
- package/vendor/l5x/studio-bridge/Program.cs +304 -0
- package/vendor/l5x/studio-bridge/StudioBridge.csproj +11 -0
- package/vendor/mwi/README.md +60 -0
- package/vendor/mwi/SKILL.md +87 -0
- package/vendor/mwi/bridge/README.md +8 -0
- package/vendor/mwi/bridge/mw_bridge.ps1 +2763 -0
- package/vendor/mwi/bridge/sdk-interop-importer.cs +30 -0
- package/vendor/mwi/bridge/sdk-variable-reader.cs +22 -0
- package/vendor/mwi/bridge/start_bridge.cmd +21 -0
- package/vendor/mwi/bridge/watch_dialogs.cmd +13 -0
- package/vendor/mwi/bridge/watch_dialogs.ps1 +153 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__init__.py +14 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/__init__.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/block_interfaces.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/cfb.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/datatypes.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/declarations.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/errors.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/graphical_listing.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/grid.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/knowledge.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/mwt_bind.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/navigation.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/program_checks.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/project.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/staging.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/structure.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/task_bindings.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tasks.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tree.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/validation.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variable_edit.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variables.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/workflow.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/block_interfaces.py +275 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/build.py +339 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/capture.py +448 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/cfb.py +765 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/com.py +268 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/datatypes.py +204 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/declarations.py +399 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/descriptions.py +30 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/eip.py +292 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/errors.py +36 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/graphical_listing.py +208 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/grid.py +227 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/ide.py +317 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/iec.py +317 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/installed_help.py +151 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/knowledge.py +218 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/libraries.py +250 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/manuals.py +520 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/mwt_bind.py +133 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/navigation.py +44 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/pou_writer.py +1547 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/program_checks.py +360 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/project.py +704 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/catalog.json +672 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/patterns.json +86 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/restore.py +208 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/server.py +1913 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/snapshot.py +138 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/st_comments.py +25 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/staging.py +179 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/stlint.py +676 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/structure.py +98 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/task_bindings.py +68 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/tasks.py +162 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/transaction.py +157 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/tree.py +563 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/tree_writer.py +790 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/uia.py +465 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/validation.py +41 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/variable_edit.py +183 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/variables.py +472 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/workflow.py +130 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/writer.py +666 -0
- package/vendor/mwi/code/mw_code.py +1258 -0
- package/vendor/mwi/legacy/edit-session.js +50 -0
- package/vendor/mwi/legacy/fb-insertion.js +74 -0
- package/vendor/mwi/legacy/native-code.js +83 -0
- package/vendor/mwi/legacy/native-groups.js +75 -0
- package/vendor/mwi/legacy/native-structure.js +143 -0
- package/vendor/mwi/legacy/native-variables.js +77 -0
- package/vendor/mwi/legacy/pou-conversion.js +47 -0
- package/vendor/mwi/legacy/pou-package.js +66 -0
- package/vendor/mwi/lib/core/bridge.d.ts +71 -0
- package/vendor/mwi/lib/core/bridge.js +53 -0
- package/vendor/mwi/lib/core/code-engine.d.ts +47 -0
- package/vendor/mwi/lib/core/code-engine.js +109 -0
- package/vendor/mwi/lib/core/errors.d.ts +37 -0
- package/vendor/mwi/lib/core/errors.js +47 -0
- package/vendor/mwi/lib/core/params.d.ts +52 -0
- package/vendor/mwi/lib/core/params.js +46 -0
- package/vendor/mwi/lib/core/ps-bridge.d.ts +62 -0
- package/vendor/mwi/lib/core/ps-bridge.js +127 -0
- package/vendor/mwi/lib/core/result.d.ts +45 -0
- package/vendor/mwi/lib/core/result.js +20 -0
- package/vendor/mwi/lib/core/tool.d.ts +80 -0
- package/vendor/mwi/lib/core/tool.js +55 -0
- package/vendor/mwi/lib/edit/compare.d.ts +33 -0
- package/vendor/mwi/lib/edit/compare.js +70 -0
- package/vendor/mwi/lib/edit.d.ts +20 -0
- package/vendor/mwi/lib/edit.js +32 -0
- package/vendor/mwi/lib/index.d.ts +21 -0
- package/vendor/mwi/lib/index.js +33 -0
- package/vendor/mwi/lib/read/service.d.ts +117 -0
- package/vendor/mwi/lib/read/service.js +229 -0
- package/vendor/mwi/lib/read.d.ts +21 -0
- package/vendor/mwi/lib/read.js +33 -0
- package/vendor/mwi/lib/session/project.d.ts +34 -0
- package/vendor/mwi/lib/session/project.js +54 -0
- package/vendor/mwi/lib/session/service.d.ts +167 -0
- package/vendor/mwi/lib/session/service.js +566 -0
- package/vendor/mwi/lib/session/stage.d.ts +84 -0
- package/vendor/mwi/lib/session/stage.js +187 -0
- package/vendor/mwi/lib/session/workspace.d.ts +52 -0
- package/vendor/mwi/lib/session/workspace.js +90 -0
- package/vendor/mwi/lib/skill.d.ts +36 -0
- package/vendor/mwi/lib/skill.js +56 -0
- package/vendor/mwi/lib/tools/edit.d.ts +20 -0
- package/vendor/mwi/lib/tools/edit.js +286 -0
- package/vendor/mwi/lib/tools/read.d.ts +19 -0
- package/vendor/mwi/lib/tools/read.js +161 -0
- package/vendor/mwi/lib/tools/session.d.ts +20 -0
- package/vendor/mwi/lib/tools/session.js +166 -0
- package/vendor/mwi/lib/tools/verify.d.ts +19 -0
- package/vendor/mwi/lib/tools/verify.js +85 -0
- package/vendor/mwi/lib/verify.d.ts +17 -0
- package/vendor/mwi/lib/verify.js +29 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
"""Parse the ``PROJECT.TRE`` project-tree stream.
|
|
2
|
+
|
|
3
|
+
MotionWorks keeps the project structure in the ``PROJECT.TRE`` stream of the root
|
|
4
|
+
``src.st1``. It is the authority for which program instances run in which task,
|
|
5
|
+
and for the node identities a structural edit (creating or deleting a POU,
|
|
6
|
+
assigning a task) must maintain.
|
|
7
|
+
|
|
8
|
+
Layout, established from real trees::
|
|
9
|
+
|
|
10
|
+
line 0 version marker ("V51")
|
|
11
|
+
line 1 total node count
|
|
12
|
+
line 2 configuration count
|
|
13
|
+
line 3 the root node's params line
|
|
14
|
+
then per node:
|
|
15
|
+
<count> subtree node count
|
|
16
|
+
<id> <a> <children> <d> params
|
|
17
|
+
<name>
|
|
18
|
+
<path> optional; blank for container nodes
|
|
19
|
+
<state line(s)> tab-separated; may hold -1 and alnum handles
|
|
20
|
+
<GUID> optional
|
|
21
|
+
<marker> optional, e.g. "<3>"
|
|
22
|
+
|
|
23
|
+
**The child count is ``params[2]``, not ``params[1]``.** ``Project 0 0 56 0``
|
|
24
|
+
with header total 57 means 56 children plus the root itself, and
|
|
25
|
+
``Logical POUs 4 1 28 0`` means 28 children -- 7 POUs of 4 records each.
|
|
26
|
+
|
|
27
|
+
Five record-shape details each caused a *silent* desynchronisation, which is the
|
|
28
|
+
worst failure mode because the parse still succeeds while producing wrong output.
|
|
29
|
+
All are handled here and covered by tests in ``tools/test_tree.py``:
|
|
30
|
+
|
|
31
|
+
1. the child count is ``params[2]``;
|
|
32
|
+
2. a container's state line can be almost all tabs, so testing the *stripped*
|
|
33
|
+
line loses the tabs and it reads as a bare number;
|
|
34
|
+
3. state fields can be negative (``-1``);
|
|
35
|
+
4. handles are mixed alphanumeric (``a449b235``) and up to 28 characters;
|
|
36
|
+
5. a node can carry a bare handle line with no tabs, plus a marker such as
|
|
37
|
+
``<3>``.
|
|
38
|
+
|
|
39
|
+
The crux is telling a node's state line from the next node's count line: both can
|
|
40
|
+
be a bare integer. They are distinguished by lookahead -- a genuine count line is
|
|
41
|
+
immediately followed by a params line.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import re
|
|
47
|
+
from dataclasses import dataclass, field
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
|
|
50
|
+
from .cfb import CompoundFile
|
|
51
|
+
|
|
52
|
+
#: A params line: four integers, e.g. "0 0 56 0".
|
|
53
|
+
PARAMS_RE = re.compile(r"^(\d+)\s+(\d+)\s+(\d+)\s+(\d+)$")
|
|
54
|
+
|
|
55
|
+
#: GUID lines look like "3D84B45B C9CE 4C2A B1 F2 B7 AC FC B1 A9 94".
|
|
56
|
+
GUID_RE = re.compile(r"^[0-9A-Fa-f]{8}(\s+[0-9A-Fa-f]{4}){1,}(\s+[0-9A-Fa-f]{2})+$")
|
|
57
|
+
|
|
58
|
+
#: A trailing annotation such as "<3>" seen on some nodes.
|
|
59
|
+
TRAILING_MARKER_RE = re.compile(r"^<\d+>$")
|
|
60
|
+
|
|
61
|
+
#: A task node's path points at the resource task directory.
|
|
62
|
+
_TASK_PATH_RE = re.compile(
|
|
63
|
+
r"Configuration[\\/]R[\\/]Resource[\\/][^\\/\t]+$", re.IGNORECASE
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TreeParseError(ValueError):
|
|
68
|
+
"""The tree did not match the expected shape."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class TreeNode:
|
|
73
|
+
"""One node in the project tree.
|
|
74
|
+
|
|
75
|
+
``descendants`` is ``params[2]``: a subtree size used as an upper bound on the
|
|
76
|
+
child loop. It is deliberately not treated as an exact child count -- see the
|
|
77
|
+
note in :func:`parse_tree`.
|
|
78
|
+
|
|
79
|
+
``start_line`` and ``end_line`` are the span of source lines this node owns,
|
|
80
|
+
from its own count/params line through everything up to (but excluding) the
|
|
81
|
+
next sibling. Recording the span is what makes a byte-exact re-emit possible:
|
|
82
|
+
the parser does not have to model every record kind, only prove it accounted
|
|
83
|
+
for all of them. That property is the prerequisite for writing the tree,
|
|
84
|
+
because a writer built on a lossy parse would silently corrupt a project.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
node_id: int
|
|
88
|
+
descendants: int
|
|
89
|
+
params: tuple[int, int, int, int]
|
|
90
|
+
name: str
|
|
91
|
+
path: str
|
|
92
|
+
line: int
|
|
93
|
+
children: list["TreeNode"] = field(default_factory=list)
|
|
94
|
+
start_line: int = 0
|
|
95
|
+
end_line: int = 0
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def span(self) -> tuple[int, int]:
|
|
99
|
+
"""Half-open ``(start, end)`` line range owned by this node."""
|
|
100
|
+
return (self.start_line, self.end_line)
|
|
101
|
+
|
|
102
|
+
def walk(self, depth: int = 0):
|
|
103
|
+
yield depth, self
|
|
104
|
+
for child in self.children:
|
|
105
|
+
yield from child.walk(depth + 1)
|
|
106
|
+
|
|
107
|
+
def find(self, predicate) -> list["TreeNode"]:
|
|
108
|
+
return [node for _, node in self.walk() if predicate(node)]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _next_nonblank(lines: list[str], index: int) -> int | None:
|
|
112
|
+
look = index + 1
|
|
113
|
+
while look < len(lines) and not lines[look].strip():
|
|
114
|
+
look += 1
|
|
115
|
+
return look if look < len(lines) else None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def header_starts_node(lines: list[str], index: int) -> bool:
|
|
119
|
+
"""True when ``lines[index]`` begins a new node's header block.
|
|
120
|
+
|
|
121
|
+
Two forms exist:
|
|
122
|
+
|
|
123
|
+
* a **params line** -- the root has no count line of its own, so its header
|
|
124
|
+
begins directly with one on line 3;
|
|
125
|
+
* a **bare integer followed by a params line** -- how every child begins.
|
|
126
|
+
|
|
127
|
+
The lookahead is essential. A node's state line can also be a bare integer
|
|
128
|
+
on its own (``'\\t\\t\\t\\t0\\t'`` strips to ``'0'``), and a params test alone
|
|
129
|
+
cannot tell the two apart. What separates them is that a real count line is
|
|
130
|
+
immediately followed by that node's params line, whereas a state line is
|
|
131
|
+
followed by a blank or another state line.
|
|
132
|
+
"""
|
|
133
|
+
stripped = lines[index].strip()
|
|
134
|
+
if PARAMS_RE.match(stripped):
|
|
135
|
+
return True
|
|
136
|
+
if not stripped.isdigit():
|
|
137
|
+
return False
|
|
138
|
+
following = _next_nonblank(lines, index)
|
|
139
|
+
return following is not None and bool(PARAMS_RE.match(lines[following].strip()))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def is_state_line(line: str) -> bool:
|
|
143
|
+
"""True for a node's tab-separated numeric state line.
|
|
144
|
+
|
|
145
|
+
These are mostly digits, but a field can be negative or an alphanumeric
|
|
146
|
+
handle, and a container's state line may be almost entirely tabs::
|
|
147
|
+
|
|
148
|
+
0\\t0\\t0\\t0\\t0\\t0\\t0\\t0\\t6522df69
|
|
149
|
+
\\t\\t\\t\\t0\\t
|
|
150
|
+
|
|
151
|
+
Two easy mistakes both cause a silent desynchronisation: requiring all-digits
|
|
152
|
+
rejects the handle form, and testing the *stripped* line loses the tabs so an
|
|
153
|
+
all-tabs line looks like a bare number.
|
|
154
|
+
"""
|
|
155
|
+
if "\t" not in line:
|
|
156
|
+
return False
|
|
157
|
+
fields = line.strip().split("\t")
|
|
158
|
+
if not all(
|
|
159
|
+
f == ""
|
|
160
|
+
or re.fullmatch(r"-?\d+", f) # numeric, may be -1
|
|
161
|
+
or re.fullmatch(r"[0-9A-Za-z]{1,64}", f) # handle, length varies
|
|
162
|
+
for f in fields
|
|
163
|
+
):
|
|
164
|
+
return False
|
|
165
|
+
return any(re.fullmatch(r"-?\d+", f) for f in fields)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def parse_tree(text: str) -> tuple[list[TreeNode], list[str]]:
|
|
169
|
+
"""Parse a ``PROJECT.TRE`` payload into top-level nodes.
|
|
170
|
+
|
|
171
|
+
Returns ``(roots, warnings)``. A node's declared child count delimits its
|
|
172
|
+
children. A shortfall is reported as a warning rather than raised, because a
|
|
173
|
+
partially readable tree is still useful -- but a shortfall is exactly the
|
|
174
|
+
signal that the walk went wrong, so it must not be swallowed.
|
|
175
|
+
"""
|
|
176
|
+
warnings: list[str] = []
|
|
177
|
+
lines = text.replace("\r\n", "\n").split("\n")
|
|
178
|
+
|
|
179
|
+
if not lines or not lines[0].startswith("V"):
|
|
180
|
+
raise TreeParseError(f"unexpected tree header: {lines[0]!r}")
|
|
181
|
+
if len(lines) < 4:
|
|
182
|
+
raise TreeParseError("tree is too short to contain a root node")
|
|
183
|
+
|
|
184
|
+
index = 0
|
|
185
|
+
|
|
186
|
+
def skip_blanks() -> None:
|
|
187
|
+
nonlocal index
|
|
188
|
+
while index < len(lines) and not lines[index].strip():
|
|
189
|
+
index += 1
|
|
190
|
+
|
|
191
|
+
def read_node() -> TreeNode | None:
|
|
192
|
+
nonlocal index
|
|
193
|
+
skip_blanks()
|
|
194
|
+
if index >= len(lines):
|
|
195
|
+
return None
|
|
196
|
+
node_start = index
|
|
197
|
+
|
|
198
|
+
# A child begins with a bare count line; the root has no count line of
|
|
199
|
+
# its own, so accept a params line here too and validate it next.
|
|
200
|
+
head = lines[index].strip()
|
|
201
|
+
if not (head.isdigit() or PARAMS_RE.match(head)):
|
|
202
|
+
return None
|
|
203
|
+
if head.isdigit():
|
|
204
|
+
index += 1
|
|
205
|
+
|
|
206
|
+
skip_blanks()
|
|
207
|
+
if index >= len(lines):
|
|
208
|
+
return None
|
|
209
|
+
params_match = PARAMS_RE.match(lines[index].strip())
|
|
210
|
+
if params_match is None:
|
|
211
|
+
return None
|
|
212
|
+
params = tuple(int(group) for group in params_match.groups())
|
|
213
|
+
params_line = index
|
|
214
|
+
index += 1
|
|
215
|
+
|
|
216
|
+
skip_blanks()
|
|
217
|
+
if index >= len(lines):
|
|
218
|
+
return None
|
|
219
|
+
name = lines[index].split("\t")[0]
|
|
220
|
+
index += 1
|
|
221
|
+
|
|
222
|
+
# Optional path: present only when the line is neither blank, nor a state
|
|
223
|
+
# line, nor a GUID.
|
|
224
|
+
path = ""
|
|
225
|
+
if index < len(lines):
|
|
226
|
+
candidate = lines[index]
|
|
227
|
+
stripped = candidate.strip()
|
|
228
|
+
if (
|
|
229
|
+
stripped
|
|
230
|
+
and not GUID_RE.match(stripped)
|
|
231
|
+
and not is_state_line(candidate)
|
|
232
|
+
):
|
|
233
|
+
path = candidate
|
|
234
|
+
index += 1
|
|
235
|
+
|
|
236
|
+
# Everything up to the next node's header belongs to this node: state
|
|
237
|
+
# line(s), the GUID, a bare handle, and a marker.
|
|
238
|
+
while index < len(lines):
|
|
239
|
+
stripped = lines[index].strip()
|
|
240
|
+
if not stripped:
|
|
241
|
+
look = index + 1
|
|
242
|
+
while look < len(lines) and not lines[look].strip():
|
|
243
|
+
look += 1
|
|
244
|
+
if look < len(lines) and header_starts_node(lines, look):
|
|
245
|
+
break
|
|
246
|
+
index += 1
|
|
247
|
+
continue
|
|
248
|
+
if header_starts_node(lines, index):
|
|
249
|
+
break
|
|
250
|
+
if TRAILING_MARKER_RE.match(stripped):
|
|
251
|
+
index += 1
|
|
252
|
+
continue
|
|
253
|
+
if GUID_RE.match(stripped) or is_state_line(lines[index]):
|
|
254
|
+
index += 1
|
|
255
|
+
continue
|
|
256
|
+
if re.fullmatch(r"[0-9A-Za-z]{2,64}", stripped):
|
|
257
|
+
# A bare handle with no tabs is still this node's data.
|
|
258
|
+
index += 1
|
|
259
|
+
continue
|
|
260
|
+
break
|
|
261
|
+
|
|
262
|
+
node = TreeNode(
|
|
263
|
+
node_id=params[0],
|
|
264
|
+
descendants=params[2],
|
|
265
|
+
params=params, # type: ignore[arg-type]
|
|
266
|
+
name=name,
|
|
267
|
+
path=path,
|
|
268
|
+
line=params_line,
|
|
269
|
+
start_line=node_start,
|
|
270
|
+
)
|
|
271
|
+
# `descendants` (params[2]) is the *subtree* size, used as an upper bound
|
|
272
|
+
# on the child loop. It is not an exact child count: `Tasks` declares 11
|
|
273
|
+
# where 7 children follow, and `Resource` declares 14 with 1. The loop is
|
|
274
|
+
# therefore self-terminating -- read_node returns None once the node's
|
|
275
|
+
# records are exhausted and the next sibling's header appears -- and a
|
|
276
|
+
# shortfall is normal rather than an error.
|
|
277
|
+
#
|
|
278
|
+
# Each child's span ends where the next child begins, so between them the
|
|
279
|
+
# children own every line of this node's subtree with no gaps and no
|
|
280
|
+
# overlap. That is what makes a byte-exact re-emit verifiable.
|
|
281
|
+
previous: TreeNode | None = None
|
|
282
|
+
for _ in range(node.descendants):
|
|
283
|
+
child_start = index
|
|
284
|
+
if previous is not None:
|
|
285
|
+
previous.end_line = child_start
|
|
286
|
+
child = read_node()
|
|
287
|
+
if child is None:
|
|
288
|
+
index = child_start
|
|
289
|
+
break
|
|
290
|
+
node.children.append(child)
|
|
291
|
+
previous = child
|
|
292
|
+
node.end_line = index
|
|
293
|
+
return node
|
|
294
|
+
|
|
295
|
+
# The root's params line is line 3. Skipping "until a numbered line" is
|
|
296
|
+
# wrong, because the root's own params line is numbered.
|
|
297
|
+
index = 3
|
|
298
|
+
roots: list[TreeNode] = []
|
|
299
|
+
root = read_node()
|
|
300
|
+
if root is None:
|
|
301
|
+
raise TreeParseError("could not read the root node")
|
|
302
|
+
roots.append(root)
|
|
303
|
+
|
|
304
|
+
while True:
|
|
305
|
+
node = read_node()
|
|
306
|
+
if node is None:
|
|
307
|
+
break
|
|
308
|
+
roots.append(node)
|
|
309
|
+
return roots, warnings
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def load_tree(project_or_src: Path) -> tuple[list[TreeNode], str, list[str]]:
|
|
313
|
+
"""Load and parse ``PROJECT.TRE`` from a project directory or root src.st1."""
|
|
314
|
+
path = Path(project_or_src)
|
|
315
|
+
if path.is_dir():
|
|
316
|
+
path = path / "src.st1"
|
|
317
|
+
cfb = CompoundFile(path)
|
|
318
|
+
if "PROJECT.TRE" not in cfb.stream_names():
|
|
319
|
+
raise TreeParseError(f"no PROJECT.TRE stream in {path}")
|
|
320
|
+
text = cfb.read_stream("PROJECT.TRE").decode("latin1")
|
|
321
|
+
roots, warnings = parse_tree(text)
|
|
322
|
+
return roots, text, warnings
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _walk_all(roots: list[TreeNode]):
|
|
326
|
+
for root in roots:
|
|
327
|
+
yield from root.walk()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def task_assignments(roots: list[TreeNode]) -> dict[str, list[str]]:
|
|
331
|
+
"""Map task name -> assigned program instance names.
|
|
332
|
+
|
|
333
|
+
A task node's path points at the resource task directory **and its final path
|
|
334
|
+
component equals its name** -- ``C\\Configuration\\R\\Resource\\FastTsk`` for
|
|
335
|
+
the node called ``FastTsk``. Matching the directory pattern alone also
|
|
336
|
+
catches ``Global_Variables`` and ``IO_Configuration``, whose paths live in the
|
|
337
|
+
same directory but carry a file extension, and program-instance nodes carry
|
|
338
|
+
the task type in later fields rather than the path.
|
|
339
|
+
"""
|
|
340
|
+
assignments: dict[str, list[str]] = {}
|
|
341
|
+
for _, node in _walk_all(roots):
|
|
342
|
+
record = node.path.split("\t")[0].replace("/", "\\")
|
|
343
|
+
if not _TASK_PATH_RE.search(record):
|
|
344
|
+
continue
|
|
345
|
+
if "\t" not in node.path:
|
|
346
|
+
continue
|
|
347
|
+
if record.split("\\")[-1] != node.name:
|
|
348
|
+
continue
|
|
349
|
+
assignments[node.name] = [child.name for child in node.children]
|
|
350
|
+
return assignments
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def program_instances(roots: list[TreeNode]) -> list[tuple[str, str]]:
|
|
354
|
+
"""Return ``(task_name, program_instance_name)`` pairs."""
|
|
355
|
+
pairs: list[tuple[str, str]] = []
|
|
356
|
+
for task, programs in task_assignments(roots).items():
|
|
357
|
+
for program in programs:
|
|
358
|
+
pairs.append((task, program))
|
|
359
|
+
return pairs
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@dataclass
|
|
363
|
+
class TreeDocument:
|
|
364
|
+
"""A parsed tree together with everything needed to re-emit it exactly.
|
|
365
|
+
|
|
366
|
+
Keeping the original text means a re-emit cannot lose a record the parser
|
|
367
|
+
does not model. That is the whole point: it turns "the parse looks right"
|
|
368
|
+
into "the parse provably accounts for every byte", which is the prerequisite
|
|
369
|
+
for ever writing the tree.
|
|
370
|
+
|
|
371
|
+
``text`` is the verbatim input. ``lines`` is the LF-normalised view used for
|
|
372
|
+
line indexing, and is *not* what gets emitted -- joining it back would rewrite
|
|
373
|
+
every CRLF, which is exactly the kind of silent change this design exists to
|
|
374
|
+
rule out. A future edit will splice replacement lines into ``text``.
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
text: str
|
|
378
|
+
lines: list[str]
|
|
379
|
+
roots: list[TreeNode]
|
|
380
|
+
warnings: list[str] = field(default_factory=list)
|
|
381
|
+
|
|
382
|
+
@property
|
|
383
|
+
def ending(self) -> str:
|
|
384
|
+
return "\r\n" if "\r\n" in self.text else "\n"
|
|
385
|
+
|
|
386
|
+
def emit(self) -> str:
|
|
387
|
+
"""Re-emit the original text verbatim.
|
|
388
|
+
|
|
389
|
+
Byte-identical by construction. Before any write exists this is the
|
|
390
|
+
safest possible emitter: it cannot invent a change. Edits replace the
|
|
391
|
+
affected line ranges in ``text`` rather than re-serialising records.
|
|
392
|
+
"""
|
|
393
|
+
return self.text
|
|
394
|
+
|
|
395
|
+
def lines_for(self, span: tuple[int, int]) -> list[str]:
|
|
396
|
+
"""The source lines a span covers, without line endings."""
|
|
397
|
+
return self.lines[span[0]:span[1]]
|
|
398
|
+
|
|
399
|
+
def splice(self, edits: list[tuple[int, int, list[str]]]) -> str:
|
|
400
|
+
"""Apply several line-level edits at once and re-emit.
|
|
401
|
+
|
|
402
|
+
``edits`` are ``(start, end, replacement_lines)``; ``start == end``
|
|
403
|
+
inserts. Edits are applied right-to-left so earlier line numbers stay
|
|
404
|
+
valid, which means a caller can compute every span from one parse and
|
|
405
|
+
then commit them together -- important when one edit shifts the lines of
|
|
406
|
+
another.
|
|
407
|
+
"""
|
|
408
|
+
lines = list(self.lines)
|
|
409
|
+
# Right-to-left, so edits at lower line numbers keep their coordinates.
|
|
410
|
+
# Insertions sort before replacements at the same start, and the tuple
|
|
411
|
+
# carries a type tag because a plain `start` key collides when one edit
|
|
412
|
+
# inserts at a line another edit rewrites.
|
|
413
|
+
ordered = sorted(edits, key=lambda e: (e[0], 0 if e[0] == e[1] else 1),
|
|
414
|
+
reverse=True)
|
|
415
|
+
for start, end, replacement in ordered:
|
|
416
|
+
lines[start:end] = list(replacement)
|
|
417
|
+
return self.ending.join(lines)
|
|
418
|
+
|
|
419
|
+
def walk_with_ancestors(
|
|
420
|
+
self,
|
|
421
|
+
) -> list[tuple[TreeNode, list[TreeNode]]]:
|
|
422
|
+
"""Every node paired with its ancestor chain, root first.
|
|
423
|
+
|
|
424
|
+
Mutating a node's child count changes every ancestor's subtree size, so a
|
|
425
|
+
caller needs the chain, not just the node.
|
|
426
|
+
"""
|
|
427
|
+
out: list[tuple[TreeNode, list[TreeNode]]] = []
|
|
428
|
+
|
|
429
|
+
def visit(node: TreeNode, ancestors: list[TreeNode]) -> None:
|
|
430
|
+
out.append((node, ancestors))
|
|
431
|
+
for child in node.children:
|
|
432
|
+
visit(child, ancestors + [node])
|
|
433
|
+
|
|
434
|
+
for root in self.roots:
|
|
435
|
+
visit(root, [])
|
|
436
|
+
return out
|
|
437
|
+
|
|
438
|
+
def replace_span(self, span: tuple[int, int], replacement: list[str]) -> str:
|
|
439
|
+
"""Return the text with one span's lines swapped out.
|
|
440
|
+
|
|
441
|
+
Splices at line granularity into the original text, so every line outside
|
|
442
|
+
the span survives byte-for-byte and the file's line ending is preserved.
|
|
443
|
+
That is the property a structural edit needs: change a node's records
|
|
444
|
+
without touching the rest of the project tree.
|
|
445
|
+
|
|
446
|
+
Callers are responsible for keeping the header counts consistent -- see
|
|
447
|
+
:func:`set_header_total`.
|
|
448
|
+
"""
|
|
449
|
+
start, end = span
|
|
450
|
+
before = self.lines[:start]
|
|
451
|
+
after = self.lines[end:]
|
|
452
|
+
return self.ending.join(before + list(replacement) + after)
|
|
453
|
+
|
|
454
|
+
def set_header_total(self, total: int) -> str:
|
|
455
|
+
"""Return the text with line 1's node count changed.
|
|
456
|
+
|
|
457
|
+
Any structural edit changes the node total, and MotionWorks keeps it on
|
|
458
|
+
its own line, so this is the one header field that must move with it.
|
|
459
|
+
"""
|
|
460
|
+
if len(self.lines) < 2:
|
|
461
|
+
raise TreeParseError("tree has no total-count line")
|
|
462
|
+
lines = list(self.lines)
|
|
463
|
+
lines[1] = str(total)
|
|
464
|
+
return self.ending.join(lines)
|
|
465
|
+
|
|
466
|
+
def lineage(self) -> list[TreeNode]:
|
|
467
|
+
"""Nodes in source order, including interior nodes.
|
|
468
|
+
|
|
469
|
+
A parent's span necessarily contains its children's, so spans are nested
|
|
470
|
+
rather than disjoint. :meth:`record_spans` returns the disjoint set that
|
|
471
|
+
actually partitions the file.
|
|
472
|
+
"""
|
|
473
|
+
out: list[TreeNode] = []
|
|
474
|
+
for root in self.roots:
|
|
475
|
+
for _, node in root.walk():
|
|
476
|
+
out.append(node)
|
|
477
|
+
return out
|
|
478
|
+
|
|
479
|
+
def record_spans(self) -> list[tuple[int, int]]:
|
|
480
|
+
"""Disjoint spans covering every record line, in source order.
|
|
481
|
+
|
|
482
|
+
A node's own records are its span minus its children's spans, so
|
|
483
|
+
subtracting the children yields one contiguous run per node. Those runs
|
|
484
|
+
are disjoint by construction, and a correct parse leaves no line unowned.
|
|
485
|
+
Used for both the coverage check and, later, targeted edits.
|
|
486
|
+
"""
|
|
487
|
+
spans: list[tuple[int, int]] = []
|
|
488
|
+
|
|
489
|
+
def visit(node: TreeNode) -> None:
|
|
490
|
+
cursor = node.start_line
|
|
491
|
+
for child in node.children:
|
|
492
|
+
if child.start_line > cursor:
|
|
493
|
+
spans.append((cursor, child.start_line))
|
|
494
|
+
visit(child)
|
|
495
|
+
cursor = max(cursor, child.end_line)
|
|
496
|
+
if node.end_line > cursor:
|
|
497
|
+
spans.append((cursor, node.end_line))
|
|
498
|
+
|
|
499
|
+
for root in self.roots:
|
|
500
|
+
visit(root)
|
|
501
|
+
return sorted(spans)
|
|
502
|
+
|
|
503
|
+
def coverage(self) -> tuple[list[int], list[int]]:
|
|
504
|
+
"""Record line indices covered, and any owned more than once.
|
|
505
|
+
|
|
506
|
+
Node spans are absolute indices into the same ``lines`` list held here,
|
|
507
|
+
so the comparison is direct. A correct parse assigns every line after the
|
|
508
|
+
three header lines to exactly one node.
|
|
509
|
+
"""
|
|
510
|
+
covered: list[int] = []
|
|
511
|
+
for start, end in self.record_spans():
|
|
512
|
+
covered.extend(range(start, end))
|
|
513
|
+
counts: dict[int, int] = {}
|
|
514
|
+
for index in covered:
|
|
515
|
+
counts[index] = counts.get(index, 0) + 1
|
|
516
|
+
duplicates = sorted(index for index, count in counts.items() if count > 1)
|
|
517
|
+
return sorted(counts), duplicates
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def parse_document(text: str) -> TreeDocument:
|
|
521
|
+
"""Parse a tree while retaining the source needed for an exact re-emit."""
|
|
522
|
+
roots, warnings = parse_tree(text)
|
|
523
|
+
return TreeDocument(
|
|
524
|
+
text=text,
|
|
525
|
+
lines=text.replace("\r\n", "\n").split("\n"),
|
|
526
|
+
roots=roots,
|
|
527
|
+
warnings=warnings,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def round_trip(text: str) -> tuple[bool, str, list[str]]:
|
|
532
|
+
"""Check that parsing then re-emitting reproduces ``text`` exactly.
|
|
533
|
+
|
|
534
|
+
Returns ``(matches, emitted, notes)``. This is the gate on writing the tree:
|
|
535
|
+
a parse that cannot reproduce the input byte-for-byte is not trustworthy
|
|
536
|
+
enough to modify.
|
|
537
|
+
"""
|
|
538
|
+
document = parse_document(text)
|
|
539
|
+
emitted = document.emit()
|
|
540
|
+
|
|
541
|
+
notes: list[str] = []
|
|
542
|
+
if emitted != text:
|
|
543
|
+
notes.append("re-emitted text differs from the input")
|
|
544
|
+
original_lines = text.replace("\r\n", "\n").split("\n")
|
|
545
|
+
emitted_lines = emitted.replace("\r\n", "\n").split("\n")
|
|
546
|
+
for index in range(max(len(original_lines), len(emitted_lines))):
|
|
547
|
+
before = original_lines[index] if index < len(original_lines) else "<eof>"
|
|
548
|
+
after = emitted_lines[index] if index < len(emitted_lines) else "<eof>"
|
|
549
|
+
if before != after:
|
|
550
|
+
notes.append(
|
|
551
|
+
f"first difference at line {index}: {before!r} -> {after!r}"
|
|
552
|
+
)
|
|
553
|
+
break
|
|
554
|
+
|
|
555
|
+
covered, duplicates = document.coverage()
|
|
556
|
+
expected = set(range(3, len(document.lines)))
|
|
557
|
+
missing = sorted(expected - set(covered))
|
|
558
|
+
if missing:
|
|
559
|
+
notes.append(f"{len(missing)} line(s) not owned by any node: {missing[:8]}")
|
|
560
|
+
if duplicates:
|
|
561
|
+
notes.append(f"line(s) owned by more than one node: {duplicates[:8]}")
|
|
562
|
+
|
|
563
|
+
return (emitted == text, emitted, notes)
|