nc-gcode-interpreter 0.2.8__tar.gz → 0.2.9__tar.gz

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.
Files changed (137) hide show
  1. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/.github/workflows/build-and-release.yml +3 -1
  2. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/CHANGELOG.md +25 -0
  3. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/Cargo.lock +1 -1
  4. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/Cargo.toml +1 -1
  5. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/PKG-INFO +1 -1
  6. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/README.md +13 -12
  7. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/src/flatten.rs +8 -7
  8. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/src/interpreter.rs +88 -9
  9. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/src/lib.rs +25 -22
  10. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/src/line_driver.rs +119 -16
  11. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/src/main.rs +1 -1
  12. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/src/output.rs +109 -22
  13. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/src/state.rs +23 -14
  14. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/.github/workflows/benchmark.yml +0 -0
  15. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/.gitignore +0 -0
  16. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/CONTRIBUTING.md +0 -0
  17. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/Development.md +0 -0
  18. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/LICENSE +0 -0
  19. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/TODO.md +0 -0
  20. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/benches/fixtures.rs +0 -0
  21. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/benches/workloads.rs +0 -0
  22. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/build.rs +0 -0
  23. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/build_backend/nc_build_backend.py +0 -0
  24. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/docs/resumable-interpretation-design.md +0 -0
  25. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/docs/sinumerik-execution-model.md +0 -0
  26. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/actual_position.csv +0 -0
  27. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/actual_position.mpf +0 -0
  28. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/arc.csv +0 -0
  29. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/arc.mpf +0 -0
  30. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/arrays.csv +0 -0
  31. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/arrays.mpf +0 -0
  32. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/axis_index_assignment.csv +0 -0
  33. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/axis_index_assignment.mpf +0 -0
  34. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/basic_math.csv +0 -0
  35. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/basic_math.mpf +0 -0
  36. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/case.csv +0 -0
  37. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/case.mpf +0 -0
  38. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/case_insensitive_variables.csv +0 -0
  39. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/case_insensitive_variables.mpf +0 -0
  40. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/custom_vars.csv +0 -0
  41. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/custom_vars.mpf +0 -0
  42. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/def_string.csv +0 -0
  43. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/def_string.mpf +0 -0
  44. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/defaults.csv +0 -0
  45. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/defaults.mpf +0 -0
  46. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/edge_cases.csv +0 -0
  47. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/edge_cases.mpf +0 -0
  48. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/flattening/flatten_demo.mpf +0 -0
  49. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/flattening/flatten_demo_flattened.csv +0 -0
  50. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/flattening/flatten_demo_raw.csv +0 -0
  51. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/for_loop.csv +0 -0
  52. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/for_loop.mpf +0 -0
  53. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/function_calls.csv +0 -0
  54. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/function_calls.mpf +0 -0
  55. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/goto.csv +0 -0
  56. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/goto.mpf +0 -0
  57. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/if_statement.csv +0 -0
  58. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/if_statement.mpf +0 -0
  59. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/increment.csv +0 -0
  60. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/increment.mpf +0 -0
  61. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/logic_operators.csv +0 -0
  62. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/logic_operators.mpf +0 -0
  63. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/loop.csv +0 -0
  64. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/loop.mpf +0 -0
  65. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/multiple_m_codes.csv +0 -0
  66. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/multiple_m_codes.mpf +0 -0
  67. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/precedence.csv +0 -0
  68. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/precedence.mpf +0 -0
  69. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/r_param.csv +0 -0
  70. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/r_param.mpf +0 -0
  71. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/repeat.csv +0 -0
  72. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/repeat.mpf +0 -0
  73. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/rot.csv +0 -0
  74. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/rot.mpf +0 -0
  75. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/simple.csv +0 -0
  76. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/simple.mpf +0 -0
  77. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/spline.csv +0 -0
  78. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/spline.mpf +0 -0
  79. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/tool.csv +0 -0
  80. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/tool.mpf +0 -0
  81. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/trans.csv +0 -0
  82. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/trans.mpf +0 -0
  83. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/trans2.csv +0 -0
  84. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/trans2.mpf +0 -0
  85. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/trans_ic.csv +0 -0
  86. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/trans_ic.mpf +0 -0
  87. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/trans_reset.csv +0 -0
  88. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/trans_reset.mpf +0 -0
  89. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/trig.csv +0 -0
  90. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/trig.mpf +0 -0
  91. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/variables.csv +0 -0
  92. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/examples/variables.mpf +0 -0
  93. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/ggroups/generate_g_commands.py +0 -0
  94. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/pyproject.toml +0 -0
  95. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/example/minimal.py +0 -0
  96. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/example/streaming.py +0 -0
  97. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/nc_gcode_interpreter/__init__.py +0 -0
  98. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/nc_gcode_interpreter/_internal.pyi +0 -0
  99. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/nc_gcode_interpreter/cli.py +0 -0
  100. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/nc_gcode_interpreter/ggroups.json +0 -0
  101. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/nc_gcode_interpreter/py.typed +0 -0
  102. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/nc_gcode_interpreter/viz.py +0 -0
  103. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/tests/nc_source.py +0 -0
  104. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/tests/test_arithmetic_functions.py +0 -0
  105. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/tests/test_batch_variables.py +0 -0
  106. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/tests/test_batches.py +0 -0
  107. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/tests/test_compare_benchmarks.py +0 -0
  108. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/tests/test_diagnostics.py +0 -0
  109. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/tests/test_expected_output.py +0 -0
  110. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/tests/test_field_defects.py +0 -0
  111. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/tests/test_flags.py +0 -0
  112. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/tests/test_flatten.py +0 -0
  113. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/tests/test_frames.py +0 -0
  114. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/tests/test_g_groups.py +0 -0
  115. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/tests/test_g_vocabulary.py +0 -0
  116. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/tests/test_jumps.py +0 -0
  117. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/tests/test_real_comparisons.py +0 -0
  118. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/tests/test_stage1.py +0 -0
  119. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/tests/test_streaming.py +0 -0
  120. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/tests/test_string_operations.py +0 -0
  121. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/tests/test_structured_errors.py +0 -0
  122. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/python/tests/test_unsupported.py +0 -0
  123. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/rustfmt.toml +0 -0
  124. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/scripts/benchmark.py +0 -0
  125. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/scripts/compare_benchmarks.py +0 -0
  126. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/src/errors.rs +0 -0
  127. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/src/frame.rs +0 -0
  128. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/src/grammar.pest +0 -0
  129. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/src/interpret_rules.rs +0 -0
  130. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/src/line_driver/experiments.rs +0 -0
  131. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/src/modal_groups.rs +0 -0
  132. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/src/structure_scan.rs +0 -0
  133. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/src/types.rs +0 -0
  134. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/src/vm.rs +0 -0
  135. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/tests/cli.rs +0 -0
  136. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/tests/performance_contract.rs +0 -0
  137. {nc_gcode_interpreter-0.2.8 → nc_gcode_interpreter-0.2.9}/uv.lock +0 -0
@@ -156,7 +156,9 @@ jobs:
156
156
  uses: PyO3/maturin-action@v1
157
157
  with:
158
158
  target: ${{ matrix.platform.target }}
159
- args: --release --out dist --find-interpreter
159
+ # Named interpreters: --find-interpreter also picks up the image's PyPy,
160
+ # which pyo3-ffi 0.29.0 fails to compile for.
161
+ args: --release --out dist -i python3.12 -i python3.13 -i python3.14 -i python3.14t
160
162
  sccache: 'true'
161
163
  manylinux: auto
162
164
  - name: Upload wheels
@@ -4,6 +4,31 @@ Notable changes to **nc-gcode-interpreter**. The format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/); versions are git tags,
5
5
  released to PyPI.
6
6
 
7
+ ## [v0.2.9] - 2026-11-01
8
+
9
+ ### Changed
10
+
11
+ - `nc_to_batches` and `nc_to_rows` borrow the Python str's UTF-8 buffer for
12
+ the run instead of copying it twice (once into a Rust `String`, once more
13
+ for error messages). On a 5M-line, 322 MB program the peak footprint of
14
+ `nc_to_batches` drops from 1.28 to 0.72 GiB. The Rust entry points
15
+ (`nc_to_table`, `nc_to_row_stream`, `nc_to_batch_stream*`) take
16
+ `impl Into<SourceText>`: a `&str` still works and is copied once, an owned
17
+ `String` or `Arc<str>` is shared without a copy (#89).
18
+
19
+ - Every jump-free program now runs on the streaming line driver, not only
20
+ those of 64 MiB or more, and the streaming driver decodes ahead on a second
21
+ thread instead of decoding inline. On a 1M-line, 63 MB program
22
+ `nc_to_batches` peaks at 0.21 GiB instead of 0.60 GiB and takes 1.02 s
23
+ instead of 1.24 s; at 5M lines it takes 5.1 s instead of 6.3 s. Programs
24
+ with jumps keep the indexed driver; `NC_STREAM_MIN_BYTES` still sets a size
25
+ threshold (#89).
26
+
27
+ - Less work per output row on the interpreter thread: the batch builder moves
28
+ string cells out of the rows it recycles instead of cloning them, and the
29
+ per-row column set and the flattener's position maps hash with FxHash. At
30
+ 5M lines `nc_to_batches` takes 4.4 s instead of 5.2 s (#89).
31
+
7
32
  ## [v0.2.8] - 2026-10-15
8
33
 
9
34
  ### Added
@@ -506,7 +506,7 @@ checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
506
506
 
507
507
  [[package]]
508
508
  name = "nc-gcode-interpreter"
509
- version = "0.2.8"
509
+ version = "0.2.9"
510
510
  dependencies = [
511
511
  "arrow-array",
512
512
  "arrow-data",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "nc-gcode-interpreter"
3
- version = "0.2.8"
3
+ version = "0.2.9"
4
4
  edition = "2021"
5
5
  readme = "README.md"
6
6
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nc-gcode-interpreter
3
- Version: 0.2.8
3
+ Version: 0.2.9
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: Programming Language :: Python :: 3.12
@@ -161,18 +161,19 @@ for line_no, row, variables in nc_to_rows(program, include_variables=True):
161
161
 
162
162
  Rows are typed and forward-filled like the batch DataFrame (disable with `forward_fill=False`); errors raise from `next()` at the offending row; after exhaustion the iterator's `state` attribute holds the final interpreter state. See `python/example/streaming.py` for a runnable version.
163
163
 
164
- **Memory.** Programs of 64 MiB or more without multi-line control blocks or
165
- jumps are validated in a parallel preflight scan and then executed one line at a
166
- time, so parse storage is bounded by the longest line instead of the program
167
- length. The input text, its diagnostic copy and line-offset index, variable
168
- state and any output the caller retains still grow with the program, so use
169
- `nc_to_batches` with a bounded consumer for large files. Smaller programs and
170
- programs with jumps keep the indexed fast path; `NC_STREAM_MIN_BYTES` sets the
171
- threshold in bytes (0 streams every jump-free program). Control blocks and
172
- `NC_STAGE1=0` still use the whole-file parser. On a 1.28M-line (112 MB) field
173
- program the peak RSS of `nc_to_batches` drops from about 1.3 GiB to 0.4 GiB,
174
- and the second decode during execution costs up to about 20 percent of
175
- interpretation time. Measure a local program with
164
+ **Memory.** Programs without multi-line control blocks or jumps are validated
165
+ in a preflight scan and then executed one line at a time, with the next lines
166
+ decoded on a second thread, so parse storage is bounded by a chunk of lines
167
+ instead of the program length. The input text (held once: the Python str is
168
+ borrowed, not copied, and error messages read from the same buffer), its
169
+ line-offset index, variable state and any output the caller retains still grow
170
+ with the program, so use `nc_to_batches` with a bounded consumer for large
171
+ files. Programs with jumps keep the indexed fast path; `NC_STREAM_MIN_BYTES`
172
+ sets a size below which jump-free programs take it too (default 0). Control
173
+ blocks and `NC_STAGE1=0` still use the whole-file parser. On a 1M-line (63 MB)
174
+ generated program the peak footprint of `nc_to_batches` is about 0.2 GiB, and
175
+ streaming is 5 to 20 percent faster than the indexed driver from 1 MB up.
176
+ Measure a local program with
176
177
  `python scripts/benchmark.py --input <file> --modes fresh --edits none
177
178
  --threads 4 --samples 1 --batch-size 4096 --allow-undefined --axis-index-e 4
178
179
  --axis-index-elx 5 --output tmp/memory.json`.
@@ -69,6 +69,7 @@
69
69
  use crate::errors::ParsingError;
70
70
  use crate::output::{intern_column, CellMap, Row, FLATTENED_COLUMN};
71
71
  use crate::state::emit_warning;
72
+ use crate::state::FxMap;
72
73
  use crate::types::Value;
73
74
  use std::collections::HashMap;
74
75
 
@@ -136,7 +137,7 @@ pub struct Flattener {
136
137
  /// non-geometric value columns), interned.
137
138
  geometric_axes: Vec<&'static str>,
138
139
  /// Last known machine coordinate per geometric axis column.
139
- positions: HashMap<&'static str, f64>,
140
+ positions: FxMap<&'static str, f64>,
140
141
  motion: Motion,
141
142
  plane: Plane,
142
143
  /// Buffered blocks of the active spline, in program order. Non-empty
@@ -144,7 +145,7 @@ pub struct Flattener {
144
145
  spline_buffer: Vec<SplineItem>,
145
146
  /// Snapshot of `positions` taken when the spline was selected: the
146
147
  /// spline's start point.
147
- spline_start: HashMap<&'static str, f64>,
148
+ spline_start: FxMap<&'static str, f64>,
148
149
  /// Current B-spline degree (`SD=`, modal within the spline).
149
150
  spline_degree: usize,
150
151
  /// Curve motion words already warned about (CIP, CT, POLY, ...): one
@@ -179,11 +180,11 @@ impl Flattener {
179
180
  Ok(Flattener {
180
181
  tolerance,
181
182
  geometric_axes,
182
- positions: HashMap::new(),
183
+ positions: FxMap::default(),
183
184
  motion: Motion::Linear,
184
185
  plane: G17,
185
186
  spline_buffer: Vec::new(),
186
- spline_start: HashMap::new(),
187
+ spline_start: FxMap::default(),
187
188
  spline_degree: 3,
188
189
  warned_motions: Vec::new(),
189
190
  })
@@ -201,7 +202,7 @@ impl Flattener {
201
202
  }
202
203
  self.motion = new_motion;
203
204
  }
204
- if let Some(Value::Str(plane)) = row.cells.get(intern_column("gg06_plane_select")) {
205
+ if let Some(Value::Str(plane)) = row.cells.get("gg06_plane_select") {
205
206
  self.plane = match plane.as_str() {
206
207
  "G18" => G18,
207
208
  "G19" => G19,
@@ -215,7 +216,7 @@ impl Flattener {
215
216
  self.spline_start = self.positions.clone();
216
217
  self.spline_degree = 3;
217
218
  }
218
- if let Some(Value::Float(sd)) = row.cells.get(intern_column("SD")) {
219
+ if let Some(Value::Float(sd)) = row.cells.get("SD") {
219
220
  self.spline_degree = (*sd as usize).max(1);
220
221
  }
221
222
  let has_point = self
@@ -659,7 +660,7 @@ fn points_value(points: &[Vec<f64>], point_rows: &[Option<usize>], item_index: u
659
660
  }
660
661
 
661
662
  fn row_motion(row: &Row) -> Option<Motion> {
662
- match row.cells.get(intern_column("gg01_motion")) {
663
+ match row.cells.get("gg01_motion") {
663
664
  Some(Value::Str(m)) => Some(match m.as_str() {
664
665
  "G0" | "G1" => Motion::Linear,
665
666
  "G2" => Motion::Arc { cw: true },
@@ -6,6 +6,54 @@ use crate::state::{self, State};
6
6
  use crate::types::{NCParser, Rule};
7
7
  use pest::Parser;
8
8
  use std::collections::HashMap;
9
+ use std::sync::Arc;
10
+
11
+ /// Program text shared between the driver that interprets it and the state's
12
+ /// diagnostics, so a run holds a single copy of the source. Converting from a
13
+ /// `&str` copies once; an owned `String`, an `Arc<str>` or any other
14
+ /// [`SourceText::new`] buffer is shared as is.
15
+ #[derive(Clone)]
16
+ pub struct SourceText(Arc<dyn AsRef<str> + Send + Sync>);
17
+
18
+ impl SourceText {
19
+ pub fn new<T: AsRef<str> + Send + Sync + 'static>(text: T) -> Self {
20
+ SourceText(Arc::new(text))
21
+ }
22
+
23
+ pub fn as_str(&self) -> &str {
24
+ (*self.0).as_ref()
25
+ }
26
+ }
27
+
28
+ impl std::fmt::Debug for SourceText {
29
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30
+ f.debug_struct("SourceText").field("len", &self.as_str().len()).finish()
31
+ }
32
+ }
33
+
34
+ impl From<&str> for SourceText {
35
+ fn from(text: &str) -> Self {
36
+ SourceText(Arc::new(Box::<str>::from(text)))
37
+ }
38
+ }
39
+
40
+ impl From<&String> for SourceText {
41
+ fn from(text: &String) -> Self {
42
+ SourceText::from(text.as_str())
43
+ }
44
+ }
45
+
46
+ impl From<String> for SourceText {
47
+ fn from(text: String) -> Self {
48
+ SourceText::new(text)
49
+ }
50
+ }
51
+
52
+ impl From<Arc<str>> for SourceText {
53
+ fn from(text: Arc<str>) -> Self {
54
+ SourceText::new(text)
55
+ }
56
+ }
9
57
 
10
58
  const DEFAULT_AXIS_IDENTIFIERS: &[&str] = &[
11
59
  "N", "X", "Y", "Z", "A", "B", "C", "D", "E", "F", "S", "U", "V", "RA1", "RA2", "RA3", "RA4", "RA5", "RA6",
@@ -13,7 +61,7 @@ const DEFAULT_AXIS_IDENTIFIERS: &[&str] = &[
13
61
 
14
62
  /// Main function: interpret the input program into a sanitized output table.
15
63
  pub fn nc_to_table(
16
- input: &str,
64
+ input: impl Into<SourceText>,
17
65
  initial_state: Option<&str>,
18
66
  axis_identifiers: Option<Vec<String>>,
19
67
  extra_axes: Option<Vec<String>>,
@@ -41,7 +89,7 @@ pub fn nc_to_table(
41
89
  // Now interpret the main input using the axis_index_map from state
42
90
  let mut output = OutputRows::collect();
43
91
  install_flattener(&mut output, &state, flatten_tolerance)?;
44
- interpret_file(input, &mut state, &mut output)?;
92
+ interpret_source(input.into(), &mut state, &mut output)?;
45
93
  let rows = output.finish()?;
46
94
 
47
95
  let table = Table::from_rows(&rows, disable_forward_fill);
@@ -104,7 +152,7 @@ fn install_flattener(
104
152
  #[allow(clippy::too_many_arguments)]
105
153
  #[allow(dead_code)] // used by the python-feature bindings, not the bin
106
154
  pub fn nc_to_row_stream(
107
- input: &str,
155
+ input: impl Into<SourceText>,
108
156
  initial_state: Option<&str>,
109
157
  axis_identifiers: Option<Vec<String>>,
110
158
  extra_axes: Option<Vec<String>>,
@@ -127,7 +175,7 @@ pub fn nc_to_row_stream(
127
175
  }
128
176
  let mut output = OutputRows::stream(sender);
129
177
  install_flattener(&mut output, &state, flatten_tolerance)?;
130
- interpret_file(input, &mut state, &mut output)?;
178
+ interpret_source(input.into(), &mut state, &mut output)?;
131
179
  output.finish()?;
132
180
  Ok(state)
133
181
  }
@@ -145,7 +193,7 @@ pub fn nc_to_row_stream(
145
193
  #[allow(dead_code)] // used by the python-feature bindings, not the bin
146
194
  #[allow(clippy::too_many_arguments)]
147
195
  pub fn nc_to_batch_stream(
148
- input: &str,
196
+ input: impl Into<SourceText>,
149
197
  initial_state: Option<&str>,
150
198
  axis_identifiers: Option<Vec<String>>,
151
199
  extra_axes: Option<Vec<String>>,
@@ -183,7 +231,7 @@ pub fn nc_to_batch_stream(
183
231
  /// callers.
184
232
  #[allow(clippy::too_many_arguments)]
185
233
  pub fn nc_to_batch_stream_with_line_numbers(
186
- input: &str,
234
+ input: impl Into<SourceText>,
187
235
  initial_state: Option<&str>,
188
236
  axis_identifiers: Option<Vec<String>>,
189
237
  extra_axes: Option<Vec<String>>,
@@ -218,15 +266,20 @@ pub fn nc_to_batch_stream_with_line_numbers(
218
266
  emit_line_no,
219
267
  );
220
268
  install_flattener(&mut output, &state, flatten_tolerance)?;
221
- interpret_file(input, &mut state, &mut output)?;
269
+ interpret_source(input.into(), &mut state, &mut output)?;
222
270
  output.finish()?;
223
271
  Ok(state)
224
272
  }
225
273
 
226
274
  /// Interpret a file, pushing rows into `output`.
227
275
  fn interpret_file(input: &str, state: &mut State, output: &mut OutputRows) -> Result<(), ParsingError> {
228
- // Store input for error messages
229
- state.set_input(input);
276
+ interpret_source(SourceText::from(input), state, output)
277
+ }
278
+
279
+ /// As [`interpret_file`], sharing `source` with the state's diagnostics.
280
+ fn interpret_source(source: SourceText, state: &mut State, output: &mut OutputRows) -> Result<(), ParsingError> {
281
+ state.set_source(source.clone());
282
+ let input = source.as_str();
230
283
 
231
284
  // Validate control-structure nesting first: a PEG reports an unclosed
232
285
  // IF/WHILE at the end of the file; the line scan reports the opener.
@@ -698,6 +751,32 @@ mod tests {
698
751
  .unwrap_or_else(|| panic!("column {name} missing; have {:?}", column_names(table)))
699
752
  }
700
753
 
754
+ /// An owned program is shared with the state's diagnostics, not copied.
755
+ #[test]
756
+ fn owned_source_is_shared_with_diagnostics() {
757
+ let program = String::from("G1 X0 Y0 F100\nX10 Y=\n");
758
+ let ptr = program.as_ptr();
759
+ let source = SourceText::from(program);
760
+ assert_eq!(source.as_str().as_ptr(), ptr);
761
+ let mut state = build_state(None, None, 10000, None, false);
762
+ state.set_source(source.clone());
763
+ assert_eq!(state.get_line(2), Some("X10 Y="));
764
+ assert_eq!(state.get_line(2).unwrap().as_ptr(), source.as_str()[14..].as_ptr());
765
+ let err = nc_to_table(
766
+ String::from("G1 X0\nX10 Y=\n"),
767
+ None,
768
+ None,
769
+ None,
770
+ 10000,
771
+ false,
772
+ None,
773
+ false,
774
+ None,
775
+ )
776
+ .expect_err("a dangling assignment does not parse");
777
+ assert!(err.to_string().contains("X10 Y="), "{err}");
778
+ }
779
+
701
780
  /// `include_line_numbers` (default off) prepends a `line_no` column: one
702
781
  /// 1-based source line per output row, repeated under a loop and
703
782
  /// non-monotonic across a jump - matching what `nc_to_rows` yields.
@@ -22,6 +22,7 @@ pub use line_driver::experiments;
22
22
  mod python_bindings {
23
23
  use pyo3::exceptions::PyValueError;
24
24
  use pyo3::prelude::*;
25
+ use pyo3::pybacked::PyBackedStr;
25
26
  use pyo3::types::PyCapsule;
26
27
  use pyo3::types::{PyDict, PyList};
27
28
  use pyo3::wrap_pyfunction;
@@ -64,7 +65,7 @@ mod python_bindings {
64
65
  use std::sync::{mpsc, Mutex};
65
66
 
66
67
  use crate::errors::ErrorLocation;
67
- use crate::interpreter::{nc_to_batch_stream_with_line_numbers, nc_to_row_stream};
68
+ use crate::interpreter::{nc_to_batch_stream_with_line_numbers, nc_to_row_stream, SourceText};
68
69
  use crate::output::{is_forward_filled_column, is_string_column, Column, Row, Table, VariableEvents};
69
70
  use crate::state::FinalState;
70
71
  use crate::types::Value;
@@ -206,11 +207,23 @@ mod python_bindings {
206
207
  })
207
208
  }
208
209
 
210
+ /// The program for a run: the file at `input` when `input_is_path`, else
211
+ /// the Python str itself, shared without a copy.
212
+ fn source_text(input: PyBackedStr, input_is_path: bool) -> PyResult<SourceText> {
213
+ if input_is_path {
214
+ let text = std::fs::read_to_string(&*input)
215
+ .map_err(|e| PyErr::new::<PyValueError, _>(format!("Error reading input file '{}': {}", input, e)))?;
216
+ Ok(SourceText::from(text))
217
+ } else {
218
+ Ok(SourceText::new(input))
219
+ }
220
+ }
221
+
209
222
  /// Spawn the interpreter on a worker thread, pushing finished rows into a
210
223
  /// bounded channel. Shared by the row and batch streaming iterators.
211
224
  #[allow(clippy::too_many_arguments)]
212
225
  fn spawn_stream(
213
- input: String,
226
+ input: SourceText,
214
227
  initial_state: Option<String>,
215
228
  axis_identifiers: Option<Vec<String>>,
216
229
  extra_axes: Option<Vec<String>>,
@@ -230,7 +243,7 @@ mod python_bindings {
230
243
  .name("nc-interpreter".to_string())
231
244
  .spawn(move || {
232
245
  let outcome = nc_to_row_stream(
233
- &input,
246
+ input,
234
247
  initial_state.as_deref(),
235
248
  axis_identifiers,
236
249
  extra_axes,
@@ -258,7 +271,7 @@ mod python_bindings {
258
271
  /// another is consumed). Used by `nc_to_batches`.
259
272
  #[allow(clippy::too_many_arguments)]
260
273
  fn spawn_batch_stream(
261
- input: String,
274
+ input: SourceText,
262
275
  batch_size: usize,
263
276
  initial_state: Option<String>,
264
277
  axis_identifiers: Option<Vec<String>>,
@@ -290,7 +303,7 @@ mod python_bindings {
290
303
  .name("nc-interpreter".to_string())
291
304
  .spawn(move || {
292
305
  let outcome = nc_to_batch_stream_with_line_numbers(
293
- &input,
306
+ input,
294
307
  initial_state.as_deref(),
295
308
  axis_identifiers,
296
309
  extra_axes,
@@ -491,7 +504,7 @@ mod python_bindings {
491
504
  #[pyo3(signature = (input, initial_state = None, axis_identifiers = None, extra_axes = None, iteration_limit = 10000, forward_fill = true, include_variables = false, axis_index_map = None, allow_undefined_variables = false, input_is_path = false, flatten_tolerance = None))]
492
505
  #[allow(clippy::too_many_arguments)]
493
506
  fn nc_to_rows(
494
- input: String,
507
+ input: PyBackedStr,
495
508
  initial_state: Option<String>,
496
509
  axis_identifiers: Option<Vec<String>>,
497
510
  extra_axes: Option<Vec<String>>,
@@ -504,15 +517,10 @@ mod python_bindings {
504
517
  flatten_tolerance: Option<f64>,
505
518
  ) -> PyResult<NcRowIterator> {
506
519
  // When `input_is_path` is set, `input` is a filesystem path: read the
507
- // program here (once) instead of copying a 1.1 GB Python str across the
508
- // PyO3 boundary. Reading before the worker spawns surfaces a missing
509
- // file as an immediate, clean error.
510
- let input = if input_is_path {
511
- std::fs::read_to_string(&input)
512
- .map_err(|e| PyErr::new::<PyValueError, _>(format!("Error reading input file '{}': {}", input, e)))?
513
- } else {
514
- input
515
- };
520
+ // program here, before the worker spawns, so a missing file surfaces
521
+ // as an immediate, clean error. Otherwise the worker borrows the
522
+ // Python str's UTF-8 buffer for the run instead of copying it.
523
+ let input = source_text(input, input_is_path)?;
516
524
  let (row_receiver, result_receiver, handle) = spawn_stream(
517
525
  input,
518
526
  initial_state,
@@ -668,7 +676,7 @@ mod python_bindings {
668
676
  #[pyo3(signature = (input, batch_size = 500_000, initial_state = None, axis_identifiers = None, extra_axes = None, iteration_limit = 10000, disable_forward_fill = false, axis_index_map = None, allow_undefined_variables = false, input_is_path = false, flatten_tolerance = None, include_line_numbers = false, include_variables = false))]
669
677
  #[allow(clippy::too_many_arguments)]
670
678
  fn nc_to_batches(
671
- input: String,
679
+ input: PyBackedStr,
672
680
  batch_size: usize,
673
681
  initial_state: Option<String>,
674
682
  axis_identifiers: Option<Vec<String>>,
@@ -686,12 +694,7 @@ mod python_bindings {
686
694
  return Err(PyErr::new::<PyValueError, _>("batch_size must be greater than 0"));
687
695
  }
688
696
  // Read the program here when given a path (see nc_to_rows).
689
- let input = if input_is_path {
690
- std::fs::read_to_string(&input)
691
- .map_err(|e| PyErr::new::<PyValueError, _>(format!("Error reading input file '{}': {}", input, e)))?
692
- } else {
693
- input
694
- };
697
+ let input = source_text(input, input_is_path)?;
695
698
  let (batch_receiver, result_receiver, events_receiver, handle) = spawn_batch_stream(
696
699
  input,
697
700
  batch_size,
@@ -43,10 +43,16 @@ pub mod experiments;
43
43
  /// of the test suite, most hand-written files) stay single-threaded.
44
44
  const PARALLEL_MIN_BYTES: usize = 4 << 20; // 4 MiB
45
45
 
46
- /// Below this many bytes the indexed driver runs jump-free programs: its
47
- /// retained decode state is small there, and it skips the streaming driver's
48
- /// second decode. NC_STREAM_MIN_BYTES overrides it (0 streams every program).
49
- const STREAMING_MIN_BYTES: usize = 64 << 20; // 64 MiB
46
+ /// Below this many bytes the indexed driver runs jump-free programs. The
47
+ /// streaming driver decodes on a second thread, so it is as fast from about
48
+ /// 1 MiB up and within 0.3 ms below, and it retains no per-line state: every
49
+ /// jump-free program streams. NC_STREAM_MIN_BYTES overrides it (a huge value
50
+ /// pins the indexed driver).
51
+ const STREAMING_MIN_BYTES: usize = 0;
52
+
53
+ /// Below this many bytes the streaming driver's preflight and decode stay on
54
+ /// the calling thread; the thread hand-off costs more than it saves there.
55
+ const STREAM_PARALLEL_MIN_BYTES: usize = 64 << 10; // 64 KiB
50
56
 
51
57
  type Output = crate::output::OutputRows;
52
58
 
@@ -266,7 +272,7 @@ pub fn interpret_lines(input: &str, output: &mut Output, state: &mut State) -> R
266
272
  /// holds a jump decides, exactly as a sequential scan would.
267
273
  fn preflight_streaming(input: &str, state: &State) -> Result<bool, ParsingError> {
268
274
  let threads = rayon::current_num_threads().max(1);
269
- let parts = if input.len() < PARALLEL_MIN_BYTES || threads <= 1 {
275
+ let parts = if input.len() < STREAM_PARALLEL_MIN_BYTES || threads <= 1 {
270
276
  1
271
277
  } else {
272
278
  threads * 8
@@ -380,23 +386,88 @@ fn chunk_at_newlines(input: &str, parts: usize) -> Vec<Range<usize>> {
380
386
  ranges
381
387
  }
382
388
 
383
- /// O(longest line) decoding/parse storage, independent of the number of lines.
389
+ /// O(chunk) decoding/parse storage, independent of the number of lines.
384
390
  /// Input ownership, diagnostic line offsets and the selected output sink are
385
391
  /// separate: collecting output still necessarily grows with the program.
386
392
  fn interpret_streaming(input: &str, output: &mut Output, state: &mut State) -> Result<BlockFlow, ParsingError> {
387
- let mut arena = Vec::new();
388
- for (index, line) in input.lines().enumerate() {
389
- arena.clear();
390
- let flow = match decode_line(line, index + 1, &mut arena) {
391
- DecodeResult::Blank => continue,
392
- DecodeResult::Trivial(decoded, _) => execute_decoded(&decoded, &arena, output, state)?,
393
- DecodeResult::NeedsGrammar => interpret_block(parse_single_line(line, index, state)?, output, state)?,
393
+ if input.len() < STREAM_PARALLEL_MIN_BYTES || rayon::current_num_threads() <= 1 {
394
+ let mut arena = Vec::new();
395
+ for (index, line) in input.lines().enumerate() {
396
+ arena.clear();
397
+ let decoded = decode_line(line, index + 1, &mut arena);
398
+ let flow = execute_streamed(&decoded, line, index, &arena, output, state)?;
399
+ if !matches!(flow, BlockFlow::Continue) {
400
+ return Ok(flow);
401
+ }
402
+ }
403
+ return Ok(BlockFlow::Continue);
404
+ }
405
+ // Decode on a second thread, a few chunks ahead, so execution does not pay
406
+ // for the decode the indexed driver runs in parallel.
407
+ std::thread::scope(|scope| {
408
+ let (sender, receiver) = std::sync::mpsc::sync_channel::<DecodedChunk>(2);
409
+ scope.spawn(move || decode_chunks(input, sender));
410
+ // Returning early drops `receiver`, which stops the decode thread.
411
+ for chunk in receiver {
412
+ for (offset, (decoded, line)) in chunk.results.iter().zip(&chunk.lines).enumerate() {
413
+ let flow = execute_streamed(decoded, line, chunk.first_line + offset, &chunk.arena, output, state)?;
414
+ if !matches!(flow, BlockFlow::Continue) {
415
+ return Ok(flow);
416
+ }
417
+ }
418
+ }
419
+ Ok(BlockFlow::Continue)
420
+ })
421
+ }
422
+
423
+ /// Lines per chunk the streaming decode thread hands to execution.
424
+ const STREAM_CHUNK_LINES: usize = 4096;
425
+
426
+ /// A run of consecutive decoded lines; `first_line` is the 0-based index of
427
+ /// the first one, and `results` index into `arena` like the indexed driver's.
428
+ struct DecodedChunk<'a> {
429
+ first_line: usize,
430
+ lines: Vec<&'a str>,
431
+ results: Vec<DecodeResult<'a>>,
432
+ arena: Vec<Word<'a>>,
433
+ }
434
+
435
+ fn decode_chunks<'a>(input: &'a str, sender: std::sync::mpsc::SyncSender<DecodedChunk<'a>>) {
436
+ let mut lines = input.lines().peekable();
437
+ let mut first_line = 0;
438
+ while lines.peek().is_some() {
439
+ let mut chunk = DecodedChunk {
440
+ first_line,
441
+ lines: lines.by_ref().take(STREAM_CHUNK_LINES).collect(),
442
+ results: Vec::with_capacity(STREAM_CHUNK_LINES),
443
+ arena: Vec::with_capacity(STREAM_CHUNK_LINES * 4),
394
444
  };
395
- if !matches!(flow, BlockFlow::Continue) {
396
- return Ok(flow);
445
+ for (offset, line) in chunk.lines.iter().enumerate() {
446
+ chunk
447
+ .results
448
+ .push(decode_line(line, first_line + offset + 1, &mut chunk.arena));
449
+ }
450
+ first_line += chunk.lines.len();
451
+ if sender.send(chunk).is_err() {
452
+ return;
397
453
  }
398
454
  }
399
- Ok(BlockFlow::Continue)
455
+ }
456
+
457
+ /// Execute one streamed line (0-based `index`) from its decode result.
458
+ fn execute_streamed(
459
+ decoded: &DecodeResult,
460
+ line: &str,
461
+ index: usize,
462
+ arena: &[Word],
463
+ output: &mut Output,
464
+ state: &mut State,
465
+ ) -> Result<BlockFlow, ParsingError> {
466
+ match decoded {
467
+ DecodeResult::Blank => Ok(BlockFlow::Continue),
468
+ DecodeResult::Trivial(decoded, _) => execute_decoded(decoded, arena, output, state),
469
+ DecodeResult::NeedsGrammar => interpret_block(parse_single_line(line, index, state)?, output, state),
470
+ }
400
471
  }
401
472
 
402
473
  fn parse_single_line<'i>(line: &'i str, offset: usize, state: &State) -> Result<Pair<'i, Rule>, ParsingError> {
@@ -1644,6 +1715,38 @@ M30
1644
1715
  assert!(rx.try_recv().is_err(), "syntax failure must not emit earlier rows");
1645
1716
  }
1646
1717
 
1718
+ /// Programs past STREAM_PARALLEL_MIN_BYTES and several decode chunks long
1719
+ /// take the pipelined decode; output, M30 and late runtime errors agree.
1720
+ #[test]
1721
+ fn pipelined_streaming_matches_full_parse() {
1722
+ use std::fmt::Write;
1723
+ let mut body = String::from("MULTIPLIER=2\nR1=0\n");
1724
+ for i in 0..3 * super::STREAM_CHUNK_LINES {
1725
+ if i % 997 == 0 {
1726
+ body.push_str("X=SIN(30) R1=R1+1\n");
1727
+ } else {
1728
+ writeln!(body, "N{i} G1 X{}.5 Y{} E=IC(MULTIPLIER*0.01) ; c", i % 100, i % 7).unwrap();
1729
+ }
1730
+ }
1731
+ assert!(body.len() > super::STREAM_PARALLEL_MIN_BYTES);
1732
+ let mid = body.len() / 2 + body[body.len() / 2..].find('\n').unwrap() + 1;
1733
+ for program in [
1734
+ body.clone(),
1735
+ format!("{}M30\n{}", &body[..mid], &body[mid..]),
1736
+ format!("{}X=SIN(MISSING)\n{}", &body[..mid], &body[mid..]),
1737
+ ] {
1738
+ assert_paths_agree(&program, false);
1739
+ }
1740
+ // The shared error check compares only the line text; pin the number.
1741
+ let program = format!("{}X=SIN(MISSING)\n{}", &body[..mid], &body[mid..]);
1742
+ let _guard = env_lock();
1743
+ std::env::set_var("NC_STREAM_MIN_BYTES", "0");
1744
+ let error = run(&program, true, false).unwrap_err();
1745
+ std::env::remove_var("NC_STREAM_MIN_BYTES");
1746
+ let expected = body[..mid].lines().count() + 1;
1747
+ assert_eq!(error.location().map(|l| l.line), Some(expected), "{error}");
1748
+ }
1749
+
1647
1750
  #[test]
1648
1751
  fn preflight_chunks_agree_with_a_single_chunk() {
1649
1752
  let mut state = crate::state::State::new(vec!["X".into()], 100, None, false);
@@ -142,7 +142,7 @@ fn main() -> io::Result<()> {
142
142
  let flatten_tolerance = matches.get_one::<f64>("flatten_tolerance").copied();
143
143
 
144
144
  match nc_to_table(
145
- &input,
145
+ input,
146
146
  initial_state.as_deref(),
147
147
  axes_override.clone(),
148
148
  extra_axes,