AuxPreprocessor: ignore M-codes inside paren comments

Two bugs surfaced when macros got prose like:

    (Composed from atoms: M102 = RELEASE (V1 on), M103 = CLAMP)

1. _ATC_M_RE.finditer was being run against the raw line, so the
   M102/M103 *inside* the comment fired spurious release/clamp
   hooks at file load.
2. The simple _PAREN_COMMENT_RE = re.compile(r'\(\[^)]*\)') is a
   greedy non-nested match, so a header with a nested paren
   (e.g. 'M102 = RELEASE (V1 on)') only stripped the inner
   paren, leaving the trailing 'M103 = CLAMP)' visible to the
   matcher.

Fix:

  - Add _strip_comments() that walks the line tracking paren depth
    and drops the trailing semicolon comment. Handles nested parens
    correctly.
  - Run _ATC_M_RE.finditer against the comment-stripped 'code'
    instead of the raw line, so prose mentions are inert.
  - Drop the original line's comments from the rewritten output;
    keeping them around led to the M-codes being matched twice
    (once stripped, once still in the trailing comment).
  - Use _strip_comments in file_uses_aux too.

The grab.nc and drop.nc macros on the controller already had the
prose headers; they now preprocess correctly to clean
release / G4 / clamp and release / N x eject / Z0 / clamp
sequences.
This commit is contained in:
2026-05-03 18:20:19 +02:00
parent 130c39fad9
commit d5ad717f78

View File

@@ -46,8 +46,42 @@ import tempfile
# Strip line comments so we don't get fooled by "(M100 not really)".
# Note this is a simple regex and doesn't handle nested parentheses
# - which actually occur in real macro headers like
# `(Composed from atoms: M102 = RELEASE (V1 on), M103 = CLAMP)`.
# Use _strip_comments() below for a parser that does handle them.
_PAREN_COMMENT_RE = re.compile(r'\([^)]*\)')
def _strip_comments(line):
"""Return `line` with paren comments and the trailing semicolon
comment removed. Handles arbitrarily nested parentheses (RS274
technically forbids them but real-world gcode comments often
contain prose with parens, e.g. `(M102 = RELEASE (V1 on))`).
Returns just the executable code, with the original whitespace
preserved between tokens."""
out = []
depth = 0
i = 0
n = len(line)
while i < n:
c = line[i]
if c == ';' and depth == 0:
break
if c == '(':
depth += 1
i += 1
continue
if c == ')':
if depth > 0: depth -= 1
i += 1
continue
if depth == 0:
out.append(c)
i += 1
return ''.join(out)
# ATC pneumatics M-codes mapped onto hook events. M101 is
# deliberately unassigned (see header).
_ATC_M_CODES = {
@@ -135,8 +169,7 @@ class AuxPreprocessor(object):
try:
with open(path, 'r', encoding='utf-8', errors='replace') as f:
for line in f:
code = _PAREN_COMMENT_RE.sub('', line)
code = code.split(';', 1)[0]
code = _strip_comments(line)
if _ATC_M_RE.search(code):
return True
if couple_active:
@@ -327,8 +360,7 @@ class AuxPreprocessor(object):
line = raw.rstrip('\n')
# Comment-only or blank lines pass through verbatim.
code = _PAREN_COMMENT_RE.sub('', line)
code = code.split(';', 1)[0]
code = _strip_comments(line)
if not code.strip():
fout.write(raw)
continue
@@ -347,10 +379,14 @@ class AuxPreprocessor(object):
if self._maybe_inject_a_down(code, fout):
rewrote_any = True
# ATC M-codes (M100/M102/M103). Each ATC M-code on the line
# is replaced with its (MSG,HOOK:<event>:) line and
# stripped from the residual.
atc_matches = list(_ATC_M_RE.finditer(line))
# ATC M-codes (M100/M102/M103). Match against the
# comment-stripped `code` so prose mentions like
# `(M102 = RELEASE)` inside a comment don't spuriously
# fire hooks. Each match emits a (MSG,HOOK:<event>:)
# line; the M-code is stripped from the executable
# residual but the original line's comments are kept
# for log readability.
atc_matches = list(_ATC_M_RE.finditer(code))
if atc_matches:
rewrote_any = True
for m in atc_matches:
@@ -359,18 +395,13 @@ class AuxPreprocessor(object):
event = _ATC_M_CODES.get(num)
if event:
fout.write('(MSG,HOOK:%s:)\n' % event)
line = _ATC_M_RE.sub('', line)
code = _PAREN_COMMENT_RE.sub('', line)
code = code.split(';', 1)[0]
if not code.strip():
# Nothing meaningful left; preserve any trailing
# comment text but skip empty lines.
rest = line.rstrip()
if rest:
fout.write(rest + '\n')
continue
# Other gcode remains on the line - emit it.
fout.write(line + '\n')
code_stripped = _ATC_M_RE.sub('', code).strip()
if code_stripped:
# Mixed line: keep the residual executable
# gcode. Drop the comments to keep the
# rewritten file tidy (the original line's
# text already appears once as the input).
fout.write(code_stripped + '\n')
continue
# No rewrite needed.