openscad-parser 2.6.1__tar.gz → 3.0.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.6.1
3
+ Version: 3.0.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
@@ -184,12 +184,39 @@ Use ``getASTfromLibraryFile()`` to find and parse library files using OpenSCAD's
184
184
  The function searches for library files in this order:
185
185
 
186
186
  1. Directory of the current file (if provided)
187
- 2. Directories in the ``OPENSCADPATH`` environment variable
188
- 3. Platform-specific default library directories:
189
- - Windows: ``~/Documents/OpenSCAD/libraries``
187
+ 2. Directories in the ``OPENSCADPATH`` environment variable, which add to the
188
+ directories below rather than replacing them, as in OpenSCAD
189
+ 3. The platform's libraries folder:
190
+
191
+ - Windows: ``OpenSCAD\libraries`` in the Documents folder Windows reports
192
+ (under OneDrive when Known Folder Move is on)
190
193
  - macOS: ``~/Documents/OpenSCAD/libraries``
191
194
  - Linux: ``~/.local/share/OpenSCAD/libraries``
192
195
 
196
+ 4. A ``libraries`` directory beside the installed package
197
+
198
+ ``librarySearchDirs(currfile)`` returns this list, and a not-found error names
199
+ every directory in it.
200
+
201
+ Strict Commas
202
+ ~~~~~~~~~~~~~
203
+
204
+ OpenSCAD accepts a trailing comma almost everywhere. OpenSCAD 2021.01 did not:
205
+ it rejected one after a call's last argument (``cube(1,)``) and after the last
206
+ assignment of a ``let``/``for``/``intersection_for`` (``let(x=1,)``), while
207
+ accepting it in list literals, list comprehensions and parameter lists. To
208
+ parse as 2021.01 did:
209
+
210
+ .. code-block:: python
211
+
212
+ from openscad_parser import strict_commas
213
+
214
+ with strict_commas():
215
+ ast = getASTfromFile("model.scad") # None if it uses a rejected comma
216
+
217
+ The mode nests, restores on exit, and every AST cache keys on it, so a strict
218
+ parse is never served a lenient tree.
219
+
193
220
  Advanced AST Generation
194
221
  ~~~~~~~~~~~~~~~~~~~~~~~~
195
222
 
@@ -432,6 +459,7 @@ Expressions
432
459
  - ``AssertOp(arguments: list[Argument], body: Expression)``: assert clause ``assert(arguments) body``
433
460
  - ``FunctionLiteral(parameters: list[ParameterDeclaration], body: Expression)``: Anonymous function expression ``function(parameters) body``
434
461
  - ``PrimaryCall(left: Expression, arguments: list[Argument])``: Function calls ``left(arguments)``
462
+ - ``RenderExpression(arguments: list[Argument], children: list[ModuleInstantiation])``: Geometry measured as a value ``render(arguments) { children }``. Braces required; ``render`` stays usable as an ordinary name. Not part of upstream OpenSCAD
435
463
  - ``PrimaryIndex(left: Expression, index: Expression)``: Array indexing ``left[index]``
436
464
  - ``PrimaryMember(left: Expression, member: Identifier)``: Member access ``left.member``
437
465
 
@@ -535,7 +563,7 @@ Main Functions
535
563
 
536
564
  ``getASTfromLibraryFile(currfile: str, libfile: str, include_comments: bool = False, process_includes: bool = True)``
537
565
  Find and parse an OpenSCAD library file using OpenSCAD's search path rules.
538
- Searches in: current file directory, OPENSCADPATH, and platform default paths.
566
+ Searches the directories ``librarySearchDirs(currfile)`` returns.
539
567
 
540
568
  :param currfile: Full path to the current OpenSCAD file (can be empty string)
541
569
  :param libfile: Partial or full path to the library file to find
@@ -565,11 +593,16 @@ Main Functions
565
593
  This function removes all cached AST trees from memory.
566
594
 
567
595
  ``build_scopes(ast: list[ASTNode]) -> Scope``
568
- Build a scope tree over an AST and attach a ``scope`` attribute to every node.
596
+ Build a scope tree over an AST, recording every node's scope in a ``ScopeTable``
597
+ hung on the root (``root.table``). Read a node's scope with ``root.scope_of(node)``.
569
598
 
570
599
  :param ast: A list of top-level AST nodes (as returned by the ``getAST*`` functions)
571
600
  :returns: The root ``Scope`` object
572
601
 
602
+ ``build_scopes_into(ast: list[ASTNode], table: ScopeTable) -> Scope``
603
+ The same, recording into a ``ScopeTable`` the caller owns, so several roots (one per
604
+ ``use``\ d file, say) can be read back through one table.
605
+
573
606
  ``Scope``
574
607
  Represents a lexical scope with three independent namespaces (variables, functions,
575
608
  modules), mirroring OpenSCAD's scoping rules.
@@ -1006,14 +1039,21 @@ names according to OpenSCAD's three-namespace scoping rules::
1006
1039
  print(root_scope.lookup_variable("x")) # Assignment node
1007
1040
  print(root_scope.lookup_module("box")) # ModuleDeclaration node
1008
1041
 
1009
- # Each AST node has a .scope attribute pointing to its enclosing scope
1042
+ # Every node's enclosing scope, as this pass built it
1010
1043
  box_decl = ast[1]
1011
1044
  cube_call = box_decl.children[0]
1012
- print(cube_call.scope.lookup_variable("size")) # ParameterDeclaration node
1013
-
1014
- ``build_scopes(ast)`` returns the root ``Scope`` object and attaches a ``scope`` attribute
1015
- to every node in the tree. Scopes form a parent chain so lookups fall through to enclosing
1016
- scopes automatically. Declarations (variables, functions, modules) inside a block are
1045
+ print(root_scope.scope_of(cube_call).lookup_variable("size")) # ParameterDeclaration node
1046
+
1047
+ ``build_scopes(ast)`` returns the root ``Scope`` object and records the scope of every node
1048
+ in the tree in ``root_scope.table``. Scopes form a parent chain so lookups fall through to
1049
+ enclosing scopes automatically.
1050
+
1051
+ A node's scope is not an attribute of the node. An included file is parsed once and its
1052
+ nodes are shared by every file that includes it, but ``include`` puts them in the
1053
+ *includer's* scope, so the same node sits in a different scope for each includer. Each
1054
+ ``build_scopes()`` call therefore has its own table, and two files that include the same
1055
+ library can be scoped, and used, at the same time. (Before 3.0, ``node.scope`` held the
1056
+ scope of whichever file had built its scopes last.) Declarations (variables, functions, modules) inside a block are
1017
1057
  hoisted to the top of that block's scope before child nodes are visited.
1018
1058
 
1019
1059
  Testing
@@ -141,12 +141,39 @@ Use ``getASTfromLibraryFile()`` to find and parse library files using OpenSCAD's
141
141
  The function searches for library files in this order:
142
142
 
143
143
  1. Directory of the current file (if provided)
144
- 2. Directories in the ``OPENSCADPATH`` environment variable
145
- 3. Platform-specific default library directories:
146
- - Windows: ``~/Documents/OpenSCAD/libraries``
144
+ 2. Directories in the ``OPENSCADPATH`` environment variable, which add to the
145
+ directories below rather than replacing them, as in OpenSCAD
146
+ 3. The platform's libraries folder:
147
+
148
+ - Windows: ``OpenSCAD\libraries`` in the Documents folder Windows reports
149
+ (under OneDrive when Known Folder Move is on)
147
150
  - macOS: ``~/Documents/OpenSCAD/libraries``
148
151
  - Linux: ``~/.local/share/OpenSCAD/libraries``
149
152
 
153
+ 4. A ``libraries`` directory beside the installed package
154
+
155
+ ``librarySearchDirs(currfile)`` returns this list, and a not-found error names
156
+ every directory in it.
157
+
158
+ Strict Commas
159
+ ~~~~~~~~~~~~~
160
+
161
+ OpenSCAD accepts a trailing comma almost everywhere. OpenSCAD 2021.01 did not:
162
+ it rejected one after a call's last argument (``cube(1,)``) and after the last
163
+ assignment of a ``let``/``for``/``intersection_for`` (``let(x=1,)``), while
164
+ accepting it in list literals, list comprehensions and parameter lists. To
165
+ parse as 2021.01 did:
166
+
167
+ .. code-block:: python
168
+
169
+ from openscad_parser import strict_commas
170
+
171
+ with strict_commas():
172
+ ast = getASTfromFile("model.scad") # None if it uses a rejected comma
173
+
174
+ The mode nests, restores on exit, and every AST cache keys on it, so a strict
175
+ parse is never served a lenient tree.
176
+
150
177
  Advanced AST Generation
151
178
  ~~~~~~~~~~~~~~~~~~~~~~~~
152
179
 
@@ -389,6 +416,7 @@ Expressions
389
416
  - ``AssertOp(arguments: list[Argument], body: Expression)``: assert clause ``assert(arguments) body``
390
417
  - ``FunctionLiteral(parameters: list[ParameterDeclaration], body: Expression)``: Anonymous function expression ``function(parameters) body``
391
418
  - ``PrimaryCall(left: Expression, arguments: list[Argument])``: Function calls ``left(arguments)``
419
+ - ``RenderExpression(arguments: list[Argument], children: list[ModuleInstantiation])``: Geometry measured as a value ``render(arguments) { children }``. Braces required; ``render`` stays usable as an ordinary name. Not part of upstream OpenSCAD
392
420
  - ``PrimaryIndex(left: Expression, index: Expression)``: Array indexing ``left[index]``
393
421
  - ``PrimaryMember(left: Expression, member: Identifier)``: Member access ``left.member``
394
422
 
@@ -492,7 +520,7 @@ Main Functions
492
520
 
493
521
  ``getASTfromLibraryFile(currfile: str, libfile: str, include_comments: bool = False, process_includes: bool = True)``
494
522
  Find and parse an OpenSCAD library file using OpenSCAD's search path rules.
495
- Searches in: current file directory, OPENSCADPATH, and platform default paths.
523
+ Searches the directories ``librarySearchDirs(currfile)`` returns.
496
524
 
497
525
  :param currfile: Full path to the current OpenSCAD file (can be empty string)
498
526
  :param libfile: Partial or full path to the library file to find
@@ -522,11 +550,16 @@ Main Functions
522
550
  This function removes all cached AST trees from memory.
523
551
 
524
552
  ``build_scopes(ast: list[ASTNode]) -> Scope``
525
- Build a scope tree over an AST and attach a ``scope`` attribute to every node.
553
+ Build a scope tree over an AST, recording every node's scope in a ``ScopeTable``
554
+ hung on the root (``root.table``). Read a node's scope with ``root.scope_of(node)``.
526
555
 
527
556
  :param ast: A list of top-level AST nodes (as returned by the ``getAST*`` functions)
528
557
  :returns: The root ``Scope`` object
529
558
 
559
+ ``build_scopes_into(ast: list[ASTNode], table: ScopeTable) -> Scope``
560
+ The same, recording into a ``ScopeTable`` the caller owns, so several roots (one per
561
+ ``use``\ d file, say) can be read back through one table.
562
+
530
563
  ``Scope``
531
564
  Represents a lexical scope with three independent namespaces (variables, functions,
532
565
  modules), mirroring OpenSCAD's scoping rules.
@@ -963,14 +996,21 @@ names according to OpenSCAD's three-namespace scoping rules::
963
996
  print(root_scope.lookup_variable("x")) # Assignment node
964
997
  print(root_scope.lookup_module("box")) # ModuleDeclaration node
965
998
 
966
- # Each AST node has a .scope attribute pointing to its enclosing scope
999
+ # Every node's enclosing scope, as this pass built it
967
1000
  box_decl = ast[1]
968
1001
  cube_call = box_decl.children[0]
969
- print(cube_call.scope.lookup_variable("size")) # ParameterDeclaration node
970
-
971
- ``build_scopes(ast)`` returns the root ``Scope`` object and attaches a ``scope`` attribute
972
- to every node in the tree. Scopes form a parent chain so lookups fall through to enclosing
973
- scopes automatically. Declarations (variables, functions, modules) inside a block are
1002
+ print(root_scope.scope_of(cube_call).lookup_variable("size")) # ParameterDeclaration node
1003
+
1004
+ ``build_scopes(ast)`` returns the root ``Scope`` object and records the scope of every node
1005
+ in the tree in ``root_scope.table``. Scopes form a parent chain so lookups fall through to
1006
+ enclosing scopes automatically.
1007
+
1008
+ A node's scope is not an attribute of the node. An included file is parsed once and its
1009
+ nodes are shared by every file that includes it, but ``include`` puts them in the
1010
+ *includer's* scope, so the same node sits in a different scope for each includer. Each
1011
+ ``build_scopes()`` call therefore has its own table, and two files that include the same
1012
+ library can be scoped, and used, at the same time. (Before 3.0, ``node.scope`` held the
1013
+ scope of whichever file had built its scopes last.) Declarations (variables, functions, modules) inside a block are
974
1014
  hoisted to the top of that block's scope before child nodes are visited.
975
1015
 
976
1016
  Testing
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "openscad_parser"
7
- version = "2.6.1"
7
+ version = "3.0.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 = [
@@ -5,7 +5,7 @@
5
5
  from __future__ import unicode_literals
6
6
 
7
7
  from arpeggio import ParserPython
8
- from .grammar import openscad_language, openscad_language_with_comments, comment, whitespace_only
8
+ from .grammar import openscad_language, openscad_language_with_comments, comment, whitespace_only, strict_commas
9
9
 
10
10
 
11
11
  # --- The parser ---
@@ -1,3 +1,4 @@
1
+ import functools
1
2
  import hashlib
2
3
  import json
3
4
  import os
@@ -5,7 +6,8 @@ import pickle
5
6
  import platform
6
7
  from typing import Optional
7
8
  from arpeggio import NoMatch
8
- from openscad_parser import getOpenSCADParser
9
+ from openscad_parser import getOpenSCADParser, strict_commas
10
+ from openscad_parser.grammar import _STRICT_COMMAS
9
11
  from .source_map import SourceMap, process_includes as process_includes_func
10
12
 
11
13
  # Import all AST nodes from nodes
@@ -54,6 +56,7 @@ from .nodes import (
54
56
  LessThanOrEqualOp,
55
57
  FunctionLiteral,
56
58
  PrimaryCall,
59
+ RenderExpression,
57
60
  PrimaryIndex,
58
61
  PrimaryMember,
59
62
  VectorElement,
@@ -87,7 +90,7 @@ from .nodes import (
87
90
  from .builder import ASTBuilderVisitor, Position
88
91
 
89
92
  # Import scope classes
90
- from .scope import Scope, build_scopes
93
+ from .scope import Scope, ScopeTable, build_scopes, build_scopes_into
91
94
 
92
95
  # Import pretty-printer
93
96
  from .pretty_print import to_openscad
@@ -105,57 +108,82 @@ from .serialization import (
105
108
 
106
109
  # --- AST convenience functions ---
107
110
 
111
+ def _windows_documents_dir() -> str:
112
+ """Where Windows says My Documents is -- the same SHGetFolderPathW(
113
+ CSIDL_PERSONAL, SHGFP_TYPE_CURRENT) call OpenSCAD makes -- rather than
114
+ assuming ~/Documents: OneDrive's Known Folder Move, on by default, puts
115
+ it at ~/OneDrive/Documents instead."""
116
+ try:
117
+ import ctypes
118
+ buf = ctypes.create_unicode_buffer(260)
119
+ if ctypes.windll.shell32.SHGetFolderPathW(None, 5, None, 0, buf) == 0: # CSIDL_PERSONAL, SHGFP_TYPE_CURRENT
120
+ return buf.value
121
+ except (AttributeError, OSError): # not actually on Windows
122
+ pass
123
+ return os.path.join(os.path.expanduser("~"), "Documents")
124
+
125
+
126
+ # Libraries shipped beside this package -- OpenSCAD's resourcePath("libraries").
127
+ _BUNDLED_LIBRARY_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "libraries")
128
+
129
+
130
+ def librarySearchDirs(currfile: str) -> list[str]:
131
+ """The directories `include`/`use` search, in order, as OpenSCAD's
132
+ parser_init() builds them: the including file's own directory, then
133
+ every OPENSCADPATH entry, then the user's libraries folder, then
134
+ libraries shipped beside this package.
135
+
136
+ OPENSCADPATH adds to the libraries folder rather than replacing it --
137
+ it used to replace it, so setting it for one library hid every other
138
+ one, a BOSL2 in the default folder included (openscad_cpp_parser #10).
139
+ """
140
+ dirs = []
141
+ if currfile:
142
+ dirs.append(os.path.dirname(os.path.abspath(currfile)))
143
+
144
+ system = platform.system()
145
+ pathsep = ";" if system == "Windows" else ":"
146
+ for path in os.getenv("OPENSCADPATH", "").split(pathsep):
147
+ expanded_path = os.path.expandvars(path)
148
+ if expanded_path:
149
+ dirs.append(expanded_path)
150
+
151
+ if system == "Windows":
152
+ dirs.append(os.path.join(_windows_documents_dir(), "OpenSCAD", "libraries"))
153
+ elif system == "Darwin":
154
+ dirs.append(os.path.expanduser("~/Documents/OpenSCAD/libraries"))
155
+ elif system == "Linux":
156
+ dirs.append(os.path.expanduser("~/.local/share/OpenSCAD/libraries"))
157
+ # ponytail: only the folder beside the package, no ../share/openscad walk
158
+ dirs.append(_BUNDLED_LIBRARY_DIR)
159
+ return dirs
160
+
161
+
108
162
  def findLibraryFile(currfile: str, libfile: str) -> Optional[str]:
109
- """Find a library file using OpenSCAD's search path rules.
110
-
111
- Searches for the library file in the following order:
112
- 1. Directory of the current file (if currfile is provided)
113
- 2. Directories specified in OPENSCADPATH environment variable
114
- 3. Platform-specific default library directories
115
-
163
+ """Find a library file using OpenSCAD's search path rules: the first
164
+ directory in librarySearchDirs(currfile) that holds it.
165
+
116
166
  Args:
117
167
  currfile: Full path to the current OpenSCAD file (can be empty string)
118
168
  libfile: Partial or full path to the library file to find
119
-
169
+
120
170
  Returns:
121
171
  Full path to the found library file, or None if not found
122
172
  """
123
- dirs = []
124
-
125
- # Add directory of current file if provided
126
- if currfile:
127
- dirs.append(os.path.dirname(os.path.abspath(currfile)))
128
-
129
- # Determine path separator and default path based on platform
130
- pathsep = ":"
131
- dflt_path = ""
132
- system = platform.system()
133
-
134
- if system == "Windows": # pragma: no cover
135
- dflt_path = os.path.join(os.path.expanduser("~"), "Documents", "OpenSCAD", "libraries")
136
- pathsep = ";"
137
- elif system == "Darwin": # pragma: no cover
138
- dflt_path = os.path.expanduser("~/Documents/OpenSCAD/libraries")
139
- elif system == "Linux": # pragma: no cover
140
- dflt_path = os.path.expanduser("~/.local/share/OpenSCAD/libraries")
141
-
142
- # Get OPENSCADPATH from environment or use default
143
- env = os.getenv("OPENSCADPATH", dflt_path)
144
- if env:
145
- for path in env.split(pathsep):
146
- expanded_path = os.path.expandvars(path)
147
- if expanded_path:
148
- dirs.append(expanded_path)
149
-
150
- # Search for the file in each directory
151
- for d in dirs:
173
+ for d in librarySearchDirs(currfile):
152
174
  test_file = os.path.join(d, libfile)
153
175
  if os.path.isfile(test_file):
154
176
  return test_file
155
-
156
177
  return None
157
178
 
158
179
 
180
+ def _not_found(what: str, filename: str, currfile: str) -> str:
181
+ """A not-found message that lists every directory searched. Naming only
182
+ the includer read as "only there was searched"."""
183
+ return f"{what} '{filename}' not found. Searched:" + "".join(
184
+ f"\n {d}" for d in librarySearchDirs(currfile))
185
+
186
+
159
187
  # Alias for backward compatibility (test_ast_convenience.py imports _find_library_file)
160
188
  _find_library_file = findLibraryFile
161
189
 
@@ -257,14 +285,15 @@ def getASTfromString(code: str, include_comments: bool = False, origin: str = "<
257
285
 
258
286
 
259
287
  # Module-level in-memory cache for per-file AST trees (no includes resolved)
260
- # Key: tuple of (absolute file path (str), include_comments (bool))
288
+ # Key: tuple of (absolute file path (str), include_comments (bool), strict_commas (bool))
261
289
  # Value: tuple of (AST nodes, modification timestamp)
262
- _ast_cache: dict[tuple[str, bool], tuple[list[ASTNode] | None, float]] = {}
290
+ _ast_cache: dict[tuple[str, bool, bool], tuple[list[ASTNode] | None, float]] = {}
263
291
 
264
292
  # Resolved (includes-expanded) cache
265
- # Key: tuple of (absolute file path (str), include_comments (bool), process_includes (bool))
293
+ # Key: tuple of (absolute file path (str), include_comments (bool), process_includes (bool),
294
+ # strict_commas (bool))
266
295
  # Value: tuple of (AST nodes, modification timestamp)
267
- _resolved_cache: dict[tuple[str, bool, bool], tuple[list[ASTNode] | None, float]] = {}
296
+ _resolved_cache: dict[tuple[str, bool, bool, bool], tuple[list[ASTNode] | None, float]] = {}
268
297
 
269
298
 
270
299
  def _get_disk_cache_dir() -> Optional[str]:
@@ -285,12 +314,26 @@ def _get_disk_cache_dir() -> Optional[str]:
285
314
  return None
286
315
 
287
316
 
317
+ @functools.lru_cache(maxsize=None)
318
+ def _ast_format_tag() -> str:
319
+ """Changes whenever the grammar or the AST classes do, so a pickled AST
320
+ from another version is never served: an older pickle lacks any field
321
+ added since (RangeLiteral.implicit_step, say) and reads its default."""
322
+ here = os.path.dirname(os.path.abspath(__file__))
323
+ h = hashlib.sha256()
324
+ for path in (os.path.join(here, "..", "grammar.py"), os.path.join(here, "nodes.py"),
325
+ os.path.join(here, "builder.py"), os.path.join(here, "__init__.py")):
326
+ with open(path, "rb") as f:
327
+ h.update(f.read())
328
+ return h.hexdigest()[:16]
329
+
330
+
288
331
  def _disk_cache_path(file_path: str, include_comments: bool) -> Optional[str]:
289
332
  """Get the disk cache file path for a given source file."""
290
333
  cache_dir = _get_disk_cache_dir()
291
334
  if not cache_dir:
292
335
  return None # pragma: no cover
293
- key = f"{file_path}:{include_comments}"
336
+ key = f"{file_path}:{include_comments}:{_STRICT_COMMAS.get()}:{_ast_format_tag()}"
294
337
  h = hashlib.sha256(key.encode()).hexdigest()[:16]
295
338
  return os.path.join(cache_dir, f"{h}.pickle")
296
339
 
@@ -429,7 +472,7 @@ def _parse_single_file(file_path: str, include_comments: bool = False) -> list[A
429
472
  raise FileNotFoundError(f"File {file_path} not found")
430
473
 
431
474
  current_mtime = os.path.getmtime(file_path)
432
- cache_key = (file_path, include_comments)
475
+ cache_key = (file_path, include_comments, _STRICT_COMMAS.get())
433
476
 
434
477
  # Check in-memory cache
435
478
  if cache_key in _ast_cache:
@@ -475,10 +518,7 @@ def _resolve_includes(ast_nodes: list[ASTNode] | None, current_file: str,
475
518
  filename = node.filepath.val
476
519
  lib_file = findLibraryFile(current_file, filename)
477
520
  if lib_file is None:
478
- raise FileNotFoundError(
479
- f"Included file '{filename}' not found. "
480
- f"Searched relative to: {current_file if current_file else 'current directory'}"
481
- )
521
+ raise FileNotFoundError(_not_found("Included file", filename, current_file))
482
522
  lib_file = os.path.abspath(lib_file)
483
523
  if lib_file in visited:
484
524
  continue
@@ -550,7 +590,7 @@ def getASTfromFile(file: str, include_comments: bool = False, process_includes:
550
590
  return _parse_single_file(file_path, include_comments)
551
591
 
552
592
  # Check resolved cache (in-memory only since resolved ASTs depend on multiple files)
553
- resolved_key = (file_path, include_comments, True)
593
+ resolved_key = (file_path, include_comments, True, _STRICT_COMMAS.get())
554
594
  if resolved_key in _resolved_cache:
555
595
  cached_ast, cached_mtime = _resolved_cache[resolved_key]
556
596
  if cached_mtime == current_mtime:
@@ -574,13 +614,9 @@ def getASTfromLibraryFile(currfile: str, libfile: str, include_comments: bool =
574
614
  Find and parse an OpenSCAD library file using OpenSCAD's search path rules,
575
615
  and return both the AST and absolute path to the file.
576
616
 
577
- This function searches for the library file in the following order:
578
- 1. Directory of the current file (if currfile is provided)
579
- 2. Directories specified in OPENSCADPATH environment variable
580
- 3. Platform-specific default library directories:
581
- - Windows: ~/Documents/OpenSCAD/libraries
582
- - macOS: ~/Documents/OpenSCAD/libraries
583
- - Linux: ~/.local/share/OpenSCAD/libraries
617
+ This function searches the directories librarySearchDirs(currfile)
618
+ returns, in order: the current file's directory, OPENSCADPATH, the
619
+ platform's libraries folder, then libraries beside this package.
584
620
 
585
621
  Once found, the file is parsed using getASTfromFile(), which includes
586
622
  caching support and include processing.
@@ -624,10 +660,7 @@ def getASTfromLibraryFile(currfile: str, libfile: str, include_comments: bool =
624
660
  found_file = findLibraryFile(currfile, libfile)
625
661
 
626
662
  if found_file is None:
627
- raise FileNotFoundError(
628
- f"Library file '{libfile}' not found in search paths. "
629
- f"Searched in: current file directory, OPENSCADPATH, and platform default paths."
630
- )
663
+ raise FileNotFoundError(_not_found("Library file", libfile, currfile))
631
664
 
632
665
  # Use getASTfromFile() which includes caching and include processing
633
666
  ast = getASTfromFile(found_file, include_comments=include_comments, process_includes=process_includes)
@@ -984,6 +984,13 @@ class ASTBuilderVisitor(PTNodeVisitor):
984
984
  def visit_primary(self, node, children):
985
985
  return children[0]
986
986
 
987
+ def visit_render_expr(self, node, children):
988
+ # Grammar: (KWD_RENDER, TOK_PAREN, arguments, TOK_ENDPAREN, statement_block)
989
+ arguments = children.get_rule("arguments") or []
990
+ block = children.get_rule("statement_block") or []
991
+ return RenderExpression(arguments=list(arguments), children=list(block),
992
+ position=self._get_node_position(node))
993
+
987
994
  def visit_range_expr(self, node, children):
988
995
  # OpenSCAD syntax: [start:end] or [start:step:end]
989
996
  start = children[0]
@@ -993,7 +1000,8 @@ class ASTBuilderVisitor(PTNodeVisitor):
993
1000
  else:
994
1001
  end = children[1]
995
1002
  step = NumberLiteral(val=1.0, position=self._get_node_position(node))
996
- return RangeLiteral(start=start, end=end, step=step, position=self._get_node_position(node))
1003
+ return RangeLiteral(start=start, end=end, step=step, implicit_step=len(children) <= 2,
1004
+ position=self._get_node_position(node))
997
1005
 
998
1006
  def visit_vector_expr(self, node, children):
999
1007
  elements = children if children else []