openscad-parser 2.4.3__tar.gz → 2.4.5__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.4.3
3
+ Version: 2.4.5
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.4.3"
7
+ version = "2.4.5"
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 = [
@@ -193,7 +193,7 @@ class BooleanLiteral(Primary):
193
193
  val: bool
194
194
 
195
195
  def __str__(self):
196
- return str(self.val)
196
+ return "true" if self.val else "false"
197
197
 
198
198
 
199
199
  @dataclass
@@ -235,7 +235,8 @@ class ParameterDeclaration(ASTNode):
235
235
  default: Expression|None
236
236
 
237
237
  def __str__(self):
238
- return f"{self.name}{f' = {self.default}' if self.default else '' }"
238
+ has_default = self.default is not None and not isinstance(self.default, UndefinedLiteral)
239
+ return f"{self.name}{f'={self.default}' if has_default else ''}"
239
240
 
240
241
  def build_scope(self, parent_scope: "Scope") -> None:
241
242
  self.scope = parent_scope
@@ -469,21 +470,21 @@ class AssertOp(Expression):
469
470
  @dataclass
470
471
  class UnaryMinusOp(Expression):
471
472
  """Represents an OpenSCAD unary minus (negation) operation.
472
-
473
+
473
474
  Negates a numeric expression, making positive values negative and vice versa.
474
-
475
+
475
476
  Examples:
476
477
  -5 // Result: -5
477
478
  -x // Negate variable
478
479
  -(x + y) // Negate expression
479
-
480
+
480
481
  Attributes:
481
482
  expr: The expression to negate.
482
483
  """
483
484
  expr: Expression
484
485
 
485
486
  def __str__(self):
486
- return f"-{self.expr}"
487
+ return f"-{_lp(self.expr, _prec(self))}"
487
488
 
488
489
  def build_scope(self, parent_scope: "Scope") -> None:
489
490
  self.scope = parent_scope
@@ -493,15 +494,15 @@ class UnaryMinusOp(Expression):
493
494
  @dataclass
494
495
  class AdditionOp(Expression):
495
496
  """Represents an OpenSCAD addition operation.
496
-
497
+
497
498
  Performs arithmetic addition of two expressions. Can be used with numbers
498
499
  or vectors (element-wise addition for vectors).
499
-
500
+
500
501
  Examples:
501
502
  1 + 2 // Result: 3
502
503
  [1, 2, 3] + [4, 5, 6] // Result: [5, 7, 9]
503
504
  x + y // Variable addition
504
-
505
+
505
506
  Attributes:
506
507
  left: The left operand expression.
507
508
  right: The right operand expression.
@@ -510,7 +511,8 @@ class AdditionOp(Expression):
510
511
  right: Expression
511
512
 
512
513
  def __str__(self):
513
- return f"{self.left} + {self.right}"
514
+ p = _prec(self)
515
+ return f"{_lp(self.left, p)} + {_rp(self.right, p)}"
514
516
 
515
517
  def build_scope(self, parent_scope: "Scope") -> None:
516
518
  self.scope = parent_scope
@@ -538,7 +540,8 @@ class SubtractionOp(Expression):
538
540
  right: Expression
539
541
 
540
542
  def __str__(self):
541
- return f"{self.left} - {self.right}"
543
+ p = _prec(self)
544
+ return f"{_lp(self.left, p)} - {_rp(self.right, p)}"
542
545
 
543
546
  def build_scope(self, parent_scope: "Scope") -> None:
544
547
  self.scope = parent_scope
@@ -566,7 +569,8 @@ class MultiplicationOp(Expression):
566
569
  right: Expression
567
570
 
568
571
  def __str__(self):
569
- return f"{self.left} * {self.right}"
572
+ p = _prec(self)
573
+ return f"{_lp(self.left, p)} * {_rp(self.right, p)}"
570
574
 
571
575
  def build_scope(self, parent_scope: "Scope") -> None:
572
576
  self.scope = parent_scope
@@ -594,7 +598,8 @@ class DivisionOp(Expression):
594
598
  right: Expression
595
599
 
596
600
  def __str__(self):
597
- return f"{self.left} / {self.right}"
601
+ p = _prec(self)
602
+ return f"{_lp(self.left, p)} / {_rp(self.right, p)}"
598
603
 
599
604
  def build_scope(self, parent_scope: "Scope") -> None:
600
605
  self.scope = parent_scope
@@ -621,7 +626,8 @@ class ModuloOp(Expression):
621
626
  right: Expression
622
627
 
623
628
  def __str__(self):
624
- return f"{self.left} % {self.right}"
629
+ p = _prec(self)
630
+ return f"{_lp(self.left, p)} % {_rp(self.right, p)}"
625
631
 
626
632
  def build_scope(self, parent_scope: "Scope") -> None:
627
633
  self.scope = parent_scope
@@ -648,7 +654,8 @@ class ExponentOp(Expression):
648
654
  right: Expression
649
655
 
650
656
  def __str__(self):
651
- return f"{self.left} ^ {self.right}"
657
+ p = _prec(self)
658
+ return f"{_rp(self.left, p)} ^ {_lp(self.right, p)}"
652
659
 
653
660
  def build_scope(self, parent_scope: "Scope") -> None:
654
661
  self.scope = parent_scope
@@ -675,7 +682,8 @@ class BitwiseAndOp(Expression):
675
682
  right: Expression
676
683
 
677
684
  def __str__(self):
678
- return f"{self.left} & {self.right}"
685
+ p = _prec(self)
686
+ return f"{_lp(self.left, p)} & {_rp(self.right, p)}"
679
687
 
680
688
  def build_scope(self, parent_scope: "Scope") -> None:
681
689
  self.scope = parent_scope
@@ -702,7 +710,8 @@ class BitwiseOrOp(Expression):
702
710
  right: Expression
703
711
 
704
712
  def __str__(self):
705
- return f"{self.left} | {self.right}"
713
+ p = _prec(self)
714
+ return f"{_lp(self.left, p)} | {_rp(self.right, p)}"
706
715
 
707
716
  def build_scope(self, parent_scope: "Scope") -> None:
708
717
  self.scope = parent_scope
@@ -726,7 +735,7 @@ class BitwiseNotOp(Expression):
726
735
  expr: Expression
727
736
 
728
737
  def __str__(self):
729
- return f"~{self.expr}"
738
+ return f"~{_lp(self.expr, _prec(self))}"
730
739
 
731
740
  def build_scope(self, parent_scope: "Scope") -> None:
732
741
  self.scope = parent_scope
@@ -752,7 +761,8 @@ class BitwiseShiftLeftOp(Expression):
752
761
  right: Expression
753
762
 
754
763
  def __str__(self):
755
- return f"{self.left} << {self.right}"
764
+ p = _prec(self)
765
+ return f"{_lp(self.left, p)} << {_rp(self.right, p)}"
756
766
 
757
767
  def build_scope(self, parent_scope: "Scope") -> None:
758
768
  self.scope = parent_scope
@@ -779,7 +789,8 @@ class BitwiseShiftRightOp(Expression):
779
789
  right: Expression
780
790
 
781
791
  def __str__(self):
782
- return f"{self.left} >> {self.right}"
792
+ p = _prec(self)
793
+ return f"{_lp(self.left, p)} >> {_rp(self.right, p)}"
783
794
 
784
795
  def build_scope(self, parent_scope: "Scope") -> None:
785
796
  self.scope = parent_scope
@@ -807,7 +818,8 @@ class LogicalAndOp(Expression):
807
818
  right: Expression
808
819
 
809
820
  def __str__(self):
810
- return f"{self.left} && {self.right}"
821
+ p = _prec(self)
822
+ return f"{_lp(self.left, p)} && {_rp(self.right, p)}"
811
823
 
812
824
  def build_scope(self, parent_scope: "Scope") -> None:
813
825
  self.scope = parent_scope
@@ -835,7 +847,8 @@ class LogicalOrOp(Expression):
835
847
  right: Expression
836
848
 
837
849
  def __str__(self):
838
- return f"{self.left} || {self.right}"
850
+ p = _prec(self)
851
+ return f"{_lp(self.left, p)} || {_rp(self.right, p)}"
839
852
 
840
853
  def build_scope(self, parent_scope: "Scope") -> None:
841
854
  self.scope = parent_scope
@@ -861,7 +874,7 @@ class LogicalNotOp(Expression):
861
874
  expr: Expression
862
875
 
863
876
  def __str__(self):
864
- return f"!{self.expr}"
877
+ return f"!{_lp(self.expr, _prec(self))}"
865
878
 
866
879
  def build_scope(self, parent_scope: "Scope") -> None:
867
880
  self.scope = parent_scope
@@ -918,7 +931,8 @@ class EqualityOp(Expression):
918
931
  right: Expression
919
932
 
920
933
  def __str__(self):
921
- return f"{self.left} == {self.right}"
934
+ p = _prec(self)
935
+ return f"{_lp(self.left, p)} == {_rp(self.right, p)}"
922
936
 
923
937
  def build_scope(self, parent_scope: "Scope") -> None:
924
938
  self.scope = parent_scope
@@ -945,7 +959,8 @@ class InequalityOp(Expression):
945
959
  right: Expression
946
960
 
947
961
  def __str__(self):
948
- return f"{self.left} != {self.right}"
962
+ p = _prec(self)
963
+ return f"{_lp(self.left, p)} != {_rp(self.right, p)}"
949
964
 
950
965
  def build_scope(self, parent_scope: "Scope") -> None:
951
966
  self.scope = parent_scope
@@ -972,7 +987,8 @@ class GreaterThanOp(Expression):
972
987
  right: Expression
973
988
 
974
989
  def __str__(self):
975
- return f"{self.left} > {self.right}"
990
+ p = _prec(self)
991
+ return f"{_lp(self.left, p)} > {_rp(self.right, p)}"
976
992
 
977
993
  def build_scope(self, parent_scope: "Scope") -> None:
978
994
  self.scope = parent_scope
@@ -999,7 +1015,8 @@ class GreaterThanOrEqualOp(Expression):
999
1015
  right: Expression
1000
1016
 
1001
1017
  def __str__(self):
1002
- return f"{self.left} >= {self.right}"
1018
+ p = _prec(self)
1019
+ return f"{_lp(self.left, p)} >= {_rp(self.right, p)}"
1003
1020
 
1004
1021
  def build_scope(self, parent_scope: "Scope") -> None:
1005
1022
  self.scope = parent_scope
@@ -1026,7 +1043,8 @@ class LessThanOp(Expression):
1026
1043
  right: Expression
1027
1044
 
1028
1045
  def __str__(self):
1029
- return f"{self.left} < {self.right}"
1046
+ p = _prec(self)
1047
+ return f"{_lp(self.left, p)} < {_rp(self.right, p)}"
1030
1048
 
1031
1049
  def build_scope(self, parent_scope: "Scope") -> None:
1032
1050
  self.scope = parent_scope
@@ -1053,7 +1071,8 @@ class LessThanOrEqualOp(Expression):
1053
1071
  right: Expression
1054
1072
 
1055
1073
  def __str__(self):
1056
- return f"{self.left} <= {self.right}"
1074
+ p = _prec(self)
1075
+ return f"{_lp(self.left, p)} <= {_rp(self.right, p)}"
1057
1076
 
1058
1077
  def build_scope(self, parent_scope: "Scope") -> None:
1059
1078
  self.scope = parent_scope
@@ -1892,6 +1911,46 @@ class IncludeStatement(ASTNode):
1892
1911
  return f"include <{self.filepath.val}>"
1893
1912
 
1894
1913
 
1914
+ # ---------------------------------------------------------------------------
1915
+ # Operator precedence helpers (used by __str__ methods to emit parentheses)
1916
+ # ---------------------------------------------------------------------------
1917
+
1918
+ _PREC: dict[type, int] = {
1919
+ # loosest
1920
+ TernaryOp: 10,
1921
+ LogicalOrOp: 20,
1922
+ LogicalAndOp: 30,
1923
+ EqualityOp: 40, InequalityOp: 40,
1924
+ LessThanOp: 50, LessThanOrEqualOp: 50, GreaterThanOp: 50, GreaterThanOrEqualOp: 50,
1925
+ BitwiseOrOp: 55,
1926
+ BitwiseAndOp: 57,
1927
+ BitwiseShiftLeftOp: 58, BitwiseShiftRightOp: 58,
1928
+ AdditionOp: 60, SubtractionOp: 60,
1929
+ MultiplicationOp: 70, DivisionOp: 70, ModuloOp: 70,
1930
+ UnaryMinusOp: 80, LogicalNotOp: 80, BitwiseNotOp: 80,
1931
+ ExponentOp: 90,
1932
+ # tightest — primary expressions (literals, identifiers, calls, etc.) default to 99
1933
+ }
1934
+
1935
+
1936
+ def _prec(node) -> int:
1937
+ return _PREC.get(type(node), 99)
1938
+
1939
+
1940
+ def _lp(child, parent_prec: int) -> str:
1941
+ """Left-operand: parenthesize when child binds strictly looser than parent."""
1942
+ return f"({child})" if _prec(child) < parent_prec else str(child)
1943
+
1944
+
1945
+ def _rp(child, parent_prec: int) -> str:
1946
+ """Right-operand: parenthesize when child binds looser than OR equal to parent.
1947
+
1948
+ The stricter rule is needed for left-associative operators so that
1949
+ ``a - (b - c)`` is not flattened to ``a - b - c``.
1950
+ """
1951
+ return f"({child})" if _prec(child) <= parent_prec else str(child)
1952
+
1953
+
1895
1954
  def _collect_hoisted_declarations(nodes, scope: "Scope") -> None:
1896
1955
  """Scan a list of nodes and register assignments, functions, and modules in scope.
1897
1956
 
@@ -0,0 +1,286 @@
1
+ """Pretty-printer: convert an OpenSCAD AST back to formatted source code."""
2
+ from __future__ import annotations
3
+ from .nodes import (
4
+ ASTNode, Assignment, FunctionDeclaration, ModuleDeclaration,
5
+ UseStatement, IncludeStatement,
6
+ ModuleInstantiation,
7
+ ModularCall, ModularFor,
8
+ ModularIntersectionFor,
9
+ ModularLet, ModularEcho, ModularAssert,
10
+ ModularIf, ModularIfElse,
11
+ ModularModifierShowOnly, ModularModifierHighlight,
12
+ ModularModifierBackground, ModularModifierDisable,
13
+ CommentLine, CommentSpan,
14
+ TernaryOp,
15
+ EchoOp,
16
+ AssertOp,
17
+ LetOp,
18
+ PrimaryCall,
19
+ ListComprehension,
20
+ ListCompFor,
21
+ ListCompCFor,
22
+ ListCompLet,
23
+ )
24
+
25
+
26
+ def to_openscad(nodes: list[ASTNode], indent_width: int = 4) -> str:
27
+ """Convert a list of AST nodes to formatted OpenSCAD source code.
28
+
29
+ Args:
30
+ nodes: The AST nodes to format (top-level statements).
31
+ indent_width: Number of spaces per indentation level (default: 4).
32
+
33
+ Returns:
34
+ Formatted OpenSCAD source code as a string.
35
+ """
36
+ parts = []
37
+ prev_complex = False
38
+ for node in nodes:
39
+ is_complex = isinstance(node, (ModuleDeclaration, FunctionDeclaration))
40
+ if parts and (is_complex or prev_complex):
41
+ parts.append("")
42
+ parts.append(_fmt_node(node, 0, indent_width))
43
+ prev_complex = is_complex
44
+ return "\n".join(parts)
45
+
46
+
47
+ # Line length beyond which call arguments are formatted one-per-line.
48
+ _MULTILINE_CHAR_LIMIT = 100
49
+
50
+ # --- helpers ---
51
+
52
+ def _as_list(val) -> list:
53
+ if isinstance(val, list):
54
+ return val
55
+ if val is None:
56
+ return []
57
+ return [val]
58
+
59
+
60
+ def _join_str(items) -> str:
61
+ return ", ".join(str(i) for i in items)
62
+
63
+
64
+ def _fmt_list_elem(elem, indent: int, w: int) -> str:
65
+ """Format a list comprehension element, placing the body on a new line for for loops."""
66
+ pad = " " * indent
67
+ inner_pad = " " * (indent + w)
68
+ if isinstance(elem, ListCompFor):
69
+ assigns_inline = ", ".join(str(a) for a in elem.assignments)
70
+ body = _fmt_list_elem(elem.body, indent + w, w)
71
+ if len(f"for ({assigns_inline})") + indent > _MULTILINE_CHAR_LIMIT:
72
+ assign_lines = (",\n" + inner_pad).join(str(a) for a in elem.assignments)
73
+ return f"for (\n{inner_pad}{assign_lines}\n{pad})\n{inner_pad}{body}"
74
+ return f"for ({assigns_inline})\n{inner_pad}{body}"
75
+ if isinstance(elem, ListCompCFor):
76
+ inits = ", ".join(str(a) for a in elem.inits)
77
+ incrs = ", ".join(str(a) for a in elem.incrs)
78
+ body = _fmt_list_elem(elem.body, indent + w, w)
79
+ return f"for ({inits}; {elem.condition}; {incrs})\n{inner_pad}{body}"
80
+ if isinstance(elem, ListCompLet):
81
+ formatted = [_fmt_assign(a, indent + w, w) for a in elem.assignments]
82
+ body = _fmt_list_elem(elem.body, indent, w)
83
+ if len(formatted) > 1 or any("\n" in fa for fa in formatted):
84
+ assign_lines = (",\n" + inner_pad).join(formatted)
85
+ return f"let(\n{inner_pad}{assign_lines}\n{pad})\n{pad}{body}"
86
+ assigns = ", ".join(formatted)
87
+ return f"let({assigns}) {body}"
88
+ if isinstance(elem, LetOp):
89
+ formatted = [_fmt_assign(a, indent + w, w) for a in elem.assignments]
90
+ body = str(elem.body)
91
+ if len(formatted) > 1 or any("\n" in fa for fa in formatted):
92
+ assign_lines = (",\n" + inner_pad).join(formatted)
93
+ return f"let(\n{inner_pad}{assign_lines}\n{pad})\n{pad}{body}"
94
+ assigns = ", ".join(formatted)
95
+ return f"let({assigns}) {body}"
96
+ return str(elem)
97
+
98
+
99
+ def _fmt_multiline_args(head: str, args: list, indent: int, w: int) -> str:
100
+ """Format `head(arg1, arg2, ...)` with each arg on its own line."""
101
+ inner_pad = " " * (indent + w)
102
+ pad = " " * indent
103
+ arg_lines = (",\n" + inner_pad).join(str(a) for a in args)
104
+ return f"{head}(\n{inner_pad}{arg_lines}\n{pad})"
105
+
106
+
107
+ def _fmt_assign(assign, indent: int, w: int) -> str:
108
+ """Format an Assignment node, routing its expression through _fmt_expr."""
109
+ return f"{assign.name} = {_fmt_expr(assign.expr, indent, w)}"
110
+
111
+
112
+ def _fmt_expr(expr, indent: int, w: int) -> str:
113
+ """Format an expression with indent-aware layout for ternary, assert, and echo."""
114
+ pad = " " * indent
115
+ if isinstance(expr, TernaryOp):
116
+ pad2 = " " * (indent + 2)
117
+ return (
118
+ f"{expr.condition}\n"
119
+ f"{pad2}? {_fmt_expr(expr.true_expr, indent + 2, w)}\n"
120
+ f"{pad2}: {_fmt_expr(expr.false_expr, indent + 2, w)}"
121
+ )
122
+ if isinstance(expr, AssertOp):
123
+ args = ", ".join(str(a) for a in expr.arguments)
124
+ return f"assert({args})\n{pad}{_fmt_expr(expr.body, indent, w)}"
125
+ if isinstance(expr, EchoOp):
126
+ args = ", ".join(str(a) for a in expr.arguments)
127
+ return f"echo({args})\n{pad}{_fmt_expr(expr.body, indent, w)}"
128
+ if isinstance(expr, LetOp):
129
+ inner_pad = " " * (indent + w)
130
+ formatted = [_fmt_assign(a, indent + w, w) for a in expr.assignments]
131
+ if len(formatted) > 1 or any("\n" in fa for fa in formatted):
132
+ assign_lines = (",\n" + inner_pad).join(formatted)
133
+ return (
134
+ f"let(\n{inner_pad}{assign_lines}\n{pad})\n"
135
+ f"{pad}{_fmt_expr(expr.body, indent, w)}"
136
+ )
137
+ assigns = ", ".join(formatted)
138
+ return f"let({assigns})\n{pad}{_fmt_expr(expr.body, indent, w)}"
139
+ if isinstance(expr, PrimaryCall):
140
+ inline = str(expr)
141
+ if len(inline) + indent > _MULTILINE_CHAR_LIMIT:
142
+ return _fmt_multiline_args(str(expr.left), expr.arguments, indent, w)
143
+ if isinstance(expr, ListComprehension):
144
+ inner_pad = " " * (indent + w)
145
+ let_multiline = any(
146
+ isinstance(e, (LetOp, ListCompLet)) and (
147
+ len(e.assignments) > 1 or
148
+ any("\n" in _fmt_assign(a, indent + w + w, w) for a in e.assignments)
149
+ )
150
+ for e in expr.elements
151
+ )
152
+ if len(str(expr)) + indent > _MULTILINE_CHAR_LIMIT or let_multiline:
153
+ formatted_elems = [_fmt_list_elem(e, indent + w, w) for e in expr.elements]
154
+ items = (",\n" + inner_pad).join(formatted_elems)
155
+ return f"[\n{inner_pad}{items}\n{pad}]"
156
+ return str(expr)
157
+
158
+
159
+ def _fmt_node(node: ASTNode, indent: int, w: int) -> str:
160
+ """Format any top-level or block-body node."""
161
+ pad = " " * indent
162
+
163
+ if isinstance(node, CommentLine):
164
+ return f"{pad}//{node.text}"
165
+ if isinstance(node, CommentSpan):
166
+ return f"{pad}/*{node.text}*/"
167
+ if isinstance(node, UseStatement):
168
+ return f"{pad}use <{node.filepath.val}>"
169
+ if isinstance(node, IncludeStatement):
170
+ return f"{pad}include <{node.filepath.val}>"
171
+ if isinstance(node, Assignment):
172
+ return f"{pad}{node.name} = {_fmt_expr(node.expr, indent, w)};"
173
+ if isinstance(node, FunctionDeclaration):
174
+ head = f"{pad}function {node.name}"
175
+ params_inline = _join_str(node.parameters)
176
+ expr_pad = " " * (indent + w)
177
+ if len(f"{head}({params_inline}) =") > _MULTILINE_CHAR_LIMIT:
178
+ param_block = _fmt_multiline_args(head, node.parameters, indent, w)
179
+ return f"{param_block} =\n{expr_pad}{_fmt_expr(node.expr, indent + w, w)};"
180
+ return f"{head}({params_inline}) =\n{expr_pad}{_fmt_expr(node.expr, indent + w, w)};"
181
+ if isinstance(node, ModuleDeclaration):
182
+ head = f"{pad}module {node.name}"
183
+ params_inline = _join_str(node.parameters)
184
+ block = _fmt_block(node.children, indent, w)
185
+ if len(f"{head}({params_inline})") > _MULTILINE_CHAR_LIMIT:
186
+ param_block = _fmt_multiline_args(head, node.parameters, indent, w)
187
+ return f"{param_block} {block}"
188
+ return f"{head}({params_inline}) {block}"
189
+ if isinstance(node, ModuleInstantiation):
190
+ return _fmt_inst(node, indent, w)
191
+ return f"{pad}{node}" # pragma: no cover
192
+
193
+
194
+ def _fmt_block(nodes: list, indent: int, w: int) -> str:
195
+ """Format a list of nodes as a braced block."""
196
+ pad = " " * indent
197
+ if not nodes:
198
+ return "{}"
199
+ inner = "\n".join(_fmt_node(n, indent + w, w) for n in nodes)
200
+ return "{\n" + inner + "\n" + pad + "}"
201
+
202
+
203
+ def _fmt_child(body, indent: int, w: int) -> str:
204
+ """Format the child body of a module instantiation.
205
+
206
+ Returns the tail string appended after the header:
207
+ - ``";"`` when there are no children
208
+ - ``"\\n child;"`` for a single inline child
209
+ - ``" {\\n ...\\n}"`` for a block of multiple children
210
+ """
211
+ nodes = _as_list(body)
212
+ pad = " " * indent
213
+
214
+ if not nodes:
215
+ return ";"
216
+ if len(nodes) == 1:
217
+ return "\n" + _fmt_inst(nodes[0], indent + w, w)
218
+ inner = "\n".join(_fmt_inst(n, indent + w, w) for n in nodes)
219
+ return " {\n" + inner + "\n" + pad + "}"
220
+
221
+
222
+ def _fmt_inst(node: ModuleInstantiation, indent: int, w: int, prefix: str = "") -> str:
223
+ """Format a ModuleInstantiation node.
224
+
225
+ ``prefix`` accumulates modifier characters (``!``, ``#``, ``%``, ``*``)
226
+ so nested modifiers produce e.g. ``!#cube(10);``.
227
+ """
228
+ pad = " " * indent
229
+
230
+ # Modifiers: push prefix down to the wrapped node
231
+ if isinstance(node, ModularModifierShowOnly):
232
+ return _fmt_inst(node.child, indent, w, "!" + prefix)
233
+ if isinstance(node, ModularModifierHighlight):
234
+ return _fmt_inst(node.child, indent, w, "#" + prefix)
235
+ if isinstance(node, ModularModifierBackground):
236
+ return _fmt_inst(node.child, indent, w, "%" + prefix)
237
+ if isinstance(node, ModularModifierDisable):
238
+ return _fmt_inst(node.child, indent, w, "*" + prefix)
239
+
240
+ if isinstance(node, ModularCall):
241
+ head = f"{pad}{prefix}{node.name}"
242
+ inline = f"{head}({_join_str(node.arguments)})"
243
+ if len(inline) > _MULTILINE_CHAR_LIMIT:
244
+ call = _fmt_multiline_args(head, node.arguments, indent, w)
245
+ else:
246
+ call = inline
247
+ return call + _fmt_child(node.children, indent, w)
248
+
249
+ if isinstance(node, ModularFor):
250
+ assigns = _join_str(_as_list(node.assignments))
251
+ return f"{pad}{prefix}for ({assigns})" + _fmt_child(node.body, indent, w)
252
+
253
+ if isinstance(node, ModularIntersectionFor):
254
+ assigns = _join_str(_as_list(node.assignments))
255
+ return f"{pad}{prefix}intersection_for ({assigns})" + _fmt_child(node.body, indent, w)
256
+
257
+ if isinstance(node, ModularLet):
258
+ inner_pad = " " * (indent + w)
259
+ formatted = [_fmt_assign(a, indent + w, w) for a in _as_list(node.assignments)]
260
+ if len(formatted) > 1 or any("\n" in fa for fa in formatted):
261
+ assign_lines = (",\n" + inner_pad).join(formatted)
262
+ tail = _fmt_child(node.children, indent, w)
263
+ return f"{pad}{prefix}let (\n{inner_pad}{assign_lines}\n{pad}){tail}"
264
+ assigns = ", ".join(formatted)
265
+ return f"{pad}{prefix}let ({assigns})" + _fmt_child(node.children, indent, w)
266
+
267
+ if isinstance(node, ModularEcho):
268
+ args = _join_str(node.arguments)
269
+ return f"{pad}{prefix}echo({args})" + _fmt_child(node.children, indent, w)
270
+
271
+ if isinstance(node, ModularAssert):
272
+ args = _join_str(node.arguments)
273
+ return f"{pad}{prefix}assert({args})" + _fmt_child(node.children, indent, w)
274
+
275
+ if isinstance(node, ModularIf):
276
+ header = f"{pad}{prefix}if ({node.condition})"
277
+ return header + _fmt_child(node.true_branch, indent, w)
278
+
279
+ if isinstance(node, ModularIfElse):
280
+ header = f"{pad}{prefix}if ({node.condition})"
281
+ true_tail = _fmt_child(node.true_branch, indent, w)
282
+ false_tail = _fmt_child(node.false_branch, indent, w)
283
+ connector = " else" if true_tail.startswith(" {") else f"\n{pad}else"
284
+ return header + true_tail + connector + false_tail
285
+
286
+ return f"{pad}{prefix}{node};" # pragma: no cover
@@ -1,159 +0,0 @@
1
- """Pretty-printer: convert an OpenSCAD AST back to formatted source code."""
2
- from __future__ import annotations
3
- from .nodes import (
4
- ASTNode, Assignment, FunctionDeclaration, ModuleDeclaration,
5
- UseStatement, IncludeStatement,
6
- ModuleInstantiation,
7
- ModularCall, ModularFor,
8
- ModularIntersectionFor,
9
- ModularLet, ModularEcho, ModularAssert,
10
- ModularIf, ModularIfElse,
11
- ModularModifierShowOnly, ModularModifierHighlight,
12
- ModularModifierBackground, ModularModifierDisable,
13
- CommentLine, CommentSpan,
14
- )
15
-
16
-
17
- def to_openscad(nodes: list[ASTNode], indent_width: int = 4) -> str:
18
- """Convert a list of AST nodes to formatted OpenSCAD source code.
19
-
20
- Args:
21
- nodes: The AST nodes to format (top-level statements).
22
- indent_width: Number of spaces per indentation level (default: 4).
23
-
24
- Returns:
25
- Formatted OpenSCAD source code as a string.
26
- """
27
- parts = []
28
- prev_complex = False
29
- for node in nodes:
30
- is_complex = isinstance(node, (ModuleDeclaration, FunctionDeclaration))
31
- if parts and (is_complex or prev_complex):
32
- parts.append("")
33
- parts.append(_fmt_node(node, 0, indent_width))
34
- prev_complex = is_complex
35
- return "\n".join(parts)
36
-
37
-
38
- # --- helpers ---
39
-
40
- def _as_list(val) -> list:
41
- if isinstance(val, list):
42
- return val
43
- if val is None:
44
- return []
45
- return [val]
46
-
47
-
48
- def _join_str(items) -> str:
49
- return ", ".join(str(i) for i in items)
50
-
51
-
52
- def _fmt_node(node: ASTNode, indent: int, w: int) -> str:
53
- """Format any top-level or block-body node."""
54
- pad = " " * indent
55
-
56
- if isinstance(node, CommentLine):
57
- return f"{pad}//{node.text}"
58
- if isinstance(node, CommentSpan):
59
- return f"{pad}/*{node.text}*/"
60
- if isinstance(node, UseStatement):
61
- return f"{pad}use <{node.filepath.val}>"
62
- if isinstance(node, IncludeStatement):
63
- return f"{pad}include <{node.filepath.val}>"
64
- if isinstance(node, Assignment):
65
- return f"{pad}{node.name} = {node.expr};"
66
- if isinstance(node, FunctionDeclaration):
67
- params = _join_str(node.parameters)
68
- return f"{pad}function {node.name}({params}) = {node.expr};"
69
- if isinstance(node, ModuleDeclaration):
70
- params = _join_str(node.parameters)
71
- block = _fmt_block(node.children, indent, w)
72
- return f"{pad}module {node.name}({params}) {block}"
73
- if isinstance(node, ModuleInstantiation):
74
- return _fmt_inst(node, indent, w)
75
- return f"{pad}{node}" # pragma: no cover
76
-
77
-
78
- def _fmt_block(nodes: list, indent: int, w: int) -> str:
79
- """Format a list of nodes as a braced block."""
80
- pad = " " * indent
81
- if not nodes:
82
- return "{}"
83
- inner = "\n".join(_fmt_node(n, indent + w, w) for n in nodes)
84
- return "{\n" + inner + "\n" + pad + "}"
85
-
86
-
87
- def _fmt_child(body, indent: int, w: int) -> str:
88
- """Format the child body of a module instantiation.
89
-
90
- Returns the tail string appended after the header:
91
- - ``";"`` when there are no children
92
- - ``"\\n child;"`` for a single inline child
93
- - ``" {\\n ...\\n}"`` for a block of multiple children
94
- """
95
- nodes = _as_list(body)
96
- pad = " " * indent
97
-
98
- if not nodes:
99
- return ";"
100
- if len(nodes) == 1:
101
- return "\n" + _fmt_inst(nodes[0], indent + w, w)
102
- inner = "\n".join(_fmt_inst(n, indent + w, w) for n in nodes)
103
- return " {\n" + inner + "\n" + pad + "}"
104
-
105
-
106
- def _fmt_inst(node: ModuleInstantiation, indent: int, w: int, prefix: str = "") -> str:
107
- """Format a ModuleInstantiation node.
108
-
109
- ``prefix`` accumulates modifier characters (``!``, ``#``, ``%``, ``*``)
110
- so nested modifiers produce e.g. ``!#cube(10);``.
111
- """
112
- pad = " " * indent
113
-
114
- # Modifiers: push prefix down to the wrapped node
115
- if isinstance(node, ModularModifierShowOnly):
116
- return _fmt_inst(node.child, indent, w, "!" + prefix)
117
- if isinstance(node, ModularModifierHighlight):
118
- return _fmt_inst(node.child, indent, w, "#" + prefix)
119
- if isinstance(node, ModularModifierBackground):
120
- return _fmt_inst(node.child, indent, w, "%" + prefix)
121
- if isinstance(node, ModularModifierDisable):
122
- return _fmt_inst(node.child, indent, w, "*" + prefix)
123
-
124
- if isinstance(node, ModularCall):
125
- args = _join_str(node.arguments)
126
- return f"{pad}{prefix}{node.name}({args})" + _fmt_child(node.children, indent, w)
127
-
128
- if isinstance(node, ModularFor):
129
- assigns = _join_str(_as_list(node.assignments))
130
- return f"{pad}{prefix}for ({assigns})" + _fmt_child(node.body, indent, w)
131
-
132
- if isinstance(node, ModularIntersectionFor):
133
- assigns = _join_str(_as_list(node.assignments))
134
- return f"{pad}{prefix}intersection_for ({assigns})" + _fmt_child(node.body, indent, w)
135
-
136
- if isinstance(node, ModularLet):
137
- assigns = _join_str(_as_list(node.assignments))
138
- return f"{pad}{prefix}let ({assigns})" + _fmt_child(node.children, indent, w)
139
-
140
- if isinstance(node, ModularEcho):
141
- args = _join_str(node.arguments)
142
- return f"{pad}{prefix}echo({args})" + _fmt_child(node.children, indent, w)
143
-
144
- if isinstance(node, ModularAssert):
145
- args = _join_str(node.arguments)
146
- return f"{pad}{prefix}assert({args})" + _fmt_child(node.children, indent, w)
147
-
148
- if isinstance(node, ModularIf):
149
- header = f"{pad}{prefix}if ({node.condition})"
150
- return header + _fmt_child(node.true_branch, indent, w)
151
-
152
- if isinstance(node, ModularIfElse):
153
- header = f"{pad}{prefix}if ({node.condition})"
154
- true_tail = _fmt_child(node.true_branch, indent, w)
155
- false_tail = _fmt_child(node.false_branch, indent, w)
156
- connector = " else" if true_tail.startswith(" {") else f"\n{pad}else"
157
- return header + true_tail + connector + false_tail
158
-
159
- return f"{pad}{prefix}{node};" # pragma: no cover