openscad-parser 2.3.0__tar.gz → 2.3.2__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: 2.3.0
3
+ Version: 2.3.2
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
@@ -22,6 +22,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
22
  Requires-Dist: arpeggio>=2.0.3
23
23
  Requires-Dist: pytest>=7.0.0 ; extra == 'dev'
24
24
  Requires-Dist: pytest-cov>=7.1.0 ; extra == 'dev'
25
+ Requires-Dist: pyyaml>=6.0 ; extra == 'dev'
25
26
  Requires-Dist: pyyaml>=6.0 ; extra == 'yaml'
26
27
  Maintainer: Revar Desmera
27
28
  Maintainer-email: Revar Desmera <revarbat@gmail.com>
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "openscad_parser"
7
- version = "2.3.0"
7
+ version = "2.3.2"
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 = [
@@ -39,6 +39,7 @@ dependencies = [
39
39
  dev = [
40
40
  "pytest>=7.0.0",
41
41
  "pytest-cov>=7.1.0",
42
+ "PyYAML>=6.0",
42
43
  ]
43
44
  yaml = [
44
45
  "PyYAML>=6.0",
@@ -9,18 +9,22 @@ from .nodes import *
9
9
  @dataclass
10
10
  class Position:
11
11
  """Represents a location in a source origin.
12
-
12
+
13
13
  Attributes:
14
14
  origin: Identifier for the source origin (e.g., file path, "<editor>", etc.)
15
15
  line: Line number (1-indexed)
16
16
  column: Column number (1-indexed)
17
+ start_offset: 0-based character offset of the token start within the origin's content
18
+ end_offset: 0-based character offset one past the token end within the origin's content
17
19
  """
18
20
  origin: str
19
21
  line: int
20
22
  column: int
23
+ start_offset: int = 0
24
+ end_offset: int = 0
21
25
 
22
26
  def __repr__(self):
23
- return f"{self.origin}:{self.line}:{self.column}"
27
+ return f"{self.origin}:{self.line}:{self.column}[{self.start_offset}:{self.end_offset}]"
24
28
 
25
29
 
26
30
  class SemanticChildren(list):
@@ -33,6 +37,11 @@ class SemanticChildren(list):
33
37
  def __getattr__(self, name):
34
38
  return self._rule_map.get(name, [])
35
39
 
40
+ def get_rule(self, rule_name, index=0):
41
+ """Return the index-th result for rule_name, or [] if absent/out of range."""
42
+ results = self._rule_map.get(rule_name, [])
43
+ return results[index] if index < len(results) else []
44
+
36
45
 
37
46
  class ASTBuilderVisitor(PTNodeVisitor):
38
47
  """
@@ -136,26 +145,43 @@ class ASTBuilderVisitor(PTNodeVisitor):
136
145
  # This is useful for operator nodes like TOK_BINARY_OR, TOK_EQUAL, etc.
137
146
  return node
138
147
 
148
+ def _get_node_end_position(self, node) -> int:
149
+ """Return the combined-string offset one past the last character of node."""
150
+ if node is None:
151
+ return 0
152
+ try:
153
+ # NonTerminal: recurse to find the furthest end among children
154
+ end = getattr(node, 'position', 0)
155
+ for child in node:
156
+ child_end = self._get_node_end_position(child)
157
+ if child_end > end:
158
+ end = child_end
159
+ return end
160
+ except TypeError:
161
+ pass
162
+ # Terminal: start + length of matched value
163
+ pos = getattr(node, 'position', 0)
164
+ val = getattr(node, 'value', '')
165
+ return pos + len(str(val))
166
+
139
167
  def _get_node_position(self, node):
140
168
  """Extract position information from an Arpeggio parse tree node.
141
-
169
+
142
170
  Uses SourceMap to map positions back to original origins if available.
143
-
171
+ start_offset and end_offset are 0-based offsets within the origin's content.
172
+
144
173
  Args:
145
174
  node: Arpeggio parse tree node
146
-
175
+
147
176
  Returns:
148
177
  Position object for the node
149
178
  """
150
- if hasattr(node, 'position'):
151
- char_pos = node.position
152
- else:
153
- char_pos = 0
154
-
179
+ char_pos = getattr(node, 'position', 0)
180
+ end_pos = self._get_node_end_position(node)
181
+
155
182
  # Use SourceMap if available to map position back to original origin
156
183
  if hasattr(self, 'source_map') and self.source_map.get_segments():
157
- location = self.source_map.get_location(char_pos)
158
- return Position(origin=location.origin, line=location.line, column=location.column)
184
+ return self.source_map.get_location(char_pos, end_pos)
159
185
  else:
160
186
  # Fallback: calculate line/column from character position
161
187
  input_str = self.parser.input if hasattr(self.parser, 'input') else ""
@@ -167,8 +193,14 @@ class ASTBuilderVisitor(PTNodeVisitor):
167
193
  line = text_before.count('\n') + 1
168
194
  last_newline = text_before.rfind('\n')
169
195
  column = char_pos - last_newline # 1-indexed
170
-
171
- return Position(origin=self.file if self.file else "<unknown>", line=line, column=column)
196
+
197
+ return Position(
198
+ origin=self.file if self.file else "<unknown>",
199
+ line=line,
200
+ column=column,
201
+ start_offset=char_pos,
202
+ end_offset=end_pos,
203
+ )
172
204
 
173
205
 
174
206
  # -- Basic Tokens --
@@ -455,7 +487,7 @@ class ASTBuilderVisitor(PTNodeVisitor):
455
487
  # After visiting: [Identifier, parameters, statement] (KWD_MODULE returns None)
456
488
  if hasattr(children, "module_name"):
457
489
  name = children.module_name[0] if children.module_name else None
458
- parameters = children.parameter_block[0] if hasattr(children, "parameter_block") and children.parameter_block else []
490
+ parameters = children.get_rule("parameter_block")
459
491
  statement = list(children.statement) if hasattr(children, "statement") and children.statement else []
460
492
  else:
461
493
  name = children[0] if children else None
@@ -491,7 +523,7 @@ class ASTBuilderVisitor(PTNodeVisitor):
491
523
  # After visiting: [Identifier, parameters, expr] (KWD_FUNCTION, TOK_ASSIGN, TOK_SEMICOLON return None)
492
524
  if hasattr(children, "function_name"):
493
525
  name = children.function_name[0] if children.function_name else None
494
- parameters = children.parameter_block[0] if hasattr(children, "parameter_block") and children.parameter_block else []
526
+ parameters = children.get_rule("parameter_block")
495
527
  expr = children.expr[0] if hasattr(children, "expr") and children.expr else None
496
528
  else:
497
529
  name = children[0] if children else None
@@ -551,7 +583,7 @@ class ASTBuilderVisitor(PTNodeVisitor):
551
583
  # Grammar: (KWD_LET, TOK_PAREN, assignments_expr, TOK_ENDPAREN, expr)
552
584
  # After visiting: [assignments_list, expr] (KWD_LET, TOK_PAREN, TOK_ENDPAREN return None)
553
585
  if hasattr(children, "assignments_expr"):
554
- assignments = children.assignments_expr[0] if children.assignments_expr else []
586
+ assignments = children.get_rule("assignments_expr")
555
587
  body = children.expr[0] if hasattr(children, "expr") and children.expr else None
556
588
  else:
557
589
  assignments = children[0] if len(children) > 0 else []
@@ -559,12 +591,12 @@ class ASTBuilderVisitor(PTNodeVisitor):
559
591
  if body is None:
560
592
  raise ValueError("let_expr should have an Expression body")
561
593
  return LetOp(assignments=assignments, body=body, position=self._get_node_position(node))
562
-
594
+
563
595
  def visit_assert_expr(self, node, children):
564
596
  # Grammar: (KWD_ASSERT, TOK_PAREN, arguments, TOK_ENDPAREN, Optional(expr))
565
597
  # After visiting: [arguments_list, expr or None] (KWD_ASSERT, TOK_PAREN, TOK_ENDPAREN return None)
566
598
  if hasattr(children, "arguments"):
567
- arguments = children.arguments[0] if children.arguments else []
599
+ arguments = children.get_rule("arguments")
568
600
  body = children.expr[0] if hasattr(children, "expr") and children.expr else None
569
601
  else:
570
602
  arguments = children[0] if len(children) > 0 else []
@@ -572,12 +604,12 @@ class ASTBuilderVisitor(PTNodeVisitor):
572
604
  if body is None:
573
605
  raise ValueError("assert_expr should have an Expression body")
574
606
  return AssertOp(arguments=arguments, body=body, position=self._get_node_position(node))
575
-
607
+
576
608
  def visit_echo_expr(self, node, children):
577
609
  # Grammar: (KWD_ECHO, TOK_PAREN, arguments, TOK_ENDPAREN, Optional(expr))
578
610
  # After visiting: [arguments_list, expr or None] (KWD_ECHO, TOK_PAREN, TOK_ENDPAREN return None)
579
611
  if hasattr(children, "arguments"):
580
- arguments = children.arguments[0] if children.arguments else []
612
+ arguments = children.get_rule("arguments")
581
613
  body = children.expr[0] if hasattr(children, "expr") and children.expr else None
582
614
  else:
583
615
  arguments = children[0] if len(children) > 0 else []
@@ -940,9 +972,9 @@ class ASTBuilderVisitor(PTNodeVisitor):
940
972
  for op in reversed(ops):
941
973
  if op == '-':
942
974
  result = UnaryMinusOp(expr=result, position=self._get_node_position(node))
943
- elif op == '!':
975
+ elif op == '!': # pragma: no cover
944
976
  result = LogicalNotOp(expr=result, position=self._get_node_position(node))
945
- elif op == '~':
977
+ elif op == '~': # pragma: no cover
946
978
  result = BitwiseNotOp(expr=result, position=self._get_node_position(node))
947
979
 
948
980
  return result
@@ -1015,11 +1047,7 @@ class ASTBuilderVisitor(PTNodeVisitor):
1015
1047
  return RangeLiteral(start=start, end=end, step=step, position=self._get_node_position(node))
1016
1048
 
1017
1049
  def visit_vector_expr(self, node, children):
1018
- elements = children[0] if children else []
1019
- if elements is None: # pragma: no cover
1020
- elements = []
1021
- if not isinstance(elements, list):
1022
- elements = [elements]
1050
+ elements = children if children else []
1023
1051
  return ListComprehension(elements=elements, position=self._get_node_position(node))
1024
1052
 
1025
1053
  def visit_funclit_def(self, node, children):
@@ -1035,7 +1063,7 @@ class ASTBuilderVisitor(PTNodeVisitor):
1035
1063
  return children[0]
1036
1064
 
1037
1065
  def visit_listcomp_paren_expr(self, node, children):
1038
- return children[0] if children else None
1066
+ return children[0]
1039
1067
 
1040
1068
  def visit_listcomp_let(self, node, children):
1041
1069
  return ListCompLet(assignments=children[0], body=children[1], position=self._get_node_position(node))
@@ -1080,22 +1108,14 @@ class ASTBuilderVisitor(PTNodeVisitor):
1080
1108
  raise ValueError("modular_call should have a module name")
1081
1109
  if not isinstance(name, Identifier):
1082
1110
  name = Identifier(name=str(name), position=self._get_node_position(node))
1083
- if hasattr(children, "arguments") and children.arguments:
1084
- arguments = children.arguments[0]
1085
- else:
1086
- arguments = children[1] if len(children) > 1 else []
1111
+ arguments = children.get_rule("arguments") if hasattr(children, "get_rule") else (children[1] if len(children) > 1 else [])
1087
1112
  if arguments is None: # pragma: no cover
1088
1113
  arguments = []
1089
1114
  if not isinstance(arguments, list): # pragma: no cover
1090
1115
  arguments = [arguments]
1091
- if hasattr(children, "child_statement") and children.child_statement:
1092
- mods = children.child_statement[0]
1093
- else:
1094
- mods = children[2] if len(children) > 2 else []
1116
+ mods = children.get_rule("child_statement") if hasattr(children, "get_rule") else (children[2] if len(children) > 2 else [])
1095
1117
  if mods is None: # pragma: no cover
1096
1118
  mods = []
1097
- if not isinstance(mods, list):
1098
- mods = [mods]
1099
1119
  return ModularCall(
1100
1120
  name=name,
1101
1121
  arguments=arguments,
@@ -1106,57 +1126,66 @@ class ASTBuilderVisitor(PTNodeVisitor):
1106
1126
  def visit_modular_c_for(self, node, children):
1107
1127
  initial = children[0] if isinstance(children[0], list) else [children[0]]
1108
1128
  increment = children[2] if isinstance(children[2], list) else [children[2]]
1129
+ body = children.get_rule('child_statement')
1109
1130
  return ModularCFor(
1110
1131
  initial=initial,
1111
1132
  condition=children[1],
1112
1133
  increment=increment,
1113
- body=children[3],
1134
+ body=body,
1114
1135
  position=self._get_node_position(node)
1115
1136
  )
1116
1137
 
1117
1138
  def visit_modular_for(self, node, children):
1118
1139
  assignments = children[0] if isinstance(children[0], list) else [children[0]]
1140
+ body = children.get_rule('child_statement')
1119
1141
  return ModularFor(
1120
1142
  assignments=assignments,
1121
- body=children[1],
1143
+ body=body,
1122
1144
  position=self._get_node_position(node)
1123
1145
  )
1124
1146
 
1125
1147
  def visit_modular_intersection_c_for(self, node, children):
1126
1148
  initial = children[0] if isinstance(children[0], list) else [children[0]]
1127
1149
  increment = children[2] if isinstance(children[2], list) else [children[2]]
1150
+ body = children.get_rule('child_statement')
1128
1151
  return ModularIntersectionCFor(
1129
1152
  initial=initial,
1130
1153
  condition=children[1],
1131
1154
  increment=increment,
1132
- body=children[3],
1155
+ body=body,
1133
1156
  position=self._get_node_position(node)
1134
1157
  )
1135
1158
 
1136
1159
  def visit_modular_intersection_for(self, node, children):
1137
1160
  assignments = children[0] if isinstance(children[0], list) else [children[0]]
1138
- return ModularIntersectionFor(assignments=assignments, body=children[1], position=self._get_node_position(node))
1161
+ body = children.get_rule('child_statement')
1162
+ return ModularIntersectionFor(assignments=assignments, body=body, position=self._get_node_position(node))
1139
1163
 
1140
1164
  def visit_modular_let(self, node, children):
1141
1165
  assignments = children[0] if isinstance(children[0], list) else [children[0]]
1142
- mods = children[1] if isinstance(children[1], list) else [children[1]]
1166
+ mods = children.get_rule('child_statement')
1143
1167
  return ModularLet(assignments=assignments, children=mods, position=self._get_node_position(node))
1144
1168
 
1145
1169
  def visit_modular_echo(self, node, children):
1146
1170
  arguments = children[0] if isinstance(children[0], list) else [children[0]]
1147
- mods = children[1] if isinstance(children[1], list) else [children[1]]
1171
+ mods = children.get_rule('child_statement')
1148
1172
  return ModularEcho(arguments=arguments, children=mods, position=self._get_node_position(node))
1149
1173
 
1150
1174
  def visit_modular_assert(self, node, children):
1151
1175
  arguments = children[0] if isinstance(children[0], list) else [children[0]]
1152
- mods = children[1] if isinstance(children[1], list) else [children[1]]
1176
+ mods = children.get_rule('child_statement')
1153
1177
  return ModularAssert(arguments=arguments, children=mods, position=self._get_node_position(node))
1154
1178
 
1155
1179
  def visit_if_statement(self, node, children):
1156
- return ModularIf(condition=children[0], true_branch=children[1], position=self._get_node_position(node))
1157
-
1180
+ condition = children[0]
1181
+ true_branch = children.get_rule('child_statement')
1182
+ return ModularIf(condition=condition, true_branch=true_branch, position=self._get_node_position(node))
1183
+
1158
1184
  def visit_ifelse_statement(self, node, children):
1159
- return ModularIfElse(condition=children[0], true_branch=children[1], false_branch=children[2], position=self._get_node_position(node))
1185
+ condition = children[0]
1186
+ true_branch = children.get_rule('child_statement')
1187
+ false_branch = children.get_rule('child_statement', index=1)
1188
+ return ModularIfElse(condition=condition, true_branch=true_branch, false_branch=false_branch, position=self._get_node_position(node))
1160
1189
 
1161
1190
  def visit_modifier_show_only(self, node, children):
1162
1191
  child = children[0] # We know modifiers can only have one child.
@@ -1229,8 +1258,10 @@ class ASTBuilderVisitor(PTNodeVisitor):
1229
1258
 
1230
1259
  def visit_child_statement(self, node, children):
1231
1260
  # Grammar: [empty_statement, statement_block, module_instantiation]
1232
- # Return the first child if available
1233
- return children[0] if children else None
1261
+ # Return all children as a list. statement_block flattens its contents
1262
+ # into children via _visit_node's extend behavior, so we must return all
1263
+ # of them rather than just children[0] (which would silently drop siblings).
1264
+ return list(children)
1234
1265
 
1235
1266
  def visit_expr(self, node, children) -> Expression:
1236
1267
  if not children:
@@ -184,6 +184,8 @@ def _serialize_position(position: Position) -> dict[str, Any]:
184
184
  "origin": position.origin,
185
185
  "line": position.line,
186
186
  "column": position.column,
187
+ "start_offset": position.start_offset,
188
+ "end_offset": position.end_offset,
187
189
  }
188
190
 
189
191
 
@@ -277,6 +279,8 @@ def _deserialize_position(data: dict[str, Any]) -> Position:
277
279
  origin=data["origin"],
278
280
  line=data["line"],
279
281
  column=data["column"],
282
+ start_offset=data.get("start_offset", 0),
283
+ end_offset=data.get("end_offset", 0),
280
284
  )
281
285
 
282
286
 
@@ -118,9 +118,6 @@ class SourceMap:
118
118
  start_pos: Starting position in the combined string (0-indexed)
119
119
  length: Number of characters to replace
120
120
  """
121
- if length <= 0:
122
- return
123
-
124
121
  end_pos = start_pos + length
125
122
 
126
123
  # Collect segments to modify and new segments to add (for splits)
@@ -264,34 +261,48 @@ class SourceMap:
264
261
  self._combined_string = ''.join(parts)
265
262
  self._combined_string_dirty = False
266
263
 
267
- def get_location(self, position: int):
264
+ def get_location(self, position: int, end_position: Optional[int] = None):
268
265
  """Get the original source location for a position in the combined string.
269
-
266
+
270
267
  Args:
271
268
  position: Character position in the combined string (0-indexed)
272
-
269
+ end_position: Optional end position (exclusive) in the combined string.
270
+ If provided, end_offset in the returned Position will reflect the
271
+ end of the token within the origin's content.
272
+
273
273
  Returns:
274
- Position with origin, line, and column in the original source
274
+ Position with origin, line, column, start_offset, and end_offset
275
275
  """
276
276
  from .builder import Position # Lazy import to avoid circular dependency
277
-
277
+
278
278
  if position < 0:
279
279
  position = 0
280
-
280
+
281
281
  # Find the segment containing this position
282
282
  segment = self._find_segment(position)
283
-
283
+
284
284
  if segment is None:
285
285
  # Position is beyond all segments, return location from last segment
286
286
  if self._segments:
287
287
  last_segment = max(self._segments, key=lambda s: s.combined_start + len(s.content))
288
- return self._calculate_location_in_segment(last_segment, len(last_segment.content))
288
+ seg_offset = len(last_segment.content)
289
+ end_offset = (
290
+ (end_position - last_segment.combined_start)
291
+ if end_position is not None
292
+ else seg_offset
293
+ )
294
+ return self._calculate_location_in_segment(last_segment, seg_offset, end_offset)
289
295
  else:
290
296
  return Position(origin="", line=1, column=1)
291
-
292
- # Calculate the position within the segment
297
+
298
+ # Calculate offsets within the segment
293
299
  segment_offset = position - segment.combined_start
294
- return self._calculate_location_in_segment(segment, segment_offset)
300
+ end_offset = (
301
+ (end_position - segment.combined_start)
302
+ if end_position is not None
303
+ else segment_offset
304
+ )
305
+ return self._calculate_location_in_segment(segment, segment_offset, end_offset)
295
306
 
296
307
  def _find_segment(self, position: int) -> Optional[SourceSegment]:
297
308
  """Find the segment containing the given position.
@@ -316,38 +327,34 @@ class SourceMap:
316
327
  else:
317
328
  left = mid + 1
318
329
 
319
- # Check if position is in the last segment
320
- if self._segments:
321
- last_segment = self._segments[-1]
322
- if last_segment.combined_start <= position < last_segment.combined_start + len(last_segment.content):
323
- return last_segment
324
-
325
330
  return None
326
331
 
327
- def _calculate_location_in_segment(self, segment: SourceSegment, offset: int):
332
+ def _calculate_location_in_segment(self, segment: SourceSegment, offset: int,
333
+ end_offset: Optional[int] = None):
328
334
  """Calculate the source location for an offset within a segment.
329
-
335
+
330
336
  Args:
331
337
  segment: The source segment
332
- offset: Character offset within the segment (0-indexed)
333
-
338
+ offset: Character offset within the segment (0-indexed), used for start
339
+ end_offset: Optional exclusive end offset within the segment (0-indexed)
340
+
334
341
  Returns:
335
- Position with the original source location
342
+ Position with the original source location, start_offset, and end_offset
336
343
  """
337
344
  from .builder import Position # Lazy import to avoid circular dependency
338
-
345
+
339
346
  if offset < 0:
340
347
  offset = 0 # pragma: no cover
341
348
  if offset > len(segment.content):
342
349
  offset = len(segment.content) # pragma: no cover
343
-
350
+
344
351
  # Count lines in the content up to the offset
345
352
  content_before = segment.content[:offset]
346
353
  line_count = content_before.count('\n')
347
-
354
+
348
355
  # Calculate line number
349
356
  line_number = segment.start_line + line_count
350
-
357
+
351
358
  # Calculate column number
352
359
  if line_count == 0:
353
360
  # Same line as start
@@ -356,11 +363,15 @@ class SourceMap:
356
363
  # Find the last newline before offset
357
364
  last_newline = content_before.rfind('\n')
358
365
  column_number = offset - last_newline
359
-
366
+
367
+ resolved_end = end_offset if end_offset is not None else offset
368
+
360
369
  return Position(
361
370
  origin=segment.origin,
362
371
  line=line_number,
363
- column=column_number
372
+ column=column_number,
373
+ start_offset=offset,
374
+ end_offset=resolved_end,
364
375
  )
365
376
 
366
377
  def get_segments(self) -> list[SourceSegment]:
@@ -454,7 +454,7 @@ def modular_echo():
454
454
 
455
455
 
456
456
  def modular_call():
457
- return (module_instantiation_name, TOK_PAREN, arguments, TOK_ENDPAREN, child_statement)
457
+ return (module_instantiation_name, TOK_PAREN, arguments, TOK_ENDPAREN, ZeroOrMore(comment), child_statement)
458
458
 
459
459
 
460
460
  # --- Parameters used to define functions and modules ---