openscad-parser 2.3.1__tar.gz → 2.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openscad_parser
3
- Version: 2.3.1
3
+ Version: 2.3.3
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,8 @@ 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'
26
+ Requires-Dist: coverage-badge>=1.1.0 ; extra == 'dev'
25
27
  Requires-Dist: pyyaml>=6.0 ; extra == 'yaml'
26
28
  Maintainer: Revar Desmera
27
29
  Maintainer-email: Revar Desmera <revarbat@gmail.com>
@@ -39,6 +41,9 @@ Description-Content-Type: text/x-rst
39
41
  OpenSCAD Parser
40
42
  ===============
41
43
 
44
+ .. image:: https://raw.githubusercontent.com/BelfrySCAD/openscad_parser/main/coverage-badge.svg
45
+ :alt: Coverage
46
+
42
47
  A PEG parser for the OpenSCAD language that can parse OpenSCAD source code and optionally generate an Abstract Syntax Tree (AST) for programmatic analysis and manipulation.
43
48
 
44
49
  Features
@@ -231,7 +236,7 @@ All AST nodes inherit from ``ASTNode`` and have a ``position`` attribute for sou
231
236
 
232
237
  # Access source position
233
238
  print(assignment.position.line) # Line number (1-indexed)
234
- print(assignment.position.char) # Column number (1-indexed)
239
+ print(assignment.position.column) # Column number (1-indexed)
235
240
 
236
241
  Examples
237
242
  --------
@@ -408,7 +413,7 @@ List Comprehensions
408
413
 
409
414
  - ``ListComprehension``: Vector/list literals
410
415
  - ``ListCompFor``: for loops in list comprehensions
411
- - ``ListCompCStyleFor``: C-style for loops
416
+ - ``ListCompCFor``: C-style for loops
412
417
  - ``ListCompIf``, ``ListCompIfElse``: Conditionals
413
418
  - ``ListCompLet``: let expressions
414
419
  - ``ListCompEach``: each expressions
@@ -418,8 +423,9 @@ Module Instantiations
418
423
 
419
424
  - ``ModularCall``: Module calls with arguments and children
420
425
  - ``ModularFor``: for loops
421
- - ``ModularCLikeFor``: C-style for loops
426
+ - ``ModularCFor``: C-style for loops
422
427
  - ``ModularIntersectionFor``: intersection_for loops
428
+ - ``ModularIntersectionCFor``: C-style intersection_for loops
423
429
  - ``ModularLet``: let statements
424
430
  - ``ModularEcho``: echo statements
425
431
  - ``ModularAssert``: assert statements
@@ -470,10 +476,12 @@ Main Functions
470
476
  :param debug: If True, enables debug output
471
477
  :returns: ParserPython instance
472
478
 
473
- ``getASTfromString(code: str)``
479
+ ``getASTfromString(code: str, include_comments: bool = False, origin: str = "<string>")``
474
480
  Parse OpenSCAD code from a string and return its AST.
475
481
 
476
482
  :param code: The OpenSCAD source code to be parsed
483
+ :param include_comments: If True, include comment nodes in the AST (default: False)
484
+ :param origin: Origin identifier used in source position tracking (default: "<string>")
477
485
  :returns: AST node or list of AST nodes (for top-level statements)
478
486
  :rtype: ASTNode | list[ASTNode] | None
479
487
 
@@ -514,12 +522,13 @@ Main Functions
514
522
 
515
523
  **Note:** The ``process_includes`` parameter affects the AST structure (see ``getASTfromFile`` documentation).
516
524
 
517
- ``parse_ast(parser, code, file="")``
525
+ ``parse_ast(parser, code, file="", source_map=None)``
518
526
  Parse OpenSCAD code and generate an AST (lower-level API).
519
527
 
520
528
  :param parser: Arpeggio parser instance from getOpenSCADParser()
521
529
  :param code: OpenSCAD code string to parse
522
530
  :param file: Optional file path for source location tracking
531
+ :param source_map: Optional ``SourceMap`` for multi-origin position tracking
523
532
  :returns: AST node or list of AST nodes (for top-level statements)
524
533
 
525
534
  ``clear_ast_cache()``
@@ -528,6 +537,21 @@ Main Functions
528
537
 
529
538
  This function removes all cached AST trees from memory.
530
539
 
540
+ ``build_scopes(ast: list[ASTNode]) -> Scope``
541
+ Build a scope tree over an AST and attach a ``scope`` attribute to every node.
542
+
543
+ :param ast: A list of top-level AST nodes (as returned by the ``getAST*`` functions)
544
+ :returns: The root ``Scope`` object
545
+
546
+ ``Scope``
547
+ Represents a lexical scope with three independent namespaces (variables, functions,
548
+ modules), mirroring OpenSCAD's scoping rules.
549
+
550
+ - ``scope.lookup_variable(name)`` — search this scope and its parents
551
+ - ``scope.lookup_function(name)`` — search this scope and its parents
552
+ - ``scope.lookup_module(name)`` — search this scope and its parents
553
+ - ``scope.parent`` — the enclosing scope (``None`` for root)
554
+
531
555
  Serialization Functions
532
556
  ~~~~~~~~~~~~~~~~~~~~~~~
533
557
 
@@ -636,12 +660,15 @@ All AST nodes include source position information::
636
660
  assignment = ast[0]
637
661
 
638
662
  position = assignment.position
639
- print(position.file) # "example.scad"
640
- print(position.line) # 1 (1-indexed)
641
- print(position.char) # 1 (1-indexed, column number)
642
- print(position.position) # 0 (0-indexed character position)
663
+ print(position.origin) # "example.scad" (origin identifier)
664
+ print(position.line) # 1 (1-indexed line number)
665
+ print(position.column) # 1 (1-indexed column number)
666
+ print(position.start_offset) # 0 (0-based byte offset of token start within origin)
667
+ print(position.end_offset) # N (0-based exclusive byte offset of token end)
643
668
 
644
- The ``Position`` class provides lazy evaluation of line/column numbers from character positions.
669
+ The ``Position`` dataclass carries both line/column coordinates and byte offsets relative
670
+ to the origin's content. For single-file parses these equal file byte offsets; for
671
+ multi-origin parses (e.g. after include expansion) they are relative to each included file.
645
672
 
646
673
  Serialization
647
674
  -------------
@@ -825,12 +852,42 @@ The AST is a tree structure that can be traversed recursively::
825
852
  for node in ast:
826
853
  visit_node(node)
827
854
 
855
+ Scope Tracking
856
+ --------------
857
+
858
+ The parser can build a scope tree over the AST, resolving variable, function, and module
859
+ names according to OpenSCAD's three-namespace scoping rules::
860
+
861
+ from openscad_parser.ast import getASTfromString, build_scopes
862
+
863
+ ast = getASTfromString("""
864
+ x = 10;
865
+ module box(size = x) { cube(size); }
866
+ box();
867
+ """)
868
+
869
+ root_scope = build_scopes(ast)
870
+
871
+ # Look up names in the root scope
872
+ print(root_scope.lookup_variable("x")) # Assignment node
873
+ print(root_scope.lookup_module("box")) # ModuleDeclaration node
874
+
875
+ # Each AST node has a .scope attribute pointing to its enclosing scope
876
+ box_decl = ast[1]
877
+ cube_call = box_decl.children[0]
878
+ print(cube_call.scope.lookup_variable("size")) # ParameterDeclaration node
879
+
880
+ ``build_scopes(ast)`` returns the root ``Scope`` object and attaches a ``scope`` attribute
881
+ to every node in the tree. Scopes form a parent chain so lookups fall through to enclosing
882
+ scopes automatically. Declarations (variables, functions, modules) inside a block are
883
+ hoisted to the top of that block's scope before child nodes are visited.
884
+
828
885
  Testing
829
886
  -------
830
887
 
831
888
  The project includes a comprehensive test suite. Run tests with::
832
889
 
833
- pytest tests/
890
+ uv run pytest tests/
834
891
 
835
892
  Development
836
893
  -----------
@@ -1,6 +1,9 @@
1
1
  OpenSCAD Parser
2
2
  ===============
3
3
 
4
+ .. image:: https://raw.githubusercontent.com/BelfrySCAD/openscad_parser/main/coverage-badge.svg
5
+ :alt: Coverage
6
+
4
7
  A PEG parser for the OpenSCAD language that can parse OpenSCAD source code and optionally generate an Abstract Syntax Tree (AST) for programmatic analysis and manipulation.
5
8
 
6
9
  Features
@@ -193,7 +196,7 @@ All AST nodes inherit from ``ASTNode`` and have a ``position`` attribute for sou
193
196
 
194
197
  # Access source position
195
198
  print(assignment.position.line) # Line number (1-indexed)
196
- print(assignment.position.char) # Column number (1-indexed)
199
+ print(assignment.position.column) # Column number (1-indexed)
197
200
 
198
201
  Examples
199
202
  --------
@@ -370,7 +373,7 @@ List Comprehensions
370
373
 
371
374
  - ``ListComprehension``: Vector/list literals
372
375
  - ``ListCompFor``: for loops in list comprehensions
373
- - ``ListCompCStyleFor``: C-style for loops
376
+ - ``ListCompCFor``: C-style for loops
374
377
  - ``ListCompIf``, ``ListCompIfElse``: Conditionals
375
378
  - ``ListCompLet``: let expressions
376
379
  - ``ListCompEach``: each expressions
@@ -380,8 +383,9 @@ Module Instantiations
380
383
 
381
384
  - ``ModularCall``: Module calls with arguments and children
382
385
  - ``ModularFor``: for loops
383
- - ``ModularCLikeFor``: C-style for loops
386
+ - ``ModularCFor``: C-style for loops
384
387
  - ``ModularIntersectionFor``: intersection_for loops
388
+ - ``ModularIntersectionCFor``: C-style intersection_for loops
385
389
  - ``ModularLet``: let statements
386
390
  - ``ModularEcho``: echo statements
387
391
  - ``ModularAssert``: assert statements
@@ -432,10 +436,12 @@ Main Functions
432
436
  :param debug: If True, enables debug output
433
437
  :returns: ParserPython instance
434
438
 
435
- ``getASTfromString(code: str)``
439
+ ``getASTfromString(code: str, include_comments: bool = False, origin: str = "<string>")``
436
440
  Parse OpenSCAD code from a string and return its AST.
437
441
 
438
442
  :param code: The OpenSCAD source code to be parsed
443
+ :param include_comments: If True, include comment nodes in the AST (default: False)
444
+ :param origin: Origin identifier used in source position tracking (default: "<string>")
439
445
  :returns: AST node or list of AST nodes (for top-level statements)
440
446
  :rtype: ASTNode | list[ASTNode] | None
441
447
 
@@ -476,12 +482,13 @@ Main Functions
476
482
 
477
483
  **Note:** The ``process_includes`` parameter affects the AST structure (see ``getASTfromFile`` documentation).
478
484
 
479
- ``parse_ast(parser, code, file="")``
485
+ ``parse_ast(parser, code, file="", source_map=None)``
480
486
  Parse OpenSCAD code and generate an AST (lower-level API).
481
487
 
482
488
  :param parser: Arpeggio parser instance from getOpenSCADParser()
483
489
  :param code: OpenSCAD code string to parse
484
490
  :param file: Optional file path for source location tracking
491
+ :param source_map: Optional ``SourceMap`` for multi-origin position tracking
485
492
  :returns: AST node or list of AST nodes (for top-level statements)
486
493
 
487
494
  ``clear_ast_cache()``
@@ -490,6 +497,21 @@ Main Functions
490
497
 
491
498
  This function removes all cached AST trees from memory.
492
499
 
500
+ ``build_scopes(ast: list[ASTNode]) -> Scope``
501
+ Build a scope tree over an AST and attach a ``scope`` attribute to every node.
502
+
503
+ :param ast: A list of top-level AST nodes (as returned by the ``getAST*`` functions)
504
+ :returns: The root ``Scope`` object
505
+
506
+ ``Scope``
507
+ Represents a lexical scope with three independent namespaces (variables, functions,
508
+ modules), mirroring OpenSCAD's scoping rules.
509
+
510
+ - ``scope.lookup_variable(name)`` — search this scope and its parents
511
+ - ``scope.lookup_function(name)`` — search this scope and its parents
512
+ - ``scope.lookup_module(name)`` — search this scope and its parents
513
+ - ``scope.parent`` — the enclosing scope (``None`` for root)
514
+
493
515
  Serialization Functions
494
516
  ~~~~~~~~~~~~~~~~~~~~~~~
495
517
 
@@ -598,12 +620,15 @@ All AST nodes include source position information::
598
620
  assignment = ast[0]
599
621
 
600
622
  position = assignment.position
601
- print(position.file) # "example.scad"
602
- print(position.line) # 1 (1-indexed)
603
- print(position.char) # 1 (1-indexed, column number)
604
- print(position.position) # 0 (0-indexed character position)
623
+ print(position.origin) # "example.scad" (origin identifier)
624
+ print(position.line) # 1 (1-indexed line number)
625
+ print(position.column) # 1 (1-indexed column number)
626
+ print(position.start_offset) # 0 (0-based byte offset of token start within origin)
627
+ print(position.end_offset) # N (0-based exclusive byte offset of token end)
605
628
 
606
- The ``Position`` class provides lazy evaluation of line/column numbers from character positions.
629
+ The ``Position`` dataclass carries both line/column coordinates and byte offsets relative
630
+ to the origin's content. For single-file parses these equal file byte offsets; for
631
+ multi-origin parses (e.g. after include expansion) they are relative to each included file.
607
632
 
608
633
  Serialization
609
634
  -------------
@@ -787,12 +812,42 @@ The AST is a tree structure that can be traversed recursively::
787
812
  for node in ast:
788
813
  visit_node(node)
789
814
 
815
+ Scope Tracking
816
+ --------------
817
+
818
+ The parser can build a scope tree over the AST, resolving variable, function, and module
819
+ names according to OpenSCAD's three-namespace scoping rules::
820
+
821
+ from openscad_parser.ast import getASTfromString, build_scopes
822
+
823
+ ast = getASTfromString("""
824
+ x = 10;
825
+ module box(size = x) { cube(size); }
826
+ box();
827
+ """)
828
+
829
+ root_scope = build_scopes(ast)
830
+
831
+ # Look up names in the root scope
832
+ print(root_scope.lookup_variable("x")) # Assignment node
833
+ print(root_scope.lookup_module("box")) # ModuleDeclaration node
834
+
835
+ # Each AST node has a .scope attribute pointing to its enclosing scope
836
+ box_decl = ast[1]
837
+ cube_call = box_decl.children[0]
838
+ print(cube_call.scope.lookup_variable("size")) # ParameterDeclaration node
839
+
840
+ ``build_scopes(ast)`` returns the root ``Scope`` object and attaches a ``scope`` attribute
841
+ to every node in the tree. Scopes form a parent chain so lookups fall through to enclosing
842
+ scopes automatically. Declarations (variables, functions, modules) inside a block are
843
+ hoisted to the top of that block's scope before child nodes are visited.
844
+
790
845
  Testing
791
846
  -------
792
847
 
793
848
  The project includes a comprehensive test suite. Run tests with::
794
849
 
795
- pytest tests/
850
+ uv run pytest tests/
796
851
 
797
852
  Development
798
853
  -----------
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "openscad_parser"
7
- version = "2.3.1"
7
+ version = "2.3.3"
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,8 @@ dependencies = [
39
39
  dev = [
40
40
  "pytest>=7.0.0",
41
41
  "pytest-cov>=7.1.0",
42
+ "PyYAML>=6.0",
43
+ "coverage-badge>=1.1.0",
42
44
  ]
43
45
  yaml = [
44
46
  "PyYAML>=6.0",
@@ -972,9 +972,9 @@ class ASTBuilderVisitor(PTNodeVisitor):
972
972
  for op in reversed(ops):
973
973
  if op == '-':
974
974
  result = UnaryMinusOp(expr=result, position=self._get_node_position(node))
975
- elif op == '!':
975
+ elif op == '!': # pragma: no cover
976
976
  result = LogicalNotOp(expr=result, position=self._get_node_position(node))
977
- elif op == '~':
977
+ elif op == '~': # pragma: no cover
978
978
  result = BitwiseNotOp(expr=result, position=self._get_node_position(node))
979
979
 
980
980
  return result
@@ -1048,8 +1048,6 @@ class ASTBuilderVisitor(PTNodeVisitor):
1048
1048
 
1049
1049
  def visit_vector_expr(self, node, children):
1050
1050
  elements = children if children else []
1051
- if not isinstance(elements, list):
1052
- elements = [elements]
1053
1051
  return ListComprehension(elements=elements, position=self._get_node_position(node))
1054
1052
 
1055
1053
  def visit_funclit_def(self, node, children):
@@ -1065,7 +1063,7 @@ class ASTBuilderVisitor(PTNodeVisitor):
1065
1063
  return children[0]
1066
1064
 
1067
1065
  def visit_listcomp_paren_expr(self, node, children):
1068
- return children[0] if children else None
1066
+ return children[0]
1069
1067
 
1070
1068
  def visit_listcomp_let(self, node, children):
1071
1069
  return ListCompLet(assignments=children[0], body=children[1], position=self._get_node_position(node))
@@ -1118,8 +1116,6 @@ class ASTBuilderVisitor(PTNodeVisitor):
1118
1116
  mods = children.get_rule("child_statement") if hasattr(children, "get_rule") else (children[2] if len(children) > 2 else [])
1119
1117
  if mods is None: # pragma: no cover
1120
1118
  mods = []
1121
- if not isinstance(mods, list):
1122
- mods = [mods]
1123
1119
  return ModularCall(
1124
1120
  name=name,
1125
1121
  arguments=arguments,
@@ -1131,8 +1127,6 @@ class ASTBuilderVisitor(PTNodeVisitor):
1131
1127
  initial = children[0] if isinstance(children[0], list) else [children[0]]
1132
1128
  increment = children[2] if isinstance(children[2], list) else [children[2]]
1133
1129
  body = children.get_rule('child_statement')
1134
- if not isinstance(body, list):
1135
- body = [body]
1136
1130
  return ModularCFor(
1137
1131
  initial=initial,
1138
1132
  condition=children[1],
@@ -1144,8 +1138,6 @@ class ASTBuilderVisitor(PTNodeVisitor):
1144
1138
  def visit_modular_for(self, node, children):
1145
1139
  assignments = children[0] if isinstance(children[0], list) else [children[0]]
1146
1140
  body = children.get_rule('child_statement')
1147
- if not isinstance(body, list):
1148
- body = [body]
1149
1141
  return ModularFor(
1150
1142
  assignments=assignments,
1151
1143
  body=body,
@@ -1156,8 +1148,6 @@ class ASTBuilderVisitor(PTNodeVisitor):
1156
1148
  initial = children[0] if isinstance(children[0], list) else [children[0]]
1157
1149
  increment = children[2] if isinstance(children[2], list) else [children[2]]
1158
1150
  body = children.get_rule('child_statement')
1159
- if not isinstance(body, list):
1160
- body = [body]
1161
1151
  return ModularIntersectionCFor(
1162
1152
  initial=initial,
1163
1153
  condition=children[1],
@@ -1169,46 +1159,32 @@ class ASTBuilderVisitor(PTNodeVisitor):
1169
1159
  def visit_modular_intersection_for(self, node, children):
1170
1160
  assignments = children[0] if isinstance(children[0], list) else [children[0]]
1171
1161
  body = children.get_rule('child_statement')
1172
- if not isinstance(body, list):
1173
- body = [body]
1174
1162
  return ModularIntersectionFor(assignments=assignments, body=body, position=self._get_node_position(node))
1175
1163
 
1176
1164
  def visit_modular_let(self, node, children):
1177
1165
  assignments = children[0] if isinstance(children[0], list) else [children[0]]
1178
1166
  mods = children.get_rule('child_statement')
1179
- if not isinstance(mods, list):
1180
- mods = [mods]
1181
1167
  return ModularLet(assignments=assignments, children=mods, position=self._get_node_position(node))
1182
1168
 
1183
1169
  def visit_modular_echo(self, node, children):
1184
1170
  arguments = children[0] if isinstance(children[0], list) else [children[0]]
1185
1171
  mods = children.get_rule('child_statement')
1186
- if not isinstance(mods, list):
1187
- mods = [mods]
1188
1172
  return ModularEcho(arguments=arguments, children=mods, position=self._get_node_position(node))
1189
1173
 
1190
1174
  def visit_modular_assert(self, node, children):
1191
1175
  arguments = children[0] if isinstance(children[0], list) else [children[0]]
1192
1176
  mods = children.get_rule('child_statement')
1193
- if not isinstance(mods, list):
1194
- mods = [mods]
1195
1177
  return ModularAssert(arguments=arguments, children=mods, position=self._get_node_position(node))
1196
1178
 
1197
1179
  def visit_if_statement(self, node, children):
1198
1180
  condition = children[0]
1199
1181
  true_branch = children.get_rule('child_statement')
1200
- if not isinstance(true_branch, list):
1201
- true_branch = [true_branch]
1202
1182
  return ModularIf(condition=condition, true_branch=true_branch, position=self._get_node_position(node))
1203
1183
 
1204
1184
  def visit_ifelse_statement(self, node, children):
1205
1185
  condition = children[0]
1206
1186
  true_branch = children.get_rule('child_statement')
1207
1187
  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
1188
  return ModularIfElse(condition=condition, true_branch=true_branch, false_branch=false_branch, position=self._get_node_position(node))
1213
1189
 
1214
1190
  def visit_modifier_show_only(self, node, children):
@@ -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)
@@ -330,12 +327,6 @@ class SourceMap:
330
327
  else:
331
328
  left = mid + 1
332
329
 
333
- # Check if position is in the last segment
334
- if self._segments:
335
- last_segment = self._segments[-1]
336
- if last_segment.combined_start <= position < last_segment.combined_start + len(last_segment.content):
337
- return last_segment
338
-
339
330
  return None
340
331
 
341
332
  def _calculate_location_in_segment(self, segment: SourceSegment, offset: int,