nc-gcode-interpreter 0.2.2__tar.gz → 0.2.3__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 (119) hide show
  1. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/CHANGELOG.md +20 -0
  2. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/Cargo.lock +1 -1
  3. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/Cargo.toml +1 -1
  4. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/PKG-INFO +1 -1
  5. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/nc_gcode_interpreter/_internal.pyi +4 -0
  6. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/tests/test_structured_errors.py +41 -0
  7. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/src/errors.rs +240 -8
  8. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/src/interpret_rules.rs +25 -6
  9. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/src/lib.rs +13 -8
  10. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/src/state.rs +9 -1
  11. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/.github/workflows/build-and-release.yml +0 -0
  12. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/.gitignore +0 -0
  13. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/CONTRIBUTING.md +0 -0
  14. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/Development.md +0 -0
  15. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/LICENSE +0 -0
  16. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/README.md +0 -0
  17. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/TODO.md +0 -0
  18. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/docs/sinumerik-execution-model.md +0 -0
  19. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/actual_position.csv +0 -0
  20. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/actual_position.mpf +0 -0
  21. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/arc.csv +0 -0
  22. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/arc.mpf +0 -0
  23. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/arrays.csv +0 -0
  24. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/arrays.mpf +0 -0
  25. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/axis_index_assignment.csv +0 -0
  26. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/axis_index_assignment.mpf +0 -0
  27. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/basic_math.csv +0 -0
  28. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/basic_math.mpf +0 -0
  29. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/case.csv +0 -0
  30. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/case.mpf +0 -0
  31. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/case_insensitive_variables.csv +0 -0
  32. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/case_insensitive_variables.mpf +0 -0
  33. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/custom_vars.csv +0 -0
  34. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/custom_vars.mpf +0 -0
  35. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/def_string.csv +0 -0
  36. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/def_string.mpf +0 -0
  37. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/defaults.csv +0 -0
  38. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/defaults.mpf +0 -0
  39. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/edge_cases.csv +0 -0
  40. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/edge_cases.mpf +0 -0
  41. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/flattening/flatten_demo.mpf +0 -0
  42. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/flattening/flatten_demo_flattened.csv +0 -0
  43. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/flattening/flatten_demo_raw.csv +0 -0
  44. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/for_loop.csv +0 -0
  45. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/for_loop.mpf +0 -0
  46. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/function_calls.csv +0 -0
  47. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/function_calls.mpf +0 -0
  48. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/goto.csv +0 -0
  49. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/goto.mpf +0 -0
  50. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/if_statement.csv +0 -0
  51. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/if_statement.mpf +0 -0
  52. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/increment.csv +0 -0
  53. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/increment.mpf +0 -0
  54. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/logic_operators.csv +0 -0
  55. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/logic_operators.mpf +0 -0
  56. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/loop.csv +0 -0
  57. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/loop.mpf +0 -0
  58. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/multiple_m_codes.csv +0 -0
  59. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/multiple_m_codes.mpf +0 -0
  60. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/precedence.csv +0 -0
  61. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/precedence.mpf +0 -0
  62. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/r_param.csv +0 -0
  63. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/r_param.mpf +0 -0
  64. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/repeat.csv +0 -0
  65. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/repeat.mpf +0 -0
  66. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/simple.csv +0 -0
  67. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/simple.mpf +0 -0
  68. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/spline.csv +0 -0
  69. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/spline.mpf +0 -0
  70. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/tool.csv +0 -0
  71. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/tool.mpf +0 -0
  72. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/trans.csv +0 -0
  73. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/trans.mpf +0 -0
  74. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/trans2.csv +0 -0
  75. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/trans2.mpf +0 -0
  76. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/trans_ic.csv +0 -0
  77. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/trans_ic.mpf +0 -0
  78. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/trans_reset.csv +0 -0
  79. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/trans_reset.mpf +0 -0
  80. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/trig.csv +0 -0
  81. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/trig.mpf +0 -0
  82. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/variables.csv +0 -0
  83. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/examples/variables.mpf +0 -0
  84. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/ggroups/generate_g_commands.py +0 -0
  85. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/pyproject.toml +0 -0
  86. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/example/minimal.py +0 -0
  87. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/example/streaming.py +0 -0
  88. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/nc_gcode_interpreter/__init__.py +0 -0
  89. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/nc_gcode_interpreter/cli.py +0 -0
  90. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/nc_gcode_interpreter/ggroups.json +0 -0
  91. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/nc_gcode_interpreter/py.typed +0 -0
  92. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/nc_gcode_interpreter/viz.py +0 -0
  93. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/tests/test_arithmetic_functions.py +0 -0
  94. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/tests/test_batch_variables.py +0 -0
  95. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/tests/test_batches.py +0 -0
  96. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/tests/test_diagnostics.py +0 -0
  97. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/tests/test_expected_output.py +0 -0
  98. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/tests/test_field_defects.py +0 -0
  99. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/tests/test_flags.py +0 -0
  100. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/tests/test_flatten.py +0 -0
  101. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/tests/test_g_groups.py +0 -0
  102. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/tests/test_g_vocabulary.py +0 -0
  103. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/tests/test_jumps.py +0 -0
  104. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/tests/test_real_comparisons.py +0 -0
  105. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/tests/test_stage1.py +0 -0
  106. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/tests/test_streaming.py +0 -0
  107. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/python/tests/test_unsupported.py +0 -0
  108. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/rustfmt.toml +0 -0
  109. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/src/flatten.rs +0 -0
  110. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/src/grammar.pest +0 -0
  111. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/src/interpreter.rs +0 -0
  112. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/src/line_driver.rs +0 -0
  113. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/src/main.rs +0 -0
  114. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/src/modal_groups.rs +0 -0
  115. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/src/output.rs +0 -0
  116. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/src/structure_scan.rs +0 -0
  117. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/src/types.rs +0 -0
  118. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/tests/cli.rs +0 -0
  119. {nc_gcode_interpreter-0.2.2 → nc_gcode_interpreter-0.2.3}/uv.lock +0 -0
@@ -4,6 +4,26 @@ 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.3] - 2026-07-07
8
+
9
+ ### Added
10
+
11
+ - `NcError.kind`: a stable, machine-readable string discriminating the error
12
+ class (e.g. `"unexpected_axis"`, `"undefined_variable"`, `"unknown_g_command"`,
13
+ `"parse_context"`), so a consumer can branch on the kind of error without
14
+ string-matching the formatted message. Present on every `NcError` alongside
15
+ the existing `line` / `column` / `context` / `line_text` location attributes
16
+ (#56).
17
+
18
+ ### Fixed
19
+
20
+ - Validation/semantic errors now carry their source location. `Unexpected axis`,
21
+ and the "axis/reserved name used as a variable" definition errors, previously
22
+ raised an `NcError` with `line` / `column` / `context` / `line_text` all
23
+ `None`; they now anchor to the offending line (and expose its text), so an
24
+ editor can mark the exact spot - matching what syntactic parse errors already
25
+ did (#56).
26
+
7
27
  ## [v0.2.2] - 2026-07-07
8
28
 
9
29
  ### Added
@@ -506,7 +506,7 @@ checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
506
506
 
507
507
  [[package]]
508
508
  name = "nc-gcode-interpreter"
509
- version = "0.2.2"
509
+ version = "0.2.3"
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.2"
3
+ version = "0.2.3"
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.2
3
+ Version: 0.2.3
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: Programming Language :: Python :: 3.12
@@ -9,6 +9,10 @@ class NcError(ValueError):
9
9
  """NC parse/interpret error carrying structured location data. Subclasses
10
10
  ValueError so `except ValueError` keeps working."""
11
11
 
12
+ #: Stable, machine-readable discriminator for the error class (e.g.
13
+ #: ``"unexpected_axis"``, ``"undefined_variable"``), for branching without
14
+ #: matching the formatted message.
15
+ kind: str
12
16
  line: Optional[int]
13
17
  column: Optional[int]
14
18
  context: Optional[str]
@@ -48,3 +48,44 @@ def test_str_is_still_the_full_message():
48
48
  with pytest.raises(NcError) as exc:
49
49
  nc_to_dataframe("X=R99\n")
50
50
  assert "Undefined variable" in str(exc.value)
51
+
52
+
53
+ def test_kind_is_a_stable_machine_readable_discriminator():
54
+ # #56: consumers branch on `kind` instead of string-matching the message.
55
+ with pytest.raises(NcError) as exc:
56
+ nc_to_dataframe("X=R99\n")
57
+ assert exc.value.kind == "undefined_variable"
58
+
59
+ with pytest.raises(NcError) as exc:
60
+ nc_to_dataframe("G999 X1\n")
61
+ assert exc.value.kind == "unknown_g_command"
62
+
63
+ # Present (and stable) on every NcError, alongside the location attrs.
64
+ with pytest.raises(NcError) as exc:
65
+ nc_to_dataframe("G1 X10\nX20 Y((\n")
66
+ assert exc.value.kind == "parse_context"
67
+
68
+
69
+ def test_validation_error_carries_kind_and_location():
70
+ # #56: an undeclared axis is a validation error; it now reports both a
71
+ # stable kind AND the source location (previously all-None).
72
+ with pytest.raises(NcError) as exc:
73
+ # Q is not a declared axis; the frame instruction is on line 2.
74
+ nc_to_dataframe("G1 X0 Y0 F100\nTRANS Q=10\n")
75
+ e = exc.value
76
+ assert e.kind == "unexpected_axis"
77
+ assert e.line == 2
78
+ assert e.column is None
79
+ assert e.line_text is not None and "Q=10" in e.line_text
80
+ assert "Unexpected axis" in str(e)
81
+
82
+
83
+ def test_axis_used_as_variable_carries_location():
84
+ # Declaring a variable with an axis name is a validation error that now
85
+ # anchors to the offending DEF line.
86
+ with pytest.raises(NcError) as exc:
87
+ nc_to_dataframe("DEF REAL X\n")
88
+ e = exc.value
89
+ assert e.kind == "axis_used_as_variable"
90
+ assert e.line == 1
91
+ assert e.line_text is not None and "X" in e.line_text
@@ -99,11 +99,26 @@ To fix this, ensure that each block contains at most one M command.
99
99
  message: String,
100
100
  },
101
101
  #[error("Unexpected axis '{axis}'. Valid axes are: {axes}")]
102
- UnexpectedAxis { axis: String, axes: String },
102
+ UnexpectedAxis {
103
+ axis: String,
104
+ axes: String,
105
+ /// Source location of the offending axis word. Not shown in the
106
+ /// formatted message, only exposed as data (see [`ParsingError::location`]).
107
+ line_no: usize,
108
+ preview: String,
109
+ },
103
110
  #[error("Cannot define a variable named '{name}', as it conflicts with an axis name")]
104
- AxisUsedAsVariable { name: String },
111
+ AxisUsedAsVariable {
112
+ name: String,
113
+ line_no: usize,
114
+ preview: String,
115
+ },
105
116
  #[error("Cannot define a variable named '{name}', as it is a reserved block address (spline PW/SD/PL)")]
106
- ReservedNameUsedAsVariable { name: String },
117
+ ReservedNameUsedAsVariable {
118
+ name: String,
119
+ line_no: usize,
120
+ preview: String,
121
+ },
107
122
  #[error(
108
123
  r#"
109
124
  Missing axis mapping on line {line_no}
@@ -280,16 +295,48 @@ impl ParsingError {
280
295
  | Self::JumpTargetNotFound { line_no, preview, .. }
281
296
  | Self::UnmatchedStructure { line_no, preview, .. }
282
297
  | Self::UnknownGCommand { line_no, preview, .. }
283
- | Self::InvalidFunctionArity { line_no, preview, .. } => some(*line_no, None, None, Some(preview)),
298
+ | Self::InvalidFunctionArity { line_no, preview, .. }
299
+ // Semantic/validation errors: the offending block is known at raise
300
+ // time, so they anchor to a line (no column) like the others above.
301
+ | Self::UnexpectedAxis { line_no, preview, .. }
302
+ | Self::AxisUsedAsVariable { line_no, preview, .. }
303
+ | Self::ReservedNameUsedAsVariable { line_no, preview, .. } => some(*line_no, None, None, Some(preview)),
284
304
  Self::ParseError { .. }
285
305
  | Self::InvalidElementCount { .. }
286
306
  | Self::InvalidCondition
287
307
  | Self::UnexpectedOperator { .. }
288
308
  | Self::LoopLimit { .. }
289
- | Self::StreamClosed
290
- | Self::UnexpectedAxis { .. }
291
- | Self::AxisUsedAsVariable { .. }
292
- | Self::ReservedNameUsedAsVariable { .. } => None,
309
+ | Self::StreamClosed => None,
310
+ }
311
+ }
312
+
313
+ /// A stable, machine-readable discriminator for the error class, so a
314
+ /// consumer can branch on the kind of error without string-matching the
315
+ /// formatted message. Exposed to Python as the `NcError.kind` attribute.
316
+ /// These strings are part of the public API: keep them stable.
317
+ pub fn kind(&self) -> &'static str {
318
+ match self {
319
+ Self::ParsingContext { .. } => "parse_context",
320
+ Self::UnknownVariable { .. } => "unknown_variable",
321
+ Self::UndefinedVariable { .. } => "undefined_variable",
322
+ Self::UnexpectedRule { .. } => "unexpected_rule",
323
+ Self::ParseError { .. } => "parse_error",
324
+ Self::InvalidElementCount { .. } => "invalid_element_count",
325
+ Self::InvalidCondition => "invalid_condition",
326
+ Self::UnexpectedOperator { .. } => "unexpected_operator",
327
+ Self::LoopLimit { .. } => "loop_limit",
328
+ Self::StreamClosed => "stream_closed",
329
+ Self::TooManyMCommands { .. } => "too_many_m_commands",
330
+ Self::UnexpectedAxis { .. } => "unexpected_axis",
331
+ Self::AxisUsedAsVariable { .. } => "axis_used_as_variable",
332
+ Self::ReservedNameUsedAsVariable { .. } => "reserved_name_used_as_variable",
333
+ Self::MissingAxisMapping { .. } => "missing_axis_mapping",
334
+ Self::InvalidAxisIndex { .. } => "invalid_axis_index",
335
+ Self::UnsupportedStatement { .. } => "unsupported_statement",
336
+ Self::JumpTargetNotFound { .. } => "jump_target_not_found",
337
+ Self::UnmatchedStructure { .. } => "unmatched_structure",
338
+ Self::UnknownGCommand { .. } => "unknown_g_command",
339
+ Self::InvalidFunctionArity { .. } => "invalid_function_arity",
293
340
  }
294
341
  }
295
342
  }
@@ -333,4 +380,189 @@ mod tests {
333
380
  assert!(ParsingError::StreamClosed.location().is_none());
334
381
  assert!(ParsingError::InvalidCondition.location().is_none());
335
382
  }
383
+
384
+ #[test]
385
+ fn validation_errors_now_carry_a_location() {
386
+ // #56: semantic/validation errors (previously location-less) anchor to
387
+ // the offending line so an editor can mark the spot.
388
+ let axis = ParsingError::UnexpectedAxis {
389
+ axis: "QQ".to_string(),
390
+ axes: "X, Y, Z".to_string(),
391
+ line_no: 4,
392
+ preview: "TRANS QQ10".to_string(),
393
+ };
394
+ let loc = axis.location().expect("UnexpectedAxis now has a location");
395
+ assert_eq!((loc.line, loc.column), (4, None));
396
+ assert_eq!(loc.line_text.as_deref(), Some("TRANS QQ10"));
397
+
398
+ let dup = ParsingError::AxisUsedAsVariable {
399
+ name: "X".to_string(),
400
+ line_no: 2,
401
+ preview: "DEF REAL X".to_string(),
402
+ };
403
+ assert_eq!(dup.location().expect("has location").line, 2);
404
+ }
405
+
406
+ #[test]
407
+ fn kind_is_a_stable_per_variant_discriminator() {
408
+ // Exhaustive: `kind()` strings are public API, so pin every variant's
409
+ // value. The wildcard-free match in `kind()` forces a new variant to be
410
+ // handled there; this list forces its string to be chosen deliberately.
411
+ use crate::types::Rule;
412
+ let s = String::new;
413
+ let cases: Vec<(ParsingError, &str)> = vec![
414
+ (
415
+ ParsingError::ParsingContext {
416
+ line_no: 1,
417
+ column: None,
418
+ preview: s(),
419
+ context: s(),
420
+ message: s(),
421
+ },
422
+ "parse_context",
423
+ ),
424
+ (
425
+ ParsingError::UnknownVariable {
426
+ line_no: 1,
427
+ preview: s(),
428
+ variable: s(),
429
+ },
430
+ "unknown_variable",
431
+ ),
432
+ (
433
+ ParsingError::UndefinedVariable {
434
+ line_no: 1,
435
+ preview: s(),
436
+ name: s(),
437
+ },
438
+ "undefined_variable",
439
+ ),
440
+ (
441
+ ParsingError::UnexpectedRule {
442
+ rule: Rule::EOI,
443
+ context: s(),
444
+ line_no: 1,
445
+ preview: s(),
446
+ message: s(),
447
+ },
448
+ "unexpected_rule",
449
+ ),
450
+ (ParsingError::ParseError { message: s() }, "parse_error"),
451
+ (
452
+ ParsingError::InvalidElementCount { expected: 1, actual: 2 },
453
+ "invalid_element_count",
454
+ ),
455
+ (ParsingError::InvalidCondition, "invalid_condition"),
456
+ (
457
+ ParsingError::UnexpectedOperator { operator: s() },
458
+ "unexpected_operator",
459
+ ),
460
+ (ParsingError::LoopLimit { limit: s() }, "loop_limit"),
461
+ (ParsingError::StreamClosed, "stream_closed"),
462
+ (
463
+ ParsingError::TooManyMCommands {
464
+ line_no: 1,
465
+ preview: s(),
466
+ message: s(),
467
+ },
468
+ "too_many_m_commands",
469
+ ),
470
+ (
471
+ ParsingError::UnexpectedAxis {
472
+ axis: s(),
473
+ axes: s(),
474
+ line_no: 1,
475
+ preview: s(),
476
+ },
477
+ "unexpected_axis",
478
+ ),
479
+ (
480
+ ParsingError::AxisUsedAsVariable {
481
+ name: s(),
482
+ line_no: 1,
483
+ preview: s(),
484
+ },
485
+ "axis_used_as_variable",
486
+ ),
487
+ (
488
+ ParsingError::ReservedNameUsedAsVariable {
489
+ name: s(),
490
+ line_no: 1,
491
+ preview: s(),
492
+ },
493
+ "reserved_name_used_as_variable",
494
+ ),
495
+ (
496
+ ParsingError::MissingAxisMapping {
497
+ line_no: 1,
498
+ preview: s(),
499
+ axis: s(),
500
+ },
501
+ "missing_axis_mapping",
502
+ ),
503
+ (
504
+ ParsingError::InvalidAxisIndex {
505
+ line_no: 1,
506
+ preview: s(),
507
+ axis: s(),
508
+ index: 0,
509
+ },
510
+ "invalid_axis_index",
511
+ ),
512
+ (
513
+ ParsingError::UnsupportedStatement {
514
+ line_no: 1,
515
+ preview: s(),
516
+ statement: s(),
517
+ hint: s(),
518
+ },
519
+ "unsupported_statement",
520
+ ),
521
+ (
522
+ ParsingError::JumpTargetNotFound {
523
+ line_no: 1,
524
+ preview: s(),
525
+ target: s(),
526
+ search_direction: s(),
527
+ hint: s(),
528
+ },
529
+ "jump_target_not_found",
530
+ ),
531
+ (
532
+ ParsingError::UnmatchedStructure {
533
+ line_no: 1,
534
+ preview: s(),
535
+ message: s(),
536
+ },
537
+ "unmatched_structure",
538
+ ),
539
+ (
540
+ ParsingError::UnknownGCommand {
541
+ line_no: 1,
542
+ preview: s(),
543
+ code: s(),
544
+ },
545
+ "unknown_g_command",
546
+ ),
547
+ (
548
+ ParsingError::InvalidFunctionArity {
549
+ line_no: 1,
550
+ preview: s(),
551
+ name: s(),
552
+ expected: 1,
553
+ actual: 2,
554
+ },
555
+ "invalid_function_arity",
556
+ ),
557
+ ];
558
+ for (err, expected) in &cases {
559
+ assert_eq!(err.kind(), *expected, "kind mismatch for {err:?}");
560
+ }
561
+ // Every kind string is distinct (no two variants share a discriminator).
562
+ let mut kinds: Vec<&str> = cases.iter().map(|(_, k)| *k).collect();
563
+ kinds.sort_unstable();
564
+ let unique = kinds.len();
565
+ kinds.dedup();
566
+ assert_eq!(kinds.len(), unique, "duplicate kind discriminator");
567
+ }
336
568
  }
@@ -1168,9 +1168,17 @@ fn interpret_definition(element: Pair<Rule>, output: &mut Output, state: &mut St
1168
1168
  let (line_no, preview) = get_error_context(&pair, state);
1169
1169
  let res = interpret_assignment(pair, state)?;
1170
1170
  if state.is_axis(res.0.as_str()) {
1171
- return Err(ParsingError::AxisUsedAsVariable { name: res.0 });
1171
+ return Err(ParsingError::AxisUsedAsVariable {
1172
+ name: res.0,
1173
+ line_no,
1174
+ preview,
1175
+ });
1172
1176
  } else if state.is_block_address(res.0.as_str()) {
1173
- return Err(ParsingError::ReservedNameUsedAsVariable { name: res.0 });
1177
+ return Err(ParsingError::ReservedNameUsedAsVariable {
1178
+ name: res.0,
1179
+ line_no,
1180
+ preview,
1181
+ });
1174
1182
  }
1175
1183
  match res.1 {
1176
1184
  Some(value) => {
@@ -1207,11 +1215,20 @@ fn interpret_definition(element: Pair<Rule>, output: &mut Output, state: &mut St
1207
1215
  }
1208
1216
  }
1209
1217
  Rule::variable => {
1218
+ let (line_no, preview) = get_error_context(&pair, state);
1210
1219
  let key = interpret_variable(pair, state)?;
1211
1220
  if state.is_axis(&key) {
1212
- return Err(ParsingError::AxisUsedAsVariable { name: key });
1221
+ return Err(ParsingError::AxisUsedAsVariable {
1222
+ name: key,
1223
+ line_no,
1224
+ preview,
1225
+ });
1213
1226
  } else if state.is_block_address(&key) {
1214
- return Err(ParsingError::ReservedNameUsedAsVariable { name: key });
1227
+ return Err(ParsingError::ReservedNameUsedAsVariable {
1228
+ name: key,
1229
+ line_no,
1230
+ preview,
1231
+ });
1215
1232
  }
1216
1233
  if is_string {
1217
1234
  state.string_table.insert(key.clone(), String::new());
@@ -1883,6 +1900,8 @@ fn frame_assignments(pairs: Vec<Pair<Rule>>, state: &mut State) -> Result<Vec<(S
1883
1900
  return Err(ParsingError::UnexpectedAxis {
1884
1901
  axis: key,
1885
1902
  axes: state.axis_identifiers.join(", "),
1903
+ line_no: pair_line_no,
1904
+ preview: pair_preview,
1886
1905
  });
1887
1906
  }
1888
1907
  result.push((key, value));
@@ -1908,14 +1927,14 @@ fn interpret_frame_op(element: Pair<Rule>, state: &mut State) -> Result<(), Pars
1908
1927
  // frames"). Bare TRANS is therefore just the reset.
1909
1928
  state.reset_translations();
1910
1929
  for (key, value) in frame_assignments(assignments, state)? {
1911
- state.update_translation(&key, value)?;
1930
+ state.update_translation(&key, value, line_no, &preview)?;
1912
1931
  }
1913
1932
  Ok(())
1914
1933
  }
1915
1934
  "ATRANS" => {
1916
1935
  for (key, value) in frame_assignments(assignments, state)? {
1917
1936
  let current_translation = state.get_translation(&key);
1918
- state.update_translation(&key, current_translation + value)?;
1937
+ state.update_translation(&key, current_translation + value, line_no, &preview)?;
1919
1938
  }
1920
1939
  Ok(())
1921
1940
  }
@@ -71,15 +71,18 @@ mod python_bindings {
71
71
  "NC parse/interpret error. Subclasses ValueError (so `except ValueError` \
72
72
  still catches it) and carries the error's source location as data: the \
73
73
  `line`, `column`, `context`, and `line_text` attributes (each an int / \
74
- str, or None when not applicable). `str(err)` is the full formatted \
75
- message as before."
74
+ str, or None when not applicable), plus a stable `kind` string \
75
+ discriminating the error class (e.g. 'unexpected_axis', \
76
+ 'undefined_variable') for branching without matching the message. \
77
+ `str(err)` is the full formatted message as before."
76
78
  );
77
79
 
78
- /// An error crossing the worker channel: the formatted message plus the
79
- /// structured location, so the consuming thread can raise an `NcError`
80
- /// carrying both.
80
+ /// An error crossing the worker channel: the formatted message, the stable
81
+ /// error-kind discriminator, and the structured location, so the consuming
82
+ /// thread can raise an `NcError` carrying all three.
81
83
  struct ErrInfo {
82
84
  message: String,
85
+ kind: &'static str,
83
86
  location: Option<ErrorLocation>,
84
87
  }
85
88
 
@@ -87,13 +90,14 @@ mod python_bindings {
87
90
  fn from_error(error: &crate::errors::ParsingError) -> Self {
88
91
  ErrInfo {
89
92
  message: error.to_string(),
93
+ kind: error.kind(),
90
94
  location: error.location(),
91
95
  }
92
96
  }
93
97
 
94
- /// Build the Python `NcError`, always setting the four location
95
- /// attributes (None when absent) so callers can read them
96
- /// unconditionally.
98
+ /// Build the Python `NcError`, always setting the `kind` discriminator
99
+ /// and the four location attributes (None when absent) so callers can
100
+ /// read them unconditionally.
97
101
  fn into_pyerr(self, py: Python<'_>) -> PyErr {
98
102
  let err = NcError::new_err(self.message);
99
103
  let value = err.value(py);
@@ -101,6 +105,7 @@ mod python_bindings {
101
105
  Some(l) => (Some(l.line), l.column, l.context, l.line_text),
102
106
  None => (None, None, None, None),
103
107
  };
108
+ let _ = value.setattr("kind", self.kind);
104
109
  let _ = value.setattr("line", line);
105
110
  let _ = value.setattr("column", column);
106
111
  let _ = value.setattr("context", context);
@@ -226,7 +226,13 @@ impl State {
226
226
  }
227
227
 
228
228
  /// Updates the translation value for an axis
229
- pub fn update_translation(&mut self, axis: &str, value: f64) -> Result<(), ParsingError> {
229
+ pub fn update_translation(
230
+ &mut self,
231
+ axis: &str,
232
+ value: f64,
233
+ line_no: usize,
234
+ preview: &str,
235
+ ) -> Result<(), ParsingError> {
230
236
  if self.is_axis(axis) {
231
237
  self.translation.insert(axis.to_string(), value);
232
238
  Ok(())
@@ -234,6 +240,8 @@ impl State {
234
240
  Err(ParsingError::UnexpectedAxis {
235
241
  axis: axis.to_string(),
236
242
  axes: self.axis_identifiers.join(", "),
243
+ line_no,
244
+ preview: preview.to_string(),
237
245
  })
238
246
  }
239
247
  }