openscad-parser 2.3.0__tar.gz → 2.3.1__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.1
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 = "2.3.0"
7
+ version = "2.3.1"
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 = [
@@ -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 []
@@ -1015,9 +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 = []
1050
+ elements = children if children else []
1021
1051
  if not isinstance(elements, list):
1022
1052
  elements = [elements]
1023
1053
  return ListComprehension(elements=elements, position=self._get_node_position(node))
@@ -1080,18 +1110,12 @@ class ASTBuilderVisitor(PTNodeVisitor):
1080
1110
  raise ValueError("modular_call should have a module name")
1081
1111
  if not isinstance(name, Identifier):
1082
1112
  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 []
1113
+ arguments = children.get_rule("arguments") if hasattr(children, "get_rule") else (children[1] if len(children) > 1 else [])
1087
1114
  if arguments is None: # pragma: no cover
1088
1115
  arguments = []
1089
1116
  if not isinstance(arguments, list): # pragma: no cover
1090
1117
  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 []
1118
+ mods = children.get_rule("child_statement") if hasattr(children, "get_rule") else (children[2] if len(children) > 2 else [])
1095
1119
  if mods is None: # pragma: no cover
1096
1120
  mods = []
1097
1121
  if not isinstance(mods, list):
@@ -1106,57 +1130,86 @@ class ASTBuilderVisitor(PTNodeVisitor):
1106
1130
  def visit_modular_c_for(self, node, children):
1107
1131
  initial = children[0] if isinstance(children[0], list) else [children[0]]
1108
1132
  increment = children[2] if isinstance(children[2], list) else [children[2]]
1133
+ body = children.get_rule('child_statement')
1134
+ if not isinstance(body, list):
1135
+ body = [body]
1109
1136
  return ModularCFor(
1110
1137
  initial=initial,
1111
1138
  condition=children[1],
1112
1139
  increment=increment,
1113
- body=children[3],
1140
+ body=body,
1114
1141
  position=self._get_node_position(node)
1115
1142
  )
1116
1143
 
1117
1144
  def visit_modular_for(self, node, children):
1118
1145
  assignments = children[0] if isinstance(children[0], list) else [children[0]]
1146
+ body = children.get_rule('child_statement')
1147
+ if not isinstance(body, list):
1148
+ body = [body]
1119
1149
  return ModularFor(
1120
1150
  assignments=assignments,
1121
- body=children[1],
1151
+ body=body,
1122
1152
  position=self._get_node_position(node)
1123
1153
  )
1124
1154
 
1125
1155
  def visit_modular_intersection_c_for(self, node, children):
1126
1156
  initial = children[0] if isinstance(children[0], list) else [children[0]]
1127
1157
  increment = children[2] if isinstance(children[2], list) else [children[2]]
1158
+ body = children.get_rule('child_statement')
1159
+ if not isinstance(body, list):
1160
+ body = [body]
1128
1161
  return ModularIntersectionCFor(
1129
1162
  initial=initial,
1130
1163
  condition=children[1],
1131
1164
  increment=increment,
1132
- body=children[3],
1165
+ body=body,
1133
1166
  position=self._get_node_position(node)
1134
1167
  )
1135
1168
 
1136
1169
  def visit_modular_intersection_for(self, node, children):
1137
1170
  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))
1171
+ body = children.get_rule('child_statement')
1172
+ if not isinstance(body, list):
1173
+ body = [body]
1174
+ return ModularIntersectionFor(assignments=assignments, body=body, position=self._get_node_position(node))
1139
1175
 
1140
1176
  def visit_modular_let(self, node, children):
1141
1177
  assignments = children[0] if isinstance(children[0], list) else [children[0]]
1142
- mods = children[1] if isinstance(children[1], list) else [children[1]]
1178
+ mods = children.get_rule('child_statement')
1179
+ if not isinstance(mods, list):
1180
+ mods = [mods]
1143
1181
  return ModularLet(assignments=assignments, children=mods, position=self._get_node_position(node))
1144
1182
 
1145
1183
  def visit_modular_echo(self, node, children):
1146
1184
  arguments = children[0] if isinstance(children[0], list) else [children[0]]
1147
- mods = children[1] if isinstance(children[1], list) else [children[1]]
1185
+ mods = children.get_rule('child_statement')
1186
+ if not isinstance(mods, list):
1187
+ mods = [mods]
1148
1188
  return ModularEcho(arguments=arguments, children=mods, position=self._get_node_position(node))
1149
1189
 
1150
1190
  def visit_modular_assert(self, node, children):
1151
1191
  arguments = children[0] if isinstance(children[0], list) else [children[0]]
1152
- mods = children[1] if isinstance(children[1], list) else [children[1]]
1192
+ mods = children.get_rule('child_statement')
1193
+ if not isinstance(mods, list):
1194
+ mods = [mods]
1153
1195
  return ModularAssert(arguments=arguments, children=mods, position=self._get_node_position(node))
1154
1196
 
1155
1197
  def visit_if_statement(self, node, children):
1156
- return ModularIf(condition=children[0], true_branch=children[1], position=self._get_node_position(node))
1157
-
1198
+ condition = children[0]
1199
+ true_branch = children.get_rule('child_statement')
1200
+ if not isinstance(true_branch, list):
1201
+ true_branch = [true_branch]
1202
+ return ModularIf(condition=condition, true_branch=true_branch, position=self._get_node_position(node))
1203
+
1158
1204
  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))
1205
+ condition = children[0]
1206
+ true_branch = children.get_rule('child_statement')
1207
+ false_branch = children.get_rule('child_statement', index=1)
1208
+ if not isinstance(true_branch, list):
1209
+ true_branch = [true_branch]
1210
+ if not isinstance(false_branch, list):
1211
+ false_branch = [false_branch]
1212
+ return ModularIfElse(condition=condition, true_branch=true_branch, false_branch=false_branch, position=self._get_node_position(node))
1160
1213
 
1161
1214
  def visit_modifier_show_only(self, node, children):
1162
1215
  child = children[0] # We know modifiers can only have one child.
@@ -1229,8 +1282,10 @@ class ASTBuilderVisitor(PTNodeVisitor):
1229
1282
 
1230
1283
  def visit_child_statement(self, node, children):
1231
1284
  # Grammar: [empty_statement, statement_block, module_instantiation]
1232
- # Return the first child if available
1233
- return children[0] if children else None
1285
+ # Return all children as a list. statement_block flattens its contents
1286
+ # into children via _visit_node's extend behavior, so we must return all
1287
+ # of them rather than just children[0] (which would silently drop siblings).
1288
+ return list(children)
1234
1289
 
1235
1290
  def visit_expr(self, node, children) -> Expression:
1236
1291
  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
 
@@ -264,34 +264,48 @@ class SourceMap:
264
264
  self._combined_string = ''.join(parts)
265
265
  self._combined_string_dirty = False
266
266
 
267
- def get_location(self, position: int):
267
+ def get_location(self, position: int, end_position: Optional[int] = None):
268
268
  """Get the original source location for a position in the combined string.
269
-
269
+
270
270
  Args:
271
271
  position: Character position in the combined string (0-indexed)
272
-
272
+ end_position: Optional end position (exclusive) in the combined string.
273
+ If provided, end_offset in the returned Position will reflect the
274
+ end of the token within the origin's content.
275
+
273
276
  Returns:
274
- Position with origin, line, and column in the original source
277
+ Position with origin, line, column, start_offset, and end_offset
275
278
  """
276
279
  from .builder import Position # Lazy import to avoid circular dependency
277
-
280
+
278
281
  if position < 0:
279
282
  position = 0
280
-
283
+
281
284
  # Find the segment containing this position
282
285
  segment = self._find_segment(position)
283
-
286
+
284
287
  if segment is None:
285
288
  # Position is beyond all segments, return location from last segment
286
289
  if self._segments:
287
290
  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))
291
+ seg_offset = len(last_segment.content)
292
+ end_offset = (
293
+ (end_position - last_segment.combined_start)
294
+ if end_position is not None
295
+ else seg_offset
296
+ )
297
+ return self._calculate_location_in_segment(last_segment, seg_offset, end_offset)
289
298
  else:
290
299
  return Position(origin="", line=1, column=1)
291
-
292
- # Calculate the position within the segment
300
+
301
+ # Calculate offsets within the segment
293
302
  segment_offset = position - segment.combined_start
294
- return self._calculate_location_in_segment(segment, segment_offset)
303
+ end_offset = (
304
+ (end_position - segment.combined_start)
305
+ if end_position is not None
306
+ else segment_offset
307
+ )
308
+ return self._calculate_location_in_segment(segment, segment_offset, end_offset)
295
309
 
296
310
  def _find_segment(self, position: int) -> Optional[SourceSegment]:
297
311
  """Find the segment containing the given position.
@@ -324,30 +338,32 @@ class SourceMap:
324
338
 
325
339
  return None
326
340
 
327
- def _calculate_location_in_segment(self, segment: SourceSegment, offset: int):
341
+ def _calculate_location_in_segment(self, segment: SourceSegment, offset: int,
342
+ end_offset: Optional[int] = None):
328
343
  """Calculate the source location for an offset within a segment.
329
-
344
+
330
345
  Args:
331
346
  segment: The source segment
332
- offset: Character offset within the segment (0-indexed)
333
-
347
+ offset: Character offset within the segment (0-indexed), used for start
348
+ end_offset: Optional exclusive end offset within the segment (0-indexed)
349
+
334
350
  Returns:
335
- Position with the original source location
351
+ Position with the original source location, start_offset, and end_offset
336
352
  """
337
353
  from .builder import Position # Lazy import to avoid circular dependency
338
-
354
+
339
355
  if offset < 0:
340
356
  offset = 0 # pragma: no cover
341
357
  if offset > len(segment.content):
342
358
  offset = len(segment.content) # pragma: no cover
343
-
359
+
344
360
  # Count lines in the content up to the offset
345
361
  content_before = segment.content[:offset]
346
362
  line_count = content_before.count('\n')
347
-
363
+
348
364
  # Calculate line number
349
365
  line_number = segment.start_line + line_count
350
-
366
+
351
367
  # Calculate column number
352
368
  if line_count == 0:
353
369
  # Same line as start
@@ -356,11 +372,15 @@ class SourceMap:
356
372
  # Find the last newline before offset
357
373
  last_newline = content_before.rfind('\n')
358
374
  column_number = offset - last_newline
359
-
375
+
376
+ resolved_end = end_offset if end_offset is not None else offset
377
+
360
378
  return Position(
361
379
  origin=segment.origin,
362
380
  line=line_number,
363
- column=column_number
381
+ column=column_number,
382
+ start_offset=offset,
383
+ end_offset=resolved_end,
364
384
  )
365
385
 
366
386
  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 ---