openscad-parser 3.0.1__tar.gz → 3.1.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openscad_parser
3
- Version: 3.0.1
3
+ Version: 3.1.0
4
4
  Summary: A PEG parser to read OpenSCAD language source code, with optional AST tree generation.
5
5
  Keywords: openscad,openscad parser,parser
6
6
  Author: Revar Desmera
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "openscad_parser"
7
- version = "3.0.1"
7
+ version = "3.1.0"
8
8
  description = "A PEG parser to read OpenSCAD language source code, with optional AST tree generation."
9
9
  readme = "README.rst"
10
10
  authors = [
@@ -16,7 +16,10 @@ def getOpenSCADParser(reduce_tree=False, debug=False, include_comments=False):
16
16
  Args:
17
17
  reduce_tree: If True, reduce the parse tree (default: False)
18
18
  debug: If True, enable debug output (default: False)
19
- include_comments: If True, include comments in the AST instead of skipping them (default: False)
19
+ include_comments: If True, a grammar that parses comments at the points
20
+ it lists (default: False). The getAST* functions no longer use it:
21
+ they parse with comments as whitespace and attach them afterwards
22
+ (openscad_parser.ast.comments), which accepts a comment anywhere.
20
23
 
21
24
  Returns:
22
25
  ParserPython instance configured for OpenSCAD parsing
@@ -8,6 +8,7 @@ from typing import Optional
8
8
  from arpeggio import NoMatch
9
9
  from openscad_parser import getOpenSCADParser, strict_commas
10
10
  from openscad_parser.grammar import _STRICT_COMMAS
11
+ from .comments import _attach_all_comments
11
12
  from .source_map import SourceMap, process_includes as process_includes_func
12
13
 
13
14
  # Import all AST nodes from nodes
@@ -279,8 +280,11 @@ def getASTfromString(code: str, include_comments: bool = False, origin: str = "<
279
280
  source_map = SourceMap()
280
281
  source_map.add_origin(origin, code)
281
282
 
282
- parser = getOpenSCADParser(reduce_tree=False, include_comments=include_comments)
283
+ # Comments are parsed as whitespace and attached afterwards (see comments.py).
284
+ parser = getOpenSCADParser(reduce_tree=False)
283
285
  ast = parse_ast(parser, code, source_map=source_map)
286
+ if ast is not None and include_comments:
287
+ ast = _attach_all_comments(ast, code, origin)
284
288
  return ast
285
289
 
286
290
 
@@ -293,7 +297,7 @@ _ast_cache: dict[tuple[str, bool, bool], tuple[list[ASTNode] | None, float]] = {
293
297
  # Key: tuple of (absolute file path (str), include_comments (bool), process_includes (bool),
294
298
  # strict_commas (bool))
295
299
  # Value: tuple of (AST nodes, modification timestamp)
296
- _resolved_cache: dict[tuple[str, bool, bool, bool], tuple[list[ASTNode] | None, float]] = {}
300
+ _resolved_cache: dict[tuple[str, bool, bool, bool], tuple[list[ASTNode] | None, dict[str, float]]] = {}
297
301
 
298
302
 
299
303
  def _get_disk_cache_dir() -> Optional[str]:
@@ -322,7 +326,8 @@ def _ast_format_tag() -> str:
322
326
  here = os.path.dirname(os.path.abspath(__file__))
323
327
  h = hashlib.sha256()
324
328
  for path in (os.path.join(here, "..", "grammar.py"), os.path.join(here, "nodes.py"),
325
- os.path.join(here, "builder.py"), os.path.join(here, "__init__.py")):
329
+ os.path.join(here, "builder.py"), os.path.join(here, "__init__.py"),
330
+ os.path.join(here, "comments.py")):
326
331
  with open(path, "rb") as f:
327
332
  h.update(f.read())
328
333
  return h.hexdigest()[:16]
@@ -493,8 +498,10 @@ def _parse_single_file(file_path: str, include_comments: bool = False) -> list[A
493
498
  source_map = SourceMap()
494
499
  source_map.add_origin(file_path, code)
495
500
 
496
- parser = getOpenSCADParser(reduce_tree=False, include_comments=include_comments)
501
+ parser = getOpenSCADParser(reduce_tree=False)
497
502
  ast = parse_ast(parser, code, source_map=source_map)
503
+ if ast is not None and include_comments:
504
+ ast = _attach_all_comments(ast, code, file_path)
498
505
 
499
506
  # Cache in memory and on disk
500
507
  _ast_cache[cache_key] = (ast, current_mtime)
@@ -503,14 +510,30 @@ def _parse_single_file(file_path: str, include_comments: bool = False) -> list[A
503
510
  return ast
504
511
 
505
512
 
513
+ def _deps_unchanged(deps: dict[str, float]) -> bool:
514
+ """Whether every file a resolution read still has the mtime it had."""
515
+ try:
516
+ return all(os.path.getmtime(path) == mtime for path, mtime in deps.items())
517
+ except OSError: # one was deleted
518
+ return False
519
+
520
+
506
521
  def _resolve_includes(ast_nodes: list[ASTNode] | None, current_file: str,
507
522
  include_comments: bool = False,
508
- visited: set | None = None) -> list[ASTNode] | None:
509
- """Resolve IncludeStatement nodes by parsing and inlining referenced files."""
523
+ open_files: tuple = (),
524
+ deps: dict | None = None) -> list[ASTNode] | None:
525
+ """Resolve IncludeStatement nodes by parsing and inlining referenced files.
526
+
527
+ A file is included every time it is named, as OpenSCAD includes it: an
528
+ `include` written twice runs the file twice. Only a file already open on
529
+ this include chain (`open_files`; the top-level file is not one) is
530
+ refused -- OpenSCAD warns that it can't find it and carries on -- which is
531
+ what stops a cycle. Every file read is recorded in `deps` with its mtime.
532
+ """
510
533
  if ast_nodes is None:
511
534
  return None
512
- if visited is None:
513
- visited = set()
535
+ if deps is None:
536
+ deps = {}
514
537
 
515
538
  result = []
516
539
  for node in ast_nodes:
@@ -520,11 +543,12 @@ def _resolve_includes(ast_nodes: list[ASTNode] | None, current_file: str,
520
543
  if lib_file is None:
521
544
  raise FileNotFoundError(_not_found("Included file", filename, current_file))
522
545
  lib_file = os.path.abspath(lib_file)
523
- if lib_file in visited:
546
+ if lib_file in open_files:
524
547
  continue
525
- visited.add(lib_file)
548
+ deps[lib_file] = os.path.getmtime(lib_file)
526
549
  included_ast = _parse_single_file(lib_file, include_comments)
527
- included_ast = _resolve_includes(included_ast, lib_file, include_comments, visited)
550
+ included_ast = _resolve_includes(included_ast, lib_file, include_comments,
551
+ open_files + (lib_file,), deps)
528
552
  if included_ast:
529
553
  result.extend(included_ast)
530
554
  else:
@@ -592,19 +616,20 @@ def getASTfromFile(file: str, include_comments: bool = False, process_includes:
592
616
  # Check resolved cache (in-memory only since resolved ASTs depend on multiple files)
593
617
  resolved_key = (file_path, include_comments, True, _STRICT_COMMAS.get())
594
618
  if resolved_key in _resolved_cache:
595
- cached_ast, cached_mtime = _resolved_cache[resolved_key]
596
- if cached_mtime == current_mtime:
619
+ cached_ast, cached_deps = _resolved_cache[resolved_key]
620
+ if _deps_unchanged(cached_deps):
597
621
  return cached_ast
598
622
 
599
623
  # Parse the file independently (uses per-file cache)
600
624
  ast = _parse_single_file(file_path, include_comments)
601
625
 
602
626
  # Resolve all include statements recursively
603
- visited = {file_path}
604
- ast = _resolve_includes(ast, file_path, include_comments, visited)
627
+ deps = {file_path: current_mtime}
628
+ ast = _resolve_includes(ast, file_path, include_comments, (), deps)
605
629
 
606
- # Cache the resolved result
607
- _resolved_cache[resolved_key] = (ast, current_mtime)
630
+ # Cache the resolved result, valid while every file it read is unchanged:
631
+ # keyed on the top file alone, an edited include went unnoticed.
632
+ _resolved_cache[resolved_key] = (ast, deps)
608
633
 
609
634
  return ast
610
635
 
@@ -1101,8 +1101,14 @@ class ASTBuilderVisitor(PTNodeVisitor):
1101
1101
  position=self._get_node_position(node)
1102
1102
  )
1103
1103
 
1104
+ def _header_assignments(self, children) -> list:
1105
+ """The `(...)` list's assignments only. Filtering `children` for
1106
+ Assignments also took every assignment in a braced body -- `for (i =
1107
+ [0:1]) { a = i; }` made `a` a loop variable."""
1108
+ return list(children.get_rule('assignments_expr') or [])
1109
+
1104
1110
  def visit_modular_for(self, node, children):
1105
- assignments = [c for c in children if isinstance(c, Assignment)]
1111
+ assignments = self._header_assignments(children)
1106
1112
  body = children.get_rule('child_statement')
1107
1113
  return ModularFor(
1108
1114
  assignments=assignments,
@@ -1111,12 +1117,12 @@ class ASTBuilderVisitor(PTNodeVisitor):
1111
1117
  )
1112
1118
 
1113
1119
  def visit_modular_intersection_for(self, node, children):
1114
- assignments = [c for c in children if isinstance(c, Assignment)]
1120
+ assignments = self._header_assignments(children)
1115
1121
  body = children.get_rule('child_statement')
1116
1122
  return ModularIntersectionFor(assignments=assignments, body=body, position=self._get_node_position(node))
1117
1123
 
1118
1124
  def visit_modular_let(self, node, children):
1119
- assignments = [c for c in children if isinstance(c, Assignment)]
1125
+ assignments = self._header_assignments(children)
1120
1126
  mods = children.get_rule('child_statement')
1121
1127
  return ModularLet(assignments=assignments, children=mods, position=self._get_node_position(node))
1122
1128
 
@@ -0,0 +1,592 @@
1
+ """Comments for include_comments=True, attached after the parse.
2
+
3
+ The source is parsed with comments as whitespace -- the grammar every file
4
+ already parses under -- and each comment is then placed by position: between
5
+ statements into the statement list, inside an expression as a CommentedExpr,
6
+ around a declaration's name and parameters into its comment fields.
7
+
8
+ This replaced a grammar that allowed a comment only at the points it listed,
9
+ so a comment anywhere else -- in a ternary, after a `(`, inside `each if` --
10
+ was a syntax error: 19 of BOSL2's 57 files failed to parse with comments
11
+ kept. Ported from openscad_lalr_parser, which keeps every one of BOSL2's
12
+ comments this way.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import bisect
17
+ import dataclasses
18
+ import re
19
+
20
+ from .builder import Position, _insert_blank_lines
21
+ from .nodes import (
22
+ ASTNode, BlankLine, CommentLine, CommentSpan, CommentedExpr, Expression, FunctionDeclaration,
23
+ Identifier, ModuleDeclaration, ModuleInstantiation, ParameterDeclaration, VectorElement,
24
+ )
25
+
26
+
27
+ # --- Comment extraction for include_comments mode ---
28
+
29
+ _COMMENT_RE = re.compile(
30
+ r'//([^\n]*)' # single-line comment
31
+ r'|/\*([\s\S]*?)\*/' # multi-line comment
32
+ r'|"(?:[^"\\]|\\[\s\S])*"' # string literal (skip); a backslash may escape a newline
33
+ )
34
+
35
+
36
+ def _line_of(newlines: list[int], offset: int) -> int:
37
+ """1-based line of `offset`, given the offsets of every newline."""
38
+ return bisect.bisect_left(newlines, offset) + 1
39
+
40
+
41
+ def _extract_comments(code: str, origin: str) -> list[ASTNode]:
42
+ """Extract all comments from source code as AST nodes."""
43
+ newlines = [m.start() for m in re.finditer("\n", code)]
44
+ comments = []
45
+ for m in _COMMENT_RE.finditer(code):
46
+ kind = CommentLine if m.group(1) is not None else CommentSpan if m.group(2) is not None else None
47
+ if kind is None:
48
+ continue # a string literal
49
+ start = m.start()
50
+ line = _line_of(newlines, start)
51
+ col = start - newlines[line - 2] if line > 1 else start + 1
52
+ pos = Position(origin=origin, line=line, column=col, start_offset=start, end_offset=m.end())
53
+ comments.append(kind(position=pos, text=m.group(1) if kind is CommentLine else m.group(2)))
54
+ return comments
55
+
56
+
57
+ def _is_inline_comment(comment: ASTNode, code: str) -> bool:
58
+ """Return True if the comment shares a source line with non-comment code."""
59
+ start = comment.position.start_offset
60
+ end = comment.position.end_offset
61
+ line_start = code.rfind('\n', 0, start)
62
+ line_start = 0 if line_start < 0 else line_start + 1
63
+ before = code[line_start:start].strip()
64
+ if before:
65
+ return True
66
+ if isinstance(comment, CommentSpan):
67
+ last_line_end = code.find('\n', end)
68
+ if last_line_end < 0:
69
+ last_line_end = len(code)
70
+ after = code[end:last_line_end].strip()
71
+ if after and not after.startswith('//') and not after.startswith('/*'):
72
+ return True
73
+ return False
74
+
75
+
76
+ def _classify_comments(comments: list[ASTNode], code: str) -> tuple[list[ASTNode], list[ASTNode]]:
77
+ """Split comments into (inline, standalone) lists."""
78
+ inline = []
79
+ standalone = []
80
+ for c in comments:
81
+ if _is_inline_comment(c, code):
82
+ inline.append(c)
83
+ else:
84
+ standalone.append(c)
85
+ return inline, standalone
86
+
87
+
88
+ _SKIP_FIELDS = frozenset(('position', 'scope', 'leading_comments', 'trailing_comments',
89
+ 'pre_name_comments', 'post_name_comments', 'post_params_comments'))
90
+
91
+
92
+ _STATEMENT_LIST_FIELDS = ("children", "body", "true_branch", "false_branch")
93
+ _STATEMENT_TYPES = (ModuleInstantiation, ModuleDeclaration, FunctionDeclaration)
94
+ # What an inline comment can wrap: expressions, and list-comprehension
95
+ # elements (`each`, `if`, `for` ...), which aren't Expressions -- with only
96
+ # those between them, a comment had nothing to attach to and was dropped.
97
+ _ATTACHABLE = (Expression, VectorElement)
98
+
99
+
100
+ def _blank_comments(code: str, comments: list) -> str:
101
+ """`code` with every comment replaced by spaces (newlines kept), so
102
+ looking back past comments for the previous real character is easy."""
103
+ chars = list(code)
104
+ for c in comments:
105
+ for i in range(c.position.start_offset, c.position.end_offset):
106
+ if chars[i] != "\n":
107
+ chars[i] = " "
108
+ return "".join(chars)
109
+
110
+
111
+ def _place_comment(stmts: list, comment, blanked: str, same_line: bool, top: bool = False) -> str:
112
+ """Put `comment` into the statement list where it falls between
113
+ statements, descending into nested blocks. Returns "placed", "head"
114
+ (inside a statement's non-block part -- arguments, a condition -- where
115
+ expression attachment takes it) or "top" (an own-line comment between
116
+ top-level statements, which _inject_comments places with its blank
117
+ lines)."""
118
+ cs = comment.position.start_offset
119
+ for node in stmts:
120
+ if isinstance(node, (CommentLine, CommentSpan, BlankLine)):
121
+ continue
122
+ pos = node.position
123
+ if pos.start_offset <= cs < pos.end_offset:
124
+ return _place_in_statement(node, comment, blanked, same_line)
125
+ if top and not same_line:
126
+ return "top"
127
+ index = sum(1 for n in stmts if n.position.start_offset < cs) # comments come in source order
128
+ if index == 0 and top:
129
+ return "head"
130
+ if isinstance(comment, CommentLine):
131
+ comment.same_line = same_line
132
+ stmts.insert(index, comment)
133
+ return "placed"
134
+
135
+
136
+ def _place_in_statement(node, comment, blanked: str, same_line: bool) -> str:
137
+ child = getattr(node, "child", None) # the #/%/!/* modifiers wrap one statement
138
+ if isinstance(child, ASTNode):
139
+ return _place_in_statement(child, comment, blanked, same_line)
140
+ # The block the comment is in: the last one starting before it (an if/else
141
+ # has two). Failing that, the next block if only its `{` comes between
142
+ # (`module m() { // why`, or an own-line comment atop the block).
143
+ cs = comment.position.start_offset
144
+ block = next_block = None
145
+ for name in _STATEMENT_LIST_FIELDS:
146
+ stmts = getattr(node, name, None)
147
+ if not isinstance(stmts, list):
148
+ continue
149
+ # By its first STATEMENT: a comment placed there already may come first.
150
+ first = next((n for n in stmts if isinstance(n, ASTNode)
151
+ and not isinstance(n, (CommentLine, CommentSpan, BlankLine))), None)
152
+ if first is None:
153
+ continue
154
+ if first.position.start_offset <= cs:
155
+ block = stmts
156
+ elif next_block is None:
157
+ next_block = stmts
158
+ inside_block = block is not None and any(
159
+ n.position.start_offset <= cs < n.position.end_offset
160
+ for n in block if not isinstance(n, (CommentLine, CommentSpan, BlankLine)))
161
+ if not inside_block and next_block is not None and blanked[:cs].rstrip().endswith("{"):
162
+ block = next_block # atop a block: an else's, even with the if's block before it
163
+ if block is None:
164
+ return "head"
165
+ return _place_comment(block, comment, blanked, same_line)
166
+
167
+
168
+ def _attach_inline_comments(ast_nodes: list[ASTNode], inline_comments: list[ASTNode]) -> list[ASTNode]:
169
+ """Returns the comments it found no place for, for the caller to keep as
170
+ top-level ones: dropping them lost the text."""
171
+ """Walk AST and wrap expressions adjacent to inline comments in CommentedExpr."""
172
+ if not inline_comments:
173
+ return []
174
+ inline_comments.sort(key=lambda c: c.position.start_offset)
175
+ used: set[int] = set()
176
+ for node in ast_nodes:
177
+ _walk_attach(node, inline_comments, used)
178
+
179
+ # Attach remaining unused inline comments as trailing on the nearest
180
+ # preceding top-level node's last expression field.
181
+ for ci, comment in enumerate(inline_comments):
182
+ if ci in used:
183
+ continue
184
+ cs = comment.position.start_offset
185
+ best_node = None
186
+ for node in ast_nodes:
187
+ if isinstance(node, (CommentLine, CommentSpan, BlankLine)):
188
+ continue
189
+ if node.position.end_offset <= cs:
190
+ best_node = node
191
+ if best_node is not None:
192
+ _attach_trailing_to_last_expr(best_node, comment)
193
+ used.add(ci)
194
+
195
+ return [c for ci, c in enumerate(inline_comments) if ci not in used]
196
+
197
+
198
+ def _attach_trailing_to_last_expr(node: ASTNode, comment: ASTNode):
199
+ """Attach a trailing comment to the last Expression field in node."""
200
+ last_expr_info = None
201
+ for f in dataclasses.fields(node):
202
+ if f.name in _SKIP_FIELDS:
203
+ continue
204
+ val = getattr(node, f.name)
205
+ if isinstance(val, Expression):
206
+ last_expr_info = (node, f.name, None, val)
207
+ elif isinstance(val, list):
208
+ for idx, item in enumerate(val):
209
+ if isinstance(item, Expression):
210
+ last_expr_info = (node, f.name, idx, item)
211
+ elif isinstance(item, ASTNode):
212
+ for cf in dataclasses.fields(item):
213
+ if cf.name in _SKIP_FIELDS:
214
+ continue
215
+ cval = getattr(item, cf.name)
216
+ if isinstance(cval, Expression):
217
+ last_expr_info = (item, cf.name, None, cval)
218
+ if last_expr_info is None:
219
+ return
220
+ owner, fname, lidx, expr = last_expr_info
221
+ if isinstance(expr, CommentedExpr):
222
+ expr.trailing_comments.append(comment)
223
+ else:
224
+ wrapped = CommentedExpr(
225
+ position=expr.position,
226
+ leading_comments=[],
227
+ trailing_comments=[comment],
228
+ expr=expr,
229
+ )
230
+ if lidx is None:
231
+ setattr(owner, fname, wrapped)
232
+ else:
233
+ getattr(owner, fname)[lidx] = wrapped
234
+
235
+
236
+ def _walk_attach(node: ASTNode, comments: list[ASTNode], used: set[int]):
237
+ """Recursively attach inline comments to Expression fields of node."""
238
+ if not isinstance(node, ASTNode) or isinstance(node, (CommentedExpr, CommentLine, CommentSpan)):
239
+ return
240
+
241
+ ns = node.position.start_offset
242
+ ne = node.position.end_offset
243
+
244
+ has_relevant = any(
245
+ i not in used and ns <= comments[i].position.start_offset < ne
246
+ for i in range(len(comments))
247
+ )
248
+ if not has_relevant:
249
+ for f in dataclasses.fields(node):
250
+ if f.name in _SKIP_FIELDS:
251
+ continue
252
+ val = getattr(node, f.name)
253
+ if isinstance(val, ASTNode):
254
+ _walk_attach(val, comments, used)
255
+ elif isinstance(val, list):
256
+ for item in val:
257
+ if isinstance(item, ASTNode):
258
+ _walk_attach(item, comments, used)
259
+ return
260
+
261
+ # Collect wrappable Expression fields, including from non-Expression
262
+ # containers like PositionalArgument, NamedArgument, ParameterDeclaration.
263
+ # Each entry: (owner_node, field_name, list_index_or_None, expr)
264
+ expr_fields = []
265
+ non_expr_children = []
266
+
267
+ for f in dataclasses.fields(node):
268
+ if f.name in _SKIP_FIELDS:
269
+ continue
270
+ if f.name == "step" and getattr(node, "implicit_step", False):
271
+ continue # synthesized for [a:b]; it spans the whole range and would swallow its comments
272
+ val = getattr(node, f.name)
273
+ if isinstance(val, _ATTACHABLE) and not isinstance(val, CommentedExpr):
274
+ expr_fields.append((node, f.name, None, val))
275
+ elif isinstance(val, ASTNode):
276
+ non_expr_children.append(val)
277
+ elif isinstance(val, list):
278
+ for idx, item in enumerate(val):
279
+ if isinstance(item, _ATTACHABLE) and not isinstance(item, CommentedExpr):
280
+ expr_fields.append((node, f.name, idx, item))
281
+ elif isinstance(item, _STATEMENT_TYPES) or (f.name in _STATEMENT_LIST_FIELDS and isinstance(item, ASTNode)):
282
+ # A child statement (an assignment in a block too): its
283
+ # own walk attaches its comments. Mined here, its NAME
284
+ # became one of this node's expressions, and a comment in
285
+ # this node's arguments landed on it (`translate([1, // c`
286
+ # ... `cube // c(1);`, or a last parameter's comment on
287
+ # the body's first assignment).
288
+ non_expr_children.append(item)
289
+ elif isinstance(item, ASTNode):
290
+ _collect_container_exprs(item, expr_fields, non_expr_children)
291
+
292
+ expr_fields.sort(key=lambda x: x[3].position.start_offset)
293
+
294
+ leading_map: dict[int, list] = {ei: [] for ei in range(len(expr_fields))}
295
+ trailing_map: dict[int, list] = {ei: [] for ei in range(len(expr_fields))}
296
+
297
+ child_spans = [(c.position.start_offset, c.position.end_offset) for c in non_expr_children]
298
+ for ci, comment in enumerate(comments):
299
+ if ci in used:
300
+ continue
301
+ cs = comment.position.start_offset
302
+ if cs < ns or cs >= ne:
303
+ continue
304
+ if any(s <= cs < e for s, e in child_spans):
305
+ continue # inside a child statement: its own walk places it
306
+
307
+ attached = False
308
+ for ei, (owner, fname, lidx, expr) in enumerate(expr_fields):
309
+ es = expr.position.start_offset
310
+ if cs < es:
311
+ leading_map[ei].append((ci, comment))
312
+ used.add(ci)
313
+ attached = True
314
+ break
315
+ elif cs >= expr.position.end_offset:
316
+ continue
317
+ # Inside this expression: the recursion below attaches it within.
318
+ # Falling through made it the NEXT expression's leading comment.
319
+ attached = True
320
+ break
321
+ if not attached and expr_fields:
322
+ last_ei = len(expr_fields) - 1
323
+ if cs >= expr_fields[last_ei][3].position.end_offset:
324
+ trailing_map[last_ei].append((ci, comment))
325
+ used.add(ci)
326
+
327
+ for ei, (owner, fname, lidx, expr) in enumerate(expr_fields):
328
+ leading = [c for _, c in leading_map[ei]]
329
+ trailing = [c for _, c in trailing_map[ei]]
330
+
331
+ if leading or trailing:
332
+ wrapped = CommentedExpr(
333
+ position=expr.position,
334
+ leading_comments=leading,
335
+ trailing_comments=trailing,
336
+ expr=expr,
337
+ )
338
+ if lidx is None:
339
+ setattr(owner, fname, wrapped)
340
+ else:
341
+ getattr(owner, fname)[lidx] = wrapped
342
+
343
+ _walk_attach(expr, comments, used)
344
+
345
+ for child in non_expr_children:
346
+ _walk_attach(child, comments, used)
347
+
348
+
349
+ def _collect_container_exprs(container: ASTNode, expr_fields: list, non_expr_children: list):
350
+ """Extract Expression fields from a non-Expression container node."""
351
+ found_expr = False
352
+ for f in dataclasses.fields(container):
353
+ if f.name in _SKIP_FIELDS:
354
+ continue
355
+ val = getattr(container, f.name)
356
+ if isinstance(val, Expression) and not isinstance(val, CommentedExpr):
357
+ expr_fields.append((container, f.name, None, val))
358
+ found_expr = True
359
+ elif isinstance(val, list):
360
+ for idx, item in enumerate(val):
361
+ if isinstance(item, Expression) and not isinstance(item, CommentedExpr):
362
+ expr_fields.append((container, f.name, idx, item))
363
+ found_expr = True
364
+ if not found_expr:
365
+ non_expr_children.append(container)
366
+
367
+
368
+ def _find_char_skipping_comments(code: str, frm: int, ch: str, comments: list[ASTNode]) -> int:
369
+ """Scan raw source from `frm` for the next occurrence of `ch`, skipping
370
+ over any already-extracted comment span encountered along the way (so a
371
+ stray '(' or ')' inside a comment's own text is never mistaken for the
372
+ real token). Returns -1 if not found.
373
+
374
+ Needed because the grammar captures no location for the parameter
375
+ list's own '(' / ')' tokens -- only NAME and the whole declaration's
376
+ span are available (see transformer.py's
377
+ function_definition/module_definition/parameter_with_default).
378
+ """
379
+ i = frm
380
+ n = len(code)
381
+ while i < n:
382
+ skipped = False
383
+ for c in comments:
384
+ if c is not None and c.position.start_offset == i:
385
+ i = c.position.end_offset
386
+ skipped = True
387
+ break
388
+ if skipped:
389
+ continue
390
+ if code[i] == ch:
391
+ return i
392
+ i += 1
393
+ return -1
394
+
395
+
396
+ def _claim_comment_spans_in_range(lo: int, hi: int, out: list, comments: list) -> None:
397
+ """Moves every CommentSpan in `comments` whose start offset falls in
398
+ [lo, hi) into `out`, nulling its slot in `comments` in place so a later
399
+ pass (the inline/standalone split) never sees it again."""
400
+ if lo >= hi:
401
+ return
402
+ for idx, c in enumerate(comments):
403
+ if c is None or not isinstance(c, CommentSpan):
404
+ continue
405
+ cs = c.position.start_offset
406
+ if lo <= cs < hi:
407
+ out.append(c)
408
+ comments[idx] = None
409
+
410
+
411
+ def _claim_decl_signature_comments(decl: ASTNode, name: Identifier, parameters: list[ParameterDeclaration],
412
+ body_start: int, pre_name: list, post_name: list, post_params: list,
413
+ code: str, comments: list) -> None:
414
+ """Claims '/* */' comments (CommentSpan only -- these fields' own
415
+ declared type) in every structural gap a FunctionDeclaration/
416
+ ModuleDeclaration doesn't expose as any Expression field: before the
417
+ name, between the name and the parameter list, between adjacent
418
+ parameters (including a parameter with no default value, which
419
+ _walk_attach's own generic Expression-field scan never visits at all,
420
+ since it only descends into fields whose value IS an Expression),
421
+ between the last parameter and ')', and between ')' and the body.
422
+
423
+ ponytail: when there are zero parameters, the "between name and '('"
424
+ and "between '(' and ')'" gaps are both real but there's no parameter
425
+ to own the latter -- folded into post_params_comments rather than
426
+ adding a dedicated field neither language's AST declares.
427
+ """
428
+ name_start = name.position.start_offset
429
+ name_end = name.position.end_offset
430
+ _claim_comment_spans_in_range(decl.position.start_offset, name_start, pre_name, comments)
431
+
432
+ open_paren = _find_char_skipping_comments(code, name_end, '(', comments)
433
+ paren_open_pos = open_paren if open_paren >= 0 else name_end
434
+ _claim_comment_spans_in_range(name_end, paren_open_pos, post_name, comments)
435
+ cursor = open_paren + 1 if open_paren >= 0 else name_end
436
+
437
+ for p in parameters:
438
+ _claim_comment_spans_in_range(cursor, p.position.start_offset, p.leading_comments, comments)
439
+ cursor = p.position.end_offset
440
+
441
+ close_paren = _find_char_skipping_comments(code, cursor, ')', comments)
442
+ paren_close_pos = close_paren if close_paren >= 0 else cursor
443
+ if parameters:
444
+ _claim_comment_spans_in_range(cursor, paren_close_pos, parameters[-1].trailing_comments, comments)
445
+ else:
446
+ _claim_comment_spans_in_range(cursor, paren_close_pos, post_params, comments)
447
+ cursor = close_paren + 1 if close_paren >= 0 else cursor
448
+ _claim_comment_spans_in_range(cursor, body_start, post_params, comments)
449
+
450
+
451
+ def _walk_attach_decl_comments(node: ASTNode, code: str, comments: list) -> None:
452
+ """Recurses through `node` looking for FunctionDeclaration/
453
+ ModuleDeclaration nodes (which can nest inside a module's own
454
+ children), claiming their signature-gap comments. A generic
455
+ reflection-based walk over dataclass fields (mirrors _walk_attach's own
456
+ field introspection), since -- unlike the C++ port, which needs an
457
+ explicit per-NodeKind switch -- Python's dataclasses.fields() already
458
+ gives a free generic tree walk.
459
+ """
460
+ if not isinstance(node, ASTNode) or isinstance(node, (CommentedExpr, CommentLine, CommentSpan)):
461
+ return
462
+
463
+ if isinstance(node, FunctionDeclaration):
464
+ _claim_decl_signature_comments(node, node.name, node.parameters, node.expr.position.start_offset,
465
+ node.pre_name_comments, node.post_name_comments, node.post_params_comments,
466
+ code, comments)
467
+ elif isinstance(node, ModuleDeclaration):
468
+ body_start = node.children[0].position.start_offset if node.children else node.position.end_offset
469
+ _claim_decl_signature_comments(node, node.name, node.parameters, body_start, node.pre_name_comments,
470
+ node.post_name_comments, node.post_params_comments, code, comments)
471
+
472
+ for f in dataclasses.fields(node):
473
+ if f.name in _SKIP_FIELDS:
474
+ continue
475
+ val = getattr(node, f.name)
476
+ if isinstance(val, ASTNode):
477
+ _walk_attach_decl_comments(val, code, comments)
478
+ elif isinstance(val, list):
479
+ for item in val:
480
+ if isinstance(item, ASTNode):
481
+ _walk_attach_decl_comments(item, code, comments)
482
+
483
+
484
+ def _attach_declaration_comments(ast_nodes: list[ASTNode], code: str, comments: list) -> None:
485
+ """Claims declaration-signature comments across the whole AST. Must run
486
+ BEFORE _classify_comments/_attach_inline_comments -- a '/* */' comment
487
+ alone on its own line before a parameter is classified *standalone* by
488
+ _is_inline_comment, which this pass still needs to claim, so it operates
489
+ on the full extracted comment list, not just the inline subset.
490
+ """
491
+ for node in ast_nodes:
492
+ _walk_attach_decl_comments(node, code, comments)
493
+
494
+
495
+ def _attach_all_comments(ast: list[ASTNode], code: str, origin: str) -> list[ASTNode]:
496
+ """Shared by getASTfromString/_parse_single_file: extracts comments,
497
+ claims declaration-signature comments first (see
498
+ _attach_declaration_comments), then runs the existing inline/standalone
499
+ pipeline over whatever's left.
500
+ """
501
+ comments = _extract_comments(code, origin)
502
+ blanked = _blank_comments(code, comments)
503
+ _attach_declaration_comments(ast, code, comments)
504
+ comments = [c for c in comments if c is not None]
505
+ inline, standalone = _classify_comments(comments, code)
506
+ # Comments that sit between statements belong in the statement list, at
507
+ # any depth: a `//` ending a statement's line after that statement (it
508
+ # was wrapped round the statement's last expression and printed before
509
+ # the `;`, or after a child module's NAME), an own-line one where it is
510
+ # (it was moved out to top level, after the whole statement). Inside a
511
+ # statement's arguments or condition, expression attachment takes it.
512
+ expr_comments, top_level = [], []
513
+ for c in inline:
514
+ if not (isinstance(c, CommentLine) and _place_comment(ast, c, blanked, True, top=True) == "placed"):
515
+ expr_comments.append(c)
516
+ for c in standalone:
517
+ where = _place_comment(ast, c, blanked, False, top=True)
518
+ if where == "top":
519
+ top_level.append(c)
520
+ elif where == "head":
521
+ expr_comments.append(c)
522
+ top_level += _attach_inline_comments(ast, expr_comments)
523
+ top_level.sort(key=lambda c: c.position.start_offset)
524
+ for node in ast:
525
+ _blank_lines_in_blocks(node)
526
+ return _inject_comments(ast, top_level, code, origin)
527
+
528
+
529
+ def _blank_lines_in_blocks(node) -> None:
530
+ """A blank line between two `//` comments in a block, as the grammar-based
531
+ comment parse kept it (_insert_blank_lines); top-level ones are
532
+ _inject_comments' job."""
533
+ if not isinstance(node, ASTNode) or isinstance(node, (CommentedExpr, CommentLine, CommentSpan, BlankLine)):
534
+ return
535
+ for f in dataclasses.fields(node):
536
+ if f.name in _SKIP_FIELDS:
537
+ continue
538
+ val = getattr(node, f.name)
539
+ if isinstance(val, list):
540
+ if f.name in _STATEMENT_LIST_FIELDS:
541
+ val = _insert_blank_lines(val)
542
+ setattr(node, f.name, val)
543
+ for item in val:
544
+ _blank_lines_in_blocks(item)
545
+ elif isinstance(val, ASTNode):
546
+ _blank_lines_in_blocks(val)
547
+
548
+
549
+ def _inject_comments(ast_nodes: list[ASTNode], comments: list[ASTNode], code: str, origin: str) -> list[ASTNode]:
550
+ """Merge standalone comment nodes into top-level AST node list."""
551
+ if not comments:
552
+ return ast_nodes
553
+
554
+ result = []
555
+ comment_idx = 0
556
+ prev_end_line = 0
557
+ lines = code.split('\n')
558
+ newlines = [m.start() for m in re.finditer("\n", code)]
559
+
560
+ for node in ast_nodes:
561
+ node_line = node.position.line if hasattr(node, 'position') and node.position else float('inf')
562
+
563
+ while comment_idx < len(comments) and comments[comment_idx].position.line < node_line:
564
+ comment = comments[comment_idx]
565
+ cl = comment.position.line
566
+ if prev_end_line > 0 and cl - prev_end_line > 1:
567
+ # One BlankLine for the gap, however many blank lines it held.
568
+ for gap_line in range(prev_end_line + 1, cl):
569
+ line_content = lines[gap_line - 1] if gap_line <= len(lines) else ''
570
+ if line_content.strip() == '':
571
+ result.append(BlankLine(position=Position(
572
+ origin=origin, line=gap_line, column=1)))
573
+ break
574
+ result.append(comment)
575
+ if isinstance(comment, CommentLine):
576
+ prev_end_line = comment.position.line
577
+ else:
578
+ prev_end_line = comment.position.line + comment.text.count('\n')
579
+ comment_idx += 1
580
+
581
+ result.append(node)
582
+ node_end = node.position.line
583
+ if hasattr(node, 'position') and node.position and node.position.end_offset > 0:
584
+ node_end = _line_of(newlines, node.position.end_offset)
585
+ prev_end_line = max(prev_end_line, node_end)
586
+
587
+ while comment_idx < len(comments):
588
+ comment = comments[comment_idx]
589
+ result.append(comment)
590
+ comment_idx += 1
591
+
592
+ return result
@@ -52,8 +52,12 @@ class CommentLine(ASTNode):
52
52
 
53
53
  Attributes:
54
54
  text: The comment text without the leading // marker.
55
+ same_line: True for a comment that ended a statement's line in the
56
+ source (`x = 1; // why`). It sits in the statement list right
57
+ after that statement, and is printed at the end of its line.
55
58
  """
56
59
  text: str
60
+ same_line: bool = field(default=False, kw_only=True)
57
61
 
58
62
  def __str__(self):
59
63
  return f"//{self.text}"
@@ -128,10 +132,12 @@ class CommentedExpr(Expression):
128
132
  expr: Expression
129
133
 
130
134
  def __str__(self):
131
- parts = [str(c) for c in self.leading_comments]
132
- parts.append(str(self.expr))
133
- parts.extend(str(c) for c in self.trailing_comments)
134
- return " ".join(parts)
135
+ # A `//` comment runs to the end of the line, so a line break always
136
+ # follows one: printed inline, it commented out whatever came next.
137
+ out = "".join(f"{c}\n" if isinstance(c, CommentLine) else f"{c} " for c in self.leading_comments)
138
+ out += str(self.expr)
139
+ out += "".join(f" {c}\n" if isinstance(c, CommentLine) else f" {c}" for c in self.trailing_comments)
140
+ return out
135
141
 
136
142
  def build_scope(self, parent_scope: "Scope") -> None:
137
143
  _set_scope(self, parent_scope)
@@ -313,8 +313,12 @@ def _fmt_ternary_chain(expr: TernaryOp, indent: int, w: int) -> str:
313
313
  def _fmt_trailing_comments(comments: list, pad: str) -> str:
314
314
  """Comments after an expression. After a `//` one the line must end, or
315
315
  it would comment out the `,` `)` or `;` its caller adds next."""
316
- out = "".join(f" {c}" for c in comments)
317
- return out + f"\n{pad}" if comments and isinstance(comments[-1], CommentLine) else out
316
+ out = ""
317
+ for c in comments:
318
+ out += ("" if out.endswith(f"\n{pad}") else " ") + str(c)
319
+ if isinstance(c, CommentLine):
320
+ out += f"\n{pad}" # every one: two on a line would be one comment
321
+ return out
318
322
 
319
323
 
320
324
  def _fmt_expr(expr, indent: int, w: int) -> str:
@@ -5,9 +5,10 @@ editor buffers, etc.) into a single string for parsing while maintaining the
5
5
  ability to map positions back to their original origin, line, and column locations.
6
6
  """
7
7
 
8
+ import bisect
8
9
  import os
9
10
  import re
10
- from dataclasses import dataclass
11
+ from dataclasses import dataclass, field
11
12
  from typing import Optional, Tuple, TYPE_CHECKING
12
13
 
13
14
  if TYPE_CHECKING:
@@ -30,6 +31,15 @@ class SourceSegment:
30
31
  start_column: int
31
32
  content: str
32
33
  combined_start: int # Position in combined string where this segment starts
34
+ # Offsets of every newline in `content`, built on first use: a position's
35
+ # line is then a binary search, where counting the newlines before it
36
+ # made building a file's AST quadratic in its length.
37
+ _newlines: Optional[list[int]] = field(default=None, repr=False, compare=False)
38
+
39
+ def newlines(self) -> list[int]:
40
+ if self._newlines is None:
41
+ self._newlines = [m.start() for m in re.finditer("\n", self.content)]
42
+ return self._newlines
33
43
 
34
44
 
35
45
  class SourceMap:
@@ -352,9 +362,9 @@ class SourceMap:
352
362
  if offset > len(segment.content):
353
363
  offset = len(segment.content) # pragma: no cover
354
364
 
355
- # Count lines in the content up to the offset
356
- content_before = segment.content[:offset]
357
- line_count = content_before.count('\n')
365
+ # Newlines before the offset, and the last of them
366
+ newlines = segment.newlines()
367
+ line_count = bisect.bisect_left(newlines, offset)
358
368
 
359
369
  # Calculate line number
360
370
  line_number = segment.start_line + line_count
@@ -364,9 +374,7 @@ class SourceMap:
364
374
  # Same line as start
365
375
  column_number = segment.start_column + offset
366
376
  else:
367
- # Find the last newline before offset
368
- last_newline = content_before.rfind('\n')
369
- column_number = offset - last_newline
377
+ column_number = offset - newlines[line_count - 1]
370
378
 
371
379
  resolved_end = end_offset if end_offset is not None else offset
372
380
 
@@ -39,9 +39,13 @@ def strict_commas(enabled: bool = True):
39
39
  _STRICT_COMMAS.reset(token)
40
40
 
41
41
 
42
- def _trailing_comma():
43
- """What may follow the last item of an argument or assignment list."""
44
- return () if _STRICT_COMMAS.get() else (Optional(TOK_COMMA),)
42
+ def _comma_list(item, trailing: bool = True):
43
+ """`item`s separated by commas, possibly none, with one trailing comma
44
+ after at least one of them when `trailing` -- as OpenSCAD reads every
45
+ list: `f()`, `f(1)` and `f(1,)` parse, `f(,)` and `f(1,,)` do not."""
46
+ if not trailing:
47
+ return ZeroOrMore(item, sep=TOK_COMMA)
48
+ return Optional(OneOrMore(item, sep=TOK_COMMA), Optional(TOK_COMMA))
45
49
 
46
50
 
47
51
  # --- OpenSCAD language parsing root ---
@@ -486,7 +490,7 @@ def parameter_block():
486
490
 
487
491
 
488
492
  def parameters():
489
- return (ZeroOrMore(parameter, sep=TOK_COMMA), ZeroOrMore(TOK_COMMA))
493
+ return (_comma_list(parameter),)
490
494
 
491
495
 
492
496
  def parameter():
@@ -511,7 +515,7 @@ def argument_block():
511
515
 
512
516
 
513
517
  def arguments():
514
- return (ZeroOrMore(argument, sep=TOK_COMMA), *_trailing_comma())
518
+ return (_comma_list(argument, not _STRICT_COMMAS.get()),)
515
519
 
516
520
 
517
521
  def argument():
@@ -532,7 +536,7 @@ def named_argument():
532
536
  # --- Expressions ---
533
537
 
534
538
  def assignments_expr():
535
- return (ZeroOrMore(assignment_expr, sep=TOK_COMMA), *_trailing_comma())
539
+ return (_comma_list(assignment_expr, not _STRICT_COMMAS.get()),)
536
540
 
537
541
 
538
542
  def assignment_expr():
@@ -700,13 +704,13 @@ def range_expr():
700
704
 
701
705
 
702
706
  def vector_expr():
703
- return (TOK_BRACKET, vector_elements, Optional(TOK_COMMA), TOK_ENDBRACKET)
707
+ return (TOK_BRACKET, vector_elements, TOK_ENDBRACKET)
704
708
 
705
709
 
706
710
  # --- Vector and list comprehension ---
707
711
 
708
712
  def vector_elements():
709
- return ZeroOrMore(vector_element, sep=TOK_COMMA)
713
+ return (_comma_list(vector_element),)
710
714
 
711
715
 
712
716
  def vector_element():