nc-gcode-interpreter 0.2.4__tar.gz → 0.2.6__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 (122) hide show
  1. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/CHANGELOG.md +44 -0
  2. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/Cargo.lock +1 -1
  3. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/Cargo.toml +1 -1
  4. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/PKG-INFO +1 -1
  5. nc_gcode_interpreter-0.2.6/python/tests/test_string_operations.py +79 -0
  6. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/src/grammar.pest +27 -7
  7. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/src/interpret_rules.rs +602 -11
  8. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/src/interpreter.rs +241 -1
  9. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/src/output.rs +83 -13
  10. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/tests/cli.rs +42 -0
  11. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/.github/workflows/build-and-release.yml +0 -0
  12. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/.gitignore +0 -0
  13. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/CONTRIBUTING.md +0 -0
  14. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/Development.md +0 -0
  15. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/LICENSE +0 -0
  16. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/README.md +0 -0
  17. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/TODO.md +0 -0
  18. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/docs/resumable-interpretation-design.md +0 -0
  19. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/docs/sinumerik-execution-model.md +0 -0
  20. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/actual_position.csv +0 -0
  21. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/actual_position.mpf +0 -0
  22. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/arc.csv +0 -0
  23. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/arc.mpf +0 -0
  24. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/arrays.csv +0 -0
  25. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/arrays.mpf +0 -0
  26. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/axis_index_assignment.csv +0 -0
  27. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/axis_index_assignment.mpf +0 -0
  28. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/basic_math.csv +0 -0
  29. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/basic_math.mpf +0 -0
  30. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/case.csv +0 -0
  31. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/case.mpf +0 -0
  32. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/case_insensitive_variables.csv +0 -0
  33. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/case_insensitive_variables.mpf +0 -0
  34. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/custom_vars.csv +0 -0
  35. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/custom_vars.mpf +0 -0
  36. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/def_string.csv +0 -0
  37. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/def_string.mpf +0 -0
  38. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/defaults.csv +0 -0
  39. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/defaults.mpf +0 -0
  40. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/edge_cases.csv +0 -0
  41. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/edge_cases.mpf +0 -0
  42. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/flattening/flatten_demo.mpf +0 -0
  43. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/flattening/flatten_demo_flattened.csv +0 -0
  44. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/flattening/flatten_demo_raw.csv +0 -0
  45. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/for_loop.csv +0 -0
  46. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/for_loop.mpf +0 -0
  47. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/function_calls.csv +0 -0
  48. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/function_calls.mpf +0 -0
  49. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/goto.csv +0 -0
  50. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/goto.mpf +0 -0
  51. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/if_statement.csv +0 -0
  52. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/if_statement.mpf +0 -0
  53. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/increment.csv +0 -0
  54. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/increment.mpf +0 -0
  55. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/logic_operators.csv +0 -0
  56. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/logic_operators.mpf +0 -0
  57. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/loop.csv +0 -0
  58. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/loop.mpf +0 -0
  59. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/multiple_m_codes.csv +0 -0
  60. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/multiple_m_codes.mpf +0 -0
  61. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/precedence.csv +0 -0
  62. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/precedence.mpf +0 -0
  63. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/r_param.csv +0 -0
  64. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/r_param.mpf +0 -0
  65. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/repeat.csv +0 -0
  66. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/repeat.mpf +0 -0
  67. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/simple.csv +0 -0
  68. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/simple.mpf +0 -0
  69. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/spline.csv +0 -0
  70. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/spline.mpf +0 -0
  71. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/tool.csv +0 -0
  72. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/tool.mpf +0 -0
  73. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/trans.csv +0 -0
  74. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/trans.mpf +0 -0
  75. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/trans2.csv +0 -0
  76. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/trans2.mpf +0 -0
  77. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/trans_ic.csv +0 -0
  78. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/trans_ic.mpf +0 -0
  79. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/trans_reset.csv +0 -0
  80. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/trans_reset.mpf +0 -0
  81. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/trig.csv +0 -0
  82. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/trig.mpf +0 -0
  83. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/variables.csv +0 -0
  84. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/examples/variables.mpf +0 -0
  85. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/ggroups/generate_g_commands.py +0 -0
  86. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/pyproject.toml +0 -0
  87. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/example/minimal.py +0 -0
  88. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/example/streaming.py +0 -0
  89. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/nc_gcode_interpreter/__init__.py +0 -0
  90. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/nc_gcode_interpreter/_internal.pyi +0 -0
  91. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/nc_gcode_interpreter/cli.py +0 -0
  92. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/nc_gcode_interpreter/ggroups.json +0 -0
  93. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/nc_gcode_interpreter/py.typed +0 -0
  94. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/nc_gcode_interpreter/viz.py +0 -0
  95. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/tests/test_arithmetic_functions.py +0 -0
  96. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/tests/test_batch_variables.py +0 -0
  97. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/tests/test_batches.py +0 -0
  98. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/tests/test_diagnostics.py +0 -0
  99. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/tests/test_expected_output.py +0 -0
  100. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/tests/test_field_defects.py +0 -0
  101. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/tests/test_flags.py +0 -0
  102. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/tests/test_flatten.py +0 -0
  103. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/tests/test_g_groups.py +0 -0
  104. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/tests/test_g_vocabulary.py +0 -0
  105. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/tests/test_jumps.py +0 -0
  106. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/tests/test_real_comparisons.py +0 -0
  107. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/tests/test_stage1.py +0 -0
  108. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/tests/test_streaming.py +0 -0
  109. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/tests/test_structured_errors.py +0 -0
  110. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/python/tests/test_unsupported.py +0 -0
  111. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/rustfmt.toml +0 -0
  112. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/src/errors.rs +0 -0
  113. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/src/flatten.rs +0 -0
  114. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/src/lib.rs +0 -0
  115. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/src/line_driver.rs +0 -0
  116. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/src/main.rs +0 -0
  117. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/src/modal_groups.rs +0 -0
  118. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/src/state.rs +0 -0
  119. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/src/structure_scan.rs +0 -0
  120. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/src/types.rs +0 -0
  121. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/src/vm.rs +0 -0
  122. {nc_gcode_interpreter-0.2.4 → nc_gcode_interpreter-0.2.6}/uv.lock +0 -0
@@ -4,6 +4,50 @@ 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.6] - 2026-07-09
8
+
9
+ ### Added
10
+
11
+ - String operations (manual 4.1.4), so real CAM programs that build a
12
+ timestamped protocol-file name now parse and run end to end:
13
+ - Single-character writes into a STRING variable, `STRING[<index>] = "<char>"`
14
+ (manual 4.1.4.8), 0-based; only user-defined variables, never system
15
+ variables. The right-hand side must be exactly one character and the index
16
+ in range, both enforced loudly.
17
+ - The `<<` concatenation operator, joining quoted strings, STRING variables,
18
+ string functions and numbers (INT in plain form, REAL with up to 10 decimals
19
+ and trailing zeros trimmed, per manual 4.1.4.1).
20
+ - String functions: `SPRINT` (printf-style: `%d %f %s %x %b %c` with field
21
+ width and precision; unsupported conversions error loudly), `SUBSTR`,
22
+ `INDEX`, `RINDEX`, `NUMBER`, `STRLEN`, `ISNUMBER`. All string indices are
23
+ 0-based; the search family returns `-1` when not found; `NUMBER` on a
24
+ non-numeric string is a hard error.
25
+
26
+ ### Fixed
27
+
28
+ - A quoted string that is only whitespace (e.g. `" "`) no longer loses its
29
+ content: the implicit `WHITESPACE` rule used to eat it, so `INDEX(x, " ")`
30
+ found nothing — exactly the space that date-formatting code searches for.
31
+ Quoted-string bodies are now taken verbatim (leading/trailing spaces too).
32
+ - Declaring a variable whose name collides with a reserved axis letter *with*
33
+ an initializer (e.g. `DEF STRING[13] S = "..."`) now reports the name
34
+ collision ("conflicts with an axis name") instead of a confusing downstream
35
+ "cannot assign a string" error.
36
+
37
+ ## [v0.2.5] - 2026-07-08
38
+
39
+ ### Changed
40
+
41
+ - Interpreter throughput on the DataFrame/batch path: the per-output-row `Row`
42
+ allocations are now recycled instead of freed. Profiling the 1.1 GB → DataFrame
43
+ conversion showed ~50% of CPU in the system allocator, dominated by allocating
44
+ and freeing each of the 22M rows' cell buffers. After a batch is built its rows
45
+ are cleared (capacity retained) into a pool and handed back to the interpreter
46
+ to refill, bounding live row allocations to ~2× the batch size instead of the
47
+ whole-file row count. Output is byte-identical; the streaming (`nc_to_rows`)
48
+ and in-memory collect paths are unchanged. ~10% off end-to-end on the large
49
+ real-world program (#61).
50
+
7
51
  ## [v0.2.4] - 2026-07-08
8
52
 
9
53
  ### Changed
@@ -506,7 +506,7 @@ checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
506
506
 
507
507
  [[package]]
508
508
  name = "nc-gcode-interpreter"
509
- version = "0.2.4"
509
+ version = "0.2.6"
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.4"
3
+ version = "0.2.6"
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.4
3
+ Version: 0.2.6
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: Programming Language :: Python :: 3.12
@@ -0,0 +1,79 @@
1
+ """String operations from NC programming manual 4.1.4: single-character
2
+ writes, `<<` concatenation, and the SPRINT/SUBSTR/INDEX/NUMBER family."""
3
+
4
+ import pytest
5
+ from nc_gcode_interpreter import nc_to_dataframe
6
+
7
+
8
+ def test_single_character_write_replaces_one_character():
9
+ """STRING[<index>] = "<char>" overwrites one character in place (manual
10
+ 4.1.4.8); index 5 is the space in "AXIS N HERE" (0-based)."""
11
+ _df, state = nc_to_dataframe('DEF STRING[13] MSG = "AXIS N HERE"\nMSG[5] = "X"\n')
12
+ assert state["string_table"]["MSG"] == "AXIS X HERE"
13
+
14
+
15
+ def test_date_formatting_idiom_zero_pads_with_index_and_char_write():
16
+ """The real CAM idiom that motivated this feature: SPRINT a date (its
17
+ `%2d` fields space-pad single digits), then replace every space with "0"
18
+ via a WHILE/INDEX/char-write loop."""
19
+ program = (
20
+ "DEF STRING[13] DT\n"
21
+ 'DT = SPRINT("20%2d%2d%2dT%2d%2d", 24, 5, 29, 13, 31)\n'
22
+ 'WHILE (INDEX(DT, " ") > 0)\n'
23
+ ' DT[INDEX(DT, " ")] = "0"\n'
24
+ "ENDWHILE\n"
25
+ )
26
+ _df, state = nc_to_dataframe(program)
27
+ assert state["string_table"]["DT"] == "20240529T1331"
28
+
29
+
30
+ def test_concat_operator_joins_strings_and_formats_numbers():
31
+ """`<<` joins quoted strings and STRING variables; an INT converts to
32
+ plain form and a REAL keeps up to 10 decimals with trailing zeros trimmed
33
+ (manual 4.1.4.1) - distinct from SPRINT `%F`'s fixed six decimals."""
34
+ program = (
35
+ "DEF STRING[13] DT = \"20240529T1331\"\n"
36
+ "DEF STRING[100] WF\n"
37
+ 'WF = "//NC:/DIR/CAL_" << DT << ".TXT"\n'
38
+ "DEF INT IDX = 2\n"
39
+ "DEF REAL VAL = 9.654\n"
40
+ "DEF STRING[50] MSGS\n"
41
+ 'MSGS = "i:" << IDX << "/v:" << VAL\n'
42
+ )
43
+ _df, state = nc_to_dataframe(program)
44
+ assert state["string_table"]["WF"] == "//NC:/DIR/CAL_20240529T1331.TXT"
45
+ assert state["string_table"]["MSGS"] == "i:2/v:9.654"
46
+
47
+
48
+ @pytest.mark.parametrize(
49
+ "expression, expected",
50
+ [
51
+ ('NUMBER(SUBSTR("20240529T1331", 2, 2))', 24.0),
52
+ ('STRLEN("20240529T1331")', 13.0),
53
+ ('INDEX("20240529T1331", "T")', 8.0),
54
+ ('INDEX("20240529T1331", "Q")', -1.0),
55
+ ('RINDEX("20240529T1331", "3")', 11.0),
56
+ ('ISNUMBER("12.5")', 1.0),
57
+ ('ISNUMBER("x9")', 0.0),
58
+ ],
59
+ )
60
+ def test_string_query_functions(expression, expected):
61
+ """INDEX/RINDEX/STRLEN/ISNUMBER are 0-based; the search family returns -1
62
+ when the character is not found (manual 4.1.4.6)."""
63
+ _df, state = nc_to_dataframe(f"R1={expression}")
64
+ assert state["symbol_table"]["R1"] == expected
65
+
66
+
67
+ def test_number_on_non_numeric_string_raises():
68
+ """NUMBER on a string that is not a valid number is a loud failure, never
69
+ a silent 0 (manual 4.1.4.2)."""
70
+ with pytest.raises(ValueError, match="not a valid number"):
71
+ nc_to_dataframe('R1 = NUMBER("abc")')
72
+
73
+
74
+ def test_whitespace_only_string_literal_keeps_its_content():
75
+ """Regression: a quoted string that is only whitespace (e.g. " ") must
76
+ keep its content rather than being eaten by implicit whitespace skipping -
77
+ exactly the space the date-zeroing idiom above searches for."""
78
+ _df, state = nc_to_dataframe('DEF STRING[8] STR = " X"\nR1 = INDEX(STR, " ")\n')
79
+ assert state["symbol_table"]["R1"] == 0.0
@@ -145,16 +145,34 @@ assignment = {
145
145
 
146
146
  | (variable ~ "=" ~ axis_increment) // variable must be an axis, and may be a normal variable. Sort it out after parsing
147
147
 
148
+ // A string concatenation (`<<`) must be tried before `expression`/`string_value`:
149
+ // those would each match only the first operand and leave `<< ...` dangling.
150
+ | (variable ~ "=" ~ string_expression) // string variable built with << (manual 4.1.4)
151
+
148
152
  | (variable ~ "=" ~ expression) // variable may be an axis, and may be a normal variable. Sort it out after parsing
149
153
 
150
154
  | (variable ~ "=" ~ string_value) // string variable (DEF STRING or reassignment); never an axis
151
155
 
156
+ | (variable_array ~ "=" ~ string_expression) // string-valued array element (never an axis)
157
+
152
158
  | (variable_array ~ "=" ~ expression) // variable may not be an axis
159
+
160
+ | (variable_array ~ "=" ~ string_value) // single-character write into a STRING variable: STRING[<index>] = "<char>" (manual 4.1.4.8)
153
161
  }
154
162
  axis_increment = { ^"IC" ~ "(" ~ expression ~ ")" }
155
- // A quoted string as assignment RHS. Non-silent (unlike quoted_string) so an
156
- // empty "" still yields a pair to interpret.
157
- string_value = { QUOTE ~ string? ~ QUOTE }
163
+ // A quoted string. Compound-atomic ($): no implicit WHITESPACE is skipped
164
+ // between the quotes, so a string that is only spaces (e.g. " ", searched for
165
+ // by INDEX in real date-formatting code) keeps its content instead of being
166
+ // eaten by the WHITESPACE rule. The inner `string` pair still surfaces.
167
+ string_value = ${ QUOTE ~ string ~ QUOTE }
168
+ // String concatenation with the link operator `<<` (manual 4.1.4): joins
169
+ // quoted strings, STRING variables, string functions (SPRINT/SUBSTR) and
170
+ // numbers (converted to text) into one string. Requires at least one `<<` so
171
+ // a lone string or expression still matches the simpler rules. Each operand is
172
+ // a quoted string or a full (numeric or string-returning) expression; `<<` is
173
+ // not an expression operator, so `expression` stops at the next `<<`.
174
+ string_expression = { concat_operand ~ ("<<" ~ concat_operand)+ }
175
+ concat_operand = { string_value | expression }
158
176
 
159
177
  assignment_multi = { variable_array ~ "=" ~ (value_array | value_repeating) }
160
178
  value_array = { "SET" ~ "(" ~ (expression | value_none) ~ ("," ~ (expression | value_none))* ~ ")" }
@@ -203,7 +221,7 @@ integer = @{ "-"? ~ ASCII_DIGIT+ ~ !ASCII_ALPHA } // Ensure integers ar
203
221
  arith_fun = { arith_fun_name ~ "(" ~ function_arguments ~ ")" }
204
222
  arith_fun_name = @{ ASCII_ALPHA ~ (ASCII_ALPHANUMERIC | "_")* }
205
223
 
206
- tool_selection = { ^"T" ~ "=" ~ quoted_string }
224
+ tool_selection = { ^"T" ~ "=" ~ string_value }
207
225
 
208
226
  // Frame instructions. A single rule captures the whole family (with any
209
227
  // number of axis assignments, including none: a bare substituting frame
@@ -216,9 +234,11 @@ frame_kw = @{
216
234
 
217
235
  // function call parser
218
236
  non_returning_function_call = { identifier ~ ("(" ~ function_arguments? ~ ")")? }
219
- function_arguments = { ((expression | quoted_string)? ~ ",")* ~ (expression | quoted_string) }
220
- quoted_string = _{ QUOTE ~ (string)* ~ QUOTE }
221
- string = { (!"\"" ~ ANY)+ }
237
+ function_arguments = { ((expression | string_value)? ~ ",")* ~ (expression | string_value) }
238
+ // Atomic body: a maximal run of non-quote characters, taken verbatim with no
239
+ // WHITESPACE skipping (may be empty for ""). Its quote wrapper (string_value)
240
+ // is compound-atomic, so leading/trailing/only-whitespace content survives.
241
+ string = @{ (!"\"" ~ ANY)* }
222
242
 
223
243
  // builtins, globally available names
224
244
  nc_variable = { "$" ~ identifier }