openscad-parser 3.0.1__tar.gz → 3.1.0__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.
- {openscad_parser-3.0.1 → openscad_parser-3.1.0}/PKG-INFO +1 -1
- {openscad_parser-3.0.1 → openscad_parser-3.1.0}/pyproject.toml +1 -1
- {openscad_parser-3.0.1 → openscad_parser-3.1.0}/src/openscad_parser/__init__.py +4 -1
- {openscad_parser-3.0.1 → openscad_parser-3.1.0}/src/openscad_parser/ast/__init__.py +42 -17
- {openscad_parser-3.0.1 → openscad_parser-3.1.0}/src/openscad_parser/ast/builder.py +9 -3
- openscad_parser-3.1.0/src/openscad_parser/ast/comments.py +592 -0
- {openscad_parser-3.0.1 → openscad_parser-3.1.0}/src/openscad_parser/ast/nodes.py +10 -4
- {openscad_parser-3.0.1 → openscad_parser-3.1.0}/src/openscad_parser/ast/pretty_print.py +6 -2
- {openscad_parser-3.0.1 → openscad_parser-3.1.0}/src/openscad_parser/ast/source_map.py +15 -7
- {openscad_parser-3.0.1 → openscad_parser-3.1.0}/src/openscad_parser/grammar.py +12 -8
- {openscad_parser-3.0.1 → openscad_parser-3.1.0}/README.rst +0 -0
- {openscad_parser-3.0.1 → openscad_parser-3.1.0}/src/openscad_parser/ast/scope.py +0 -0
- {openscad_parser-3.0.1 → openscad_parser-3.1.0}/src/openscad_parser/ast/serialization.py +0 -0
- {openscad_parser-3.0.1 → openscad_parser-3.1.0}/src/openscad_parser/cli.py +0 -0
|
@@ -16,7 +16,10 @@ def getOpenSCADParser(reduce_tree=False, debug=False, include_comments=False):
|
|
|
16
16
|
Args:
|
|
17
17
|
reduce_tree: If True, reduce the parse tree (default: False)
|
|
18
18
|
debug: If True, enable debug output (default: False)
|
|
19
|
-
include_comments: If True,
|
|
19
|
+
include_comments: If True, a grammar that parses comments at the points
|
|
20
|
+
it lists (default: False). The getAST* functions no longer use it:
|
|
21
|
+
they parse with comments as whitespace and attach them afterwards
|
|
22
|
+
(openscad_parser.ast.comments), which accepts a comment anywhere.
|
|
20
23
|
|
|
21
24
|
Returns:
|
|
22
25
|
ParserPython instance configured for OpenSCAD parsing
|
|
@@ -8,6 +8,7 @@ from typing import Optional
|
|
|
8
8
|
from arpeggio import NoMatch
|
|
9
9
|
from openscad_parser import getOpenSCADParser, strict_commas
|
|
10
10
|
from openscad_parser.grammar import _STRICT_COMMAS
|
|
11
|
+
from .comments import _attach_all_comments
|
|
11
12
|
from .source_map import SourceMap, process_includes as process_includes_func
|
|
12
13
|
|
|
13
14
|
# Import all AST nodes from nodes
|
|
@@ -279,8 +280,11 @@ def getASTfromString(code: str, include_comments: bool = False, origin: str = "<
|
|
|
279
280
|
source_map = SourceMap()
|
|
280
281
|
source_map.add_origin(origin, code)
|
|
281
282
|
|
|
282
|
-
|
|
283
|
+
# Comments are parsed as whitespace and attached afterwards (see comments.py).
|
|
284
|
+
parser = getOpenSCADParser(reduce_tree=False)
|
|
283
285
|
ast = parse_ast(parser, code, source_map=source_map)
|
|
286
|
+
if ast is not None and include_comments:
|
|
287
|
+
ast = _attach_all_comments(ast, code, origin)
|
|
284
288
|
return ast
|
|
285
289
|
|
|
286
290
|
|
|
@@ -293,7 +297,7 @@ _ast_cache: dict[tuple[str, bool, bool], tuple[list[ASTNode] | None, float]] = {
|
|
|
293
297
|
# Key: tuple of (absolute file path (str), include_comments (bool), process_includes (bool),
|
|
294
298
|
# strict_commas (bool))
|
|
295
299
|
# Value: tuple of (AST nodes, modification timestamp)
|
|
296
|
-
_resolved_cache: dict[tuple[str, bool, bool, bool], tuple[list[ASTNode] | None, float]] = {}
|
|
300
|
+
_resolved_cache: dict[tuple[str, bool, bool, bool], tuple[list[ASTNode] | None, dict[str, float]]] = {}
|
|
297
301
|
|
|
298
302
|
|
|
299
303
|
def _get_disk_cache_dir() -> Optional[str]:
|
|
@@ -322,7 +326,8 @@ def _ast_format_tag() -> str:
|
|
|
322
326
|
here = os.path.dirname(os.path.abspath(__file__))
|
|
323
327
|
h = hashlib.sha256()
|
|
324
328
|
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")
|
|
329
|
+
os.path.join(here, "builder.py"), os.path.join(here, "__init__.py"),
|
|
330
|
+
os.path.join(here, "comments.py")):
|
|
326
331
|
with open(path, "rb") as f:
|
|
327
332
|
h.update(f.read())
|
|
328
333
|
return h.hexdigest()[:16]
|
|
@@ -493,8 +498,10 @@ def _parse_single_file(file_path: str, include_comments: bool = False) -> list[A
|
|
|
493
498
|
source_map = SourceMap()
|
|
494
499
|
source_map.add_origin(file_path, code)
|
|
495
500
|
|
|
496
|
-
parser = getOpenSCADParser(reduce_tree=False
|
|
501
|
+
parser = getOpenSCADParser(reduce_tree=False)
|
|
497
502
|
ast = parse_ast(parser, code, source_map=source_map)
|
|
503
|
+
if ast is not None and include_comments:
|
|
504
|
+
ast = _attach_all_comments(ast, code, file_path)
|
|
498
505
|
|
|
499
506
|
# Cache in memory and on disk
|
|
500
507
|
_ast_cache[cache_key] = (ast, current_mtime)
|
|
@@ -503,14 +510,30 @@ def _parse_single_file(file_path: str, include_comments: bool = False) -> list[A
|
|
|
503
510
|
return ast
|
|
504
511
|
|
|
505
512
|
|
|
513
|
+
def _deps_unchanged(deps: dict[str, float]) -> bool:
|
|
514
|
+
"""Whether every file a resolution read still has the mtime it had."""
|
|
515
|
+
try:
|
|
516
|
+
return all(os.path.getmtime(path) == mtime for path, mtime in deps.items())
|
|
517
|
+
except OSError: # one was deleted
|
|
518
|
+
return False
|
|
519
|
+
|
|
520
|
+
|
|
506
521
|
def _resolve_includes(ast_nodes: list[ASTNode] | None, current_file: str,
|
|
507
522
|
include_comments: bool = False,
|
|
508
|
-
|
|
509
|
-
|
|
523
|
+
open_files: tuple = (),
|
|
524
|
+
deps: dict | None = None) -> list[ASTNode] | None:
|
|
525
|
+
"""Resolve IncludeStatement nodes by parsing and inlining referenced files.
|
|
526
|
+
|
|
527
|
+
A file is included every time it is named, as OpenSCAD includes it: an
|
|
528
|
+
`include` written twice runs the file twice. Only a file already open on
|
|
529
|
+
this include chain (`open_files`; the top-level file is not one) is
|
|
530
|
+
refused -- OpenSCAD warns that it can't find it and carries on -- which is
|
|
531
|
+
what stops a cycle. Every file read is recorded in `deps` with its mtime.
|
|
532
|
+
"""
|
|
510
533
|
if ast_nodes is None:
|
|
511
534
|
return None
|
|
512
|
-
if
|
|
513
|
-
|
|
535
|
+
if deps is None:
|
|
536
|
+
deps = {}
|
|
514
537
|
|
|
515
538
|
result = []
|
|
516
539
|
for node in ast_nodes:
|
|
@@ -520,11 +543,12 @@ def _resolve_includes(ast_nodes: list[ASTNode] | None, current_file: str,
|
|
|
520
543
|
if lib_file is None:
|
|
521
544
|
raise FileNotFoundError(_not_found("Included file", filename, current_file))
|
|
522
545
|
lib_file = os.path.abspath(lib_file)
|
|
523
|
-
if lib_file in
|
|
546
|
+
if lib_file in open_files:
|
|
524
547
|
continue
|
|
525
|
-
|
|
548
|
+
deps[lib_file] = os.path.getmtime(lib_file)
|
|
526
549
|
included_ast = _parse_single_file(lib_file, include_comments)
|
|
527
|
-
included_ast = _resolve_includes(included_ast, lib_file, include_comments,
|
|
550
|
+
included_ast = _resolve_includes(included_ast, lib_file, include_comments,
|
|
551
|
+
open_files + (lib_file,), deps)
|
|
528
552
|
if included_ast:
|
|
529
553
|
result.extend(included_ast)
|
|
530
554
|
else:
|
|
@@ -592,19 +616,20 @@ def getASTfromFile(file: str, include_comments: bool = False, process_includes:
|
|
|
592
616
|
# Check resolved cache (in-memory only since resolved ASTs depend on multiple files)
|
|
593
617
|
resolved_key = (file_path, include_comments, True, _STRICT_COMMAS.get())
|
|
594
618
|
if resolved_key in _resolved_cache:
|
|
595
|
-
cached_ast,
|
|
596
|
-
if
|
|
619
|
+
cached_ast, cached_deps = _resolved_cache[resolved_key]
|
|
620
|
+
if _deps_unchanged(cached_deps):
|
|
597
621
|
return cached_ast
|
|
598
622
|
|
|
599
623
|
# Parse the file independently (uses per-file cache)
|
|
600
624
|
ast = _parse_single_file(file_path, include_comments)
|
|
601
625
|
|
|
602
626
|
# Resolve all include statements recursively
|
|
603
|
-
|
|
604
|
-
ast = _resolve_includes(ast, file_path, include_comments,
|
|
627
|
+
deps = {file_path: current_mtime}
|
|
628
|
+
ast = _resolve_includes(ast, file_path, include_comments, (), deps)
|
|
605
629
|
|
|
606
|
-
# Cache the resolved result
|
|
607
|
-
|
|
630
|
+
# Cache the resolved result, valid while every file it read is unchanged:
|
|
631
|
+
# keyed on the top file alone, an edited include went unnoticed.
|
|
632
|
+
_resolved_cache[resolved_key] = (ast, deps)
|
|
608
633
|
|
|
609
634
|
return ast
|
|
610
635
|
|
|
@@ -1101,8 +1101,14 @@ class ASTBuilderVisitor(PTNodeVisitor):
|
|
|
1101
1101
|
position=self._get_node_position(node)
|
|
1102
1102
|
)
|
|
1103
1103
|
|
|
1104
|
+
def _header_assignments(self, children) -> list:
|
|
1105
|
+
"""The `(...)` list's assignments only. Filtering `children` for
|
|
1106
|
+
Assignments also took every assignment in a braced body -- `for (i =
|
|
1107
|
+
[0:1]) { a = i; }` made `a` a loop variable."""
|
|
1108
|
+
return list(children.get_rule('assignments_expr') or [])
|
|
1109
|
+
|
|
1104
1110
|
def visit_modular_for(self, node, children):
|
|
1105
|
-
assignments =
|
|
1111
|
+
assignments = self._header_assignments(children)
|
|
1106
1112
|
body = children.get_rule('child_statement')
|
|
1107
1113
|
return ModularFor(
|
|
1108
1114
|
assignments=assignments,
|
|
@@ -1111,12 +1117,12 @@ class ASTBuilderVisitor(PTNodeVisitor):
|
|
|
1111
1117
|
)
|
|
1112
1118
|
|
|
1113
1119
|
def visit_modular_intersection_for(self, node, children):
|
|
1114
|
-
assignments =
|
|
1120
|
+
assignments = self._header_assignments(children)
|
|
1115
1121
|
body = children.get_rule('child_statement')
|
|
1116
1122
|
return ModularIntersectionFor(assignments=assignments, body=body, position=self._get_node_position(node))
|
|
1117
1123
|
|
|
1118
1124
|
def visit_modular_let(self, node, children):
|
|
1119
|
-
assignments =
|
|
1125
|
+
assignments = self._header_assignments(children)
|
|
1120
1126
|
mods = children.get_rule('child_statement')
|
|
1121
1127
|
return ModularLet(assignments=assignments, children=mods, position=self._get_node_position(node))
|
|
1122
1128
|
|
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
"""Comments for include_comments=True, attached after the parse.
|
|
2
|
+
|
|
3
|
+
The source is parsed with comments as whitespace -- the grammar every file
|
|
4
|
+
already parses under -- and each comment is then placed by position: between
|
|
5
|
+
statements into the statement list, inside an expression as a CommentedExpr,
|
|
6
|
+
around a declaration's name and parameters into its comment fields.
|
|
7
|
+
|
|
8
|
+
This replaced a grammar that allowed a comment only at the points it listed,
|
|
9
|
+
so a comment anywhere else -- in a ternary, after a `(`, inside `each if` --
|
|
10
|
+
was a syntax error: 19 of BOSL2's 57 files failed to parse with comments
|
|
11
|
+
kept. Ported from openscad_lalr_parser, which keeps every one of BOSL2's
|
|
12
|
+
comments this way.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import bisect
|
|
17
|
+
import dataclasses
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
from .builder import Position, _insert_blank_lines
|
|
21
|
+
from .nodes import (
|
|
22
|
+
ASTNode, BlankLine, CommentLine, CommentSpan, CommentedExpr, Expression, FunctionDeclaration,
|
|
23
|
+
Identifier, ModuleDeclaration, ModuleInstantiation, ParameterDeclaration, VectorElement,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# --- Comment extraction for include_comments mode ---
|
|
28
|
+
|
|
29
|
+
_COMMENT_RE = re.compile(
|
|
30
|
+
r'//([^\n]*)' # single-line comment
|
|
31
|
+
r'|/\*([\s\S]*?)\*/' # multi-line comment
|
|
32
|
+
r'|"(?:[^"\\]|\\[\s\S])*"' # string literal (skip); a backslash may escape a newline
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _line_of(newlines: list[int], offset: int) -> int:
|
|
37
|
+
"""1-based line of `offset`, given the offsets of every newline."""
|
|
38
|
+
return bisect.bisect_left(newlines, offset) + 1
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _extract_comments(code: str, origin: str) -> list[ASTNode]:
|
|
42
|
+
"""Extract all comments from source code as AST nodes."""
|
|
43
|
+
newlines = [m.start() for m in re.finditer("\n", code)]
|
|
44
|
+
comments = []
|
|
45
|
+
for m in _COMMENT_RE.finditer(code):
|
|
46
|
+
kind = CommentLine if m.group(1) is not None else CommentSpan if m.group(2) is not None else None
|
|
47
|
+
if kind is None:
|
|
48
|
+
continue # a string literal
|
|
49
|
+
start = m.start()
|
|
50
|
+
line = _line_of(newlines, start)
|
|
51
|
+
col = start - newlines[line - 2] if line > 1 else start + 1
|
|
52
|
+
pos = Position(origin=origin, line=line, column=col, start_offset=start, end_offset=m.end())
|
|
53
|
+
comments.append(kind(position=pos, text=m.group(1) if kind is CommentLine else m.group(2)))
|
|
54
|
+
return comments
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_inline_comment(comment: ASTNode, code: str) -> bool:
|
|
58
|
+
"""Return True if the comment shares a source line with non-comment code."""
|
|
59
|
+
start = comment.position.start_offset
|
|
60
|
+
end = comment.position.end_offset
|
|
61
|
+
line_start = code.rfind('\n', 0, start)
|
|
62
|
+
line_start = 0 if line_start < 0 else line_start + 1
|
|
63
|
+
before = code[line_start:start].strip()
|
|
64
|
+
if before:
|
|
65
|
+
return True
|
|
66
|
+
if isinstance(comment, CommentSpan):
|
|
67
|
+
last_line_end = code.find('\n', end)
|
|
68
|
+
if last_line_end < 0:
|
|
69
|
+
last_line_end = len(code)
|
|
70
|
+
after = code[end:last_line_end].strip()
|
|
71
|
+
if after and not after.startswith('//') and not after.startswith('/*'):
|
|
72
|
+
return True
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _classify_comments(comments: list[ASTNode], code: str) -> tuple[list[ASTNode], list[ASTNode]]:
|
|
77
|
+
"""Split comments into (inline, standalone) lists."""
|
|
78
|
+
inline = []
|
|
79
|
+
standalone = []
|
|
80
|
+
for c in comments:
|
|
81
|
+
if _is_inline_comment(c, code):
|
|
82
|
+
inline.append(c)
|
|
83
|
+
else:
|
|
84
|
+
standalone.append(c)
|
|
85
|
+
return inline, standalone
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_SKIP_FIELDS = frozenset(('position', 'scope', 'leading_comments', 'trailing_comments',
|
|
89
|
+
'pre_name_comments', 'post_name_comments', 'post_params_comments'))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
_STATEMENT_LIST_FIELDS = ("children", "body", "true_branch", "false_branch")
|
|
93
|
+
_STATEMENT_TYPES = (ModuleInstantiation, ModuleDeclaration, FunctionDeclaration)
|
|
94
|
+
# What an inline comment can wrap: expressions, and list-comprehension
|
|
95
|
+
# elements (`each`, `if`, `for` ...), which aren't Expressions -- with only
|
|
96
|
+
# those between them, a comment had nothing to attach to and was dropped.
|
|
97
|
+
_ATTACHABLE = (Expression, VectorElement)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _blank_comments(code: str, comments: list) -> str:
|
|
101
|
+
"""`code` with every comment replaced by spaces (newlines kept), so
|
|
102
|
+
looking back past comments for the previous real character is easy."""
|
|
103
|
+
chars = list(code)
|
|
104
|
+
for c in comments:
|
|
105
|
+
for i in range(c.position.start_offset, c.position.end_offset):
|
|
106
|
+
if chars[i] != "\n":
|
|
107
|
+
chars[i] = " "
|
|
108
|
+
return "".join(chars)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _place_comment(stmts: list, comment, blanked: str, same_line: bool, top: bool = False) -> str:
|
|
112
|
+
"""Put `comment` into the statement list where it falls between
|
|
113
|
+
statements, descending into nested blocks. Returns "placed", "head"
|
|
114
|
+
(inside a statement's non-block part -- arguments, a condition -- where
|
|
115
|
+
expression attachment takes it) or "top" (an own-line comment between
|
|
116
|
+
top-level statements, which _inject_comments places with its blank
|
|
117
|
+
lines)."""
|
|
118
|
+
cs = comment.position.start_offset
|
|
119
|
+
for node in stmts:
|
|
120
|
+
if isinstance(node, (CommentLine, CommentSpan, BlankLine)):
|
|
121
|
+
continue
|
|
122
|
+
pos = node.position
|
|
123
|
+
if pos.start_offset <= cs < pos.end_offset:
|
|
124
|
+
return _place_in_statement(node, comment, blanked, same_line)
|
|
125
|
+
if top and not same_line:
|
|
126
|
+
return "top"
|
|
127
|
+
index = sum(1 for n in stmts if n.position.start_offset < cs) # comments come in source order
|
|
128
|
+
if index == 0 and top:
|
|
129
|
+
return "head"
|
|
130
|
+
if isinstance(comment, CommentLine):
|
|
131
|
+
comment.same_line = same_line
|
|
132
|
+
stmts.insert(index, comment)
|
|
133
|
+
return "placed"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _place_in_statement(node, comment, blanked: str, same_line: bool) -> str:
|
|
137
|
+
child = getattr(node, "child", None) # the #/%/!/* modifiers wrap one statement
|
|
138
|
+
if isinstance(child, ASTNode):
|
|
139
|
+
return _place_in_statement(child, comment, blanked, same_line)
|
|
140
|
+
# The block the comment is in: the last one starting before it (an if/else
|
|
141
|
+
# has two). Failing that, the next block if only its `{` comes between
|
|
142
|
+
# (`module m() { // why`, or an own-line comment atop the block).
|
|
143
|
+
cs = comment.position.start_offset
|
|
144
|
+
block = next_block = None
|
|
145
|
+
for name in _STATEMENT_LIST_FIELDS:
|
|
146
|
+
stmts = getattr(node, name, None)
|
|
147
|
+
if not isinstance(stmts, list):
|
|
148
|
+
continue
|
|
149
|
+
# By its first STATEMENT: a comment placed there already may come first.
|
|
150
|
+
first = next((n for n in stmts if isinstance(n, ASTNode)
|
|
151
|
+
and not isinstance(n, (CommentLine, CommentSpan, BlankLine))), None)
|
|
152
|
+
if first is None:
|
|
153
|
+
continue
|
|
154
|
+
if first.position.start_offset <= cs:
|
|
155
|
+
block = stmts
|
|
156
|
+
elif next_block is None:
|
|
157
|
+
next_block = stmts
|
|
158
|
+
inside_block = block is not None and any(
|
|
159
|
+
n.position.start_offset <= cs < n.position.end_offset
|
|
160
|
+
for n in block if not isinstance(n, (CommentLine, CommentSpan, BlankLine)))
|
|
161
|
+
if not inside_block and next_block is not None and blanked[:cs].rstrip().endswith("{"):
|
|
162
|
+
block = next_block # atop a block: an else's, even with the if's block before it
|
|
163
|
+
if block is None:
|
|
164
|
+
return "head"
|
|
165
|
+
return _place_comment(block, comment, blanked, same_line)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _attach_inline_comments(ast_nodes: list[ASTNode], inline_comments: list[ASTNode]) -> list[ASTNode]:
|
|
169
|
+
"""Returns the comments it found no place for, for the caller to keep as
|
|
170
|
+
top-level ones: dropping them lost the text."""
|
|
171
|
+
"""Walk AST and wrap expressions adjacent to inline comments in CommentedExpr."""
|
|
172
|
+
if not inline_comments:
|
|
173
|
+
return []
|
|
174
|
+
inline_comments.sort(key=lambda c: c.position.start_offset)
|
|
175
|
+
used: set[int] = set()
|
|
176
|
+
for node in ast_nodes:
|
|
177
|
+
_walk_attach(node, inline_comments, used)
|
|
178
|
+
|
|
179
|
+
# Attach remaining unused inline comments as trailing on the nearest
|
|
180
|
+
# preceding top-level node's last expression field.
|
|
181
|
+
for ci, comment in enumerate(inline_comments):
|
|
182
|
+
if ci in used:
|
|
183
|
+
continue
|
|
184
|
+
cs = comment.position.start_offset
|
|
185
|
+
best_node = None
|
|
186
|
+
for node in ast_nodes:
|
|
187
|
+
if isinstance(node, (CommentLine, CommentSpan, BlankLine)):
|
|
188
|
+
continue
|
|
189
|
+
if node.position.end_offset <= cs:
|
|
190
|
+
best_node = node
|
|
191
|
+
if best_node is not None:
|
|
192
|
+
_attach_trailing_to_last_expr(best_node, comment)
|
|
193
|
+
used.add(ci)
|
|
194
|
+
|
|
195
|
+
return [c for ci, c in enumerate(inline_comments) if ci not in used]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _attach_trailing_to_last_expr(node: ASTNode, comment: ASTNode):
|
|
199
|
+
"""Attach a trailing comment to the last Expression field in node."""
|
|
200
|
+
last_expr_info = None
|
|
201
|
+
for f in dataclasses.fields(node):
|
|
202
|
+
if f.name in _SKIP_FIELDS:
|
|
203
|
+
continue
|
|
204
|
+
val = getattr(node, f.name)
|
|
205
|
+
if isinstance(val, Expression):
|
|
206
|
+
last_expr_info = (node, f.name, None, val)
|
|
207
|
+
elif isinstance(val, list):
|
|
208
|
+
for idx, item in enumerate(val):
|
|
209
|
+
if isinstance(item, Expression):
|
|
210
|
+
last_expr_info = (node, f.name, idx, item)
|
|
211
|
+
elif isinstance(item, ASTNode):
|
|
212
|
+
for cf in dataclasses.fields(item):
|
|
213
|
+
if cf.name in _SKIP_FIELDS:
|
|
214
|
+
continue
|
|
215
|
+
cval = getattr(item, cf.name)
|
|
216
|
+
if isinstance(cval, Expression):
|
|
217
|
+
last_expr_info = (item, cf.name, None, cval)
|
|
218
|
+
if last_expr_info is None:
|
|
219
|
+
return
|
|
220
|
+
owner, fname, lidx, expr = last_expr_info
|
|
221
|
+
if isinstance(expr, CommentedExpr):
|
|
222
|
+
expr.trailing_comments.append(comment)
|
|
223
|
+
else:
|
|
224
|
+
wrapped = CommentedExpr(
|
|
225
|
+
position=expr.position,
|
|
226
|
+
leading_comments=[],
|
|
227
|
+
trailing_comments=[comment],
|
|
228
|
+
expr=expr,
|
|
229
|
+
)
|
|
230
|
+
if lidx is None:
|
|
231
|
+
setattr(owner, fname, wrapped)
|
|
232
|
+
else:
|
|
233
|
+
getattr(owner, fname)[lidx] = wrapped
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _walk_attach(node: ASTNode, comments: list[ASTNode], used: set[int]):
|
|
237
|
+
"""Recursively attach inline comments to Expression fields of node."""
|
|
238
|
+
if not isinstance(node, ASTNode) or isinstance(node, (CommentedExpr, CommentLine, CommentSpan)):
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
ns = node.position.start_offset
|
|
242
|
+
ne = node.position.end_offset
|
|
243
|
+
|
|
244
|
+
has_relevant = any(
|
|
245
|
+
i not in used and ns <= comments[i].position.start_offset < ne
|
|
246
|
+
for i in range(len(comments))
|
|
247
|
+
)
|
|
248
|
+
if not has_relevant:
|
|
249
|
+
for f in dataclasses.fields(node):
|
|
250
|
+
if f.name in _SKIP_FIELDS:
|
|
251
|
+
continue
|
|
252
|
+
val = getattr(node, f.name)
|
|
253
|
+
if isinstance(val, ASTNode):
|
|
254
|
+
_walk_attach(val, comments, used)
|
|
255
|
+
elif isinstance(val, list):
|
|
256
|
+
for item in val:
|
|
257
|
+
if isinstance(item, ASTNode):
|
|
258
|
+
_walk_attach(item, comments, used)
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
# Collect wrappable Expression fields, including from non-Expression
|
|
262
|
+
# containers like PositionalArgument, NamedArgument, ParameterDeclaration.
|
|
263
|
+
# Each entry: (owner_node, field_name, list_index_or_None, expr)
|
|
264
|
+
expr_fields = []
|
|
265
|
+
non_expr_children = []
|
|
266
|
+
|
|
267
|
+
for f in dataclasses.fields(node):
|
|
268
|
+
if f.name in _SKIP_FIELDS:
|
|
269
|
+
continue
|
|
270
|
+
if f.name == "step" and getattr(node, "implicit_step", False):
|
|
271
|
+
continue # synthesized for [a:b]; it spans the whole range and would swallow its comments
|
|
272
|
+
val = getattr(node, f.name)
|
|
273
|
+
if isinstance(val, _ATTACHABLE) and not isinstance(val, CommentedExpr):
|
|
274
|
+
expr_fields.append((node, f.name, None, val))
|
|
275
|
+
elif isinstance(val, ASTNode):
|
|
276
|
+
non_expr_children.append(val)
|
|
277
|
+
elif isinstance(val, list):
|
|
278
|
+
for idx, item in enumerate(val):
|
|
279
|
+
if isinstance(item, _ATTACHABLE) and not isinstance(item, CommentedExpr):
|
|
280
|
+
expr_fields.append((node, f.name, idx, item))
|
|
281
|
+
elif isinstance(item, _STATEMENT_TYPES) or (f.name in _STATEMENT_LIST_FIELDS and isinstance(item, ASTNode)):
|
|
282
|
+
# A child statement (an assignment in a block too): its
|
|
283
|
+
# own walk attaches its comments. Mined here, its NAME
|
|
284
|
+
# became one of this node's expressions, and a comment in
|
|
285
|
+
# this node's arguments landed on it (`translate([1, // c`
|
|
286
|
+
# ... `cube // c(1);`, or a last parameter's comment on
|
|
287
|
+
# the body's first assignment).
|
|
288
|
+
non_expr_children.append(item)
|
|
289
|
+
elif isinstance(item, ASTNode):
|
|
290
|
+
_collect_container_exprs(item, expr_fields, non_expr_children)
|
|
291
|
+
|
|
292
|
+
expr_fields.sort(key=lambda x: x[3].position.start_offset)
|
|
293
|
+
|
|
294
|
+
leading_map: dict[int, list] = {ei: [] for ei in range(len(expr_fields))}
|
|
295
|
+
trailing_map: dict[int, list] = {ei: [] for ei in range(len(expr_fields))}
|
|
296
|
+
|
|
297
|
+
child_spans = [(c.position.start_offset, c.position.end_offset) for c in non_expr_children]
|
|
298
|
+
for ci, comment in enumerate(comments):
|
|
299
|
+
if ci in used:
|
|
300
|
+
continue
|
|
301
|
+
cs = comment.position.start_offset
|
|
302
|
+
if cs < ns or cs >= ne:
|
|
303
|
+
continue
|
|
304
|
+
if any(s <= cs < e for s, e in child_spans):
|
|
305
|
+
continue # inside a child statement: its own walk places it
|
|
306
|
+
|
|
307
|
+
attached = False
|
|
308
|
+
for ei, (owner, fname, lidx, expr) in enumerate(expr_fields):
|
|
309
|
+
es = expr.position.start_offset
|
|
310
|
+
if cs < es:
|
|
311
|
+
leading_map[ei].append((ci, comment))
|
|
312
|
+
used.add(ci)
|
|
313
|
+
attached = True
|
|
314
|
+
break
|
|
315
|
+
elif cs >= expr.position.end_offset:
|
|
316
|
+
continue
|
|
317
|
+
# Inside this expression: the recursion below attaches it within.
|
|
318
|
+
# Falling through made it the NEXT expression's leading comment.
|
|
319
|
+
attached = True
|
|
320
|
+
break
|
|
321
|
+
if not attached and expr_fields:
|
|
322
|
+
last_ei = len(expr_fields) - 1
|
|
323
|
+
if cs >= expr_fields[last_ei][3].position.end_offset:
|
|
324
|
+
trailing_map[last_ei].append((ci, comment))
|
|
325
|
+
used.add(ci)
|
|
326
|
+
|
|
327
|
+
for ei, (owner, fname, lidx, expr) in enumerate(expr_fields):
|
|
328
|
+
leading = [c for _, c in leading_map[ei]]
|
|
329
|
+
trailing = [c for _, c in trailing_map[ei]]
|
|
330
|
+
|
|
331
|
+
if leading or trailing:
|
|
332
|
+
wrapped = CommentedExpr(
|
|
333
|
+
position=expr.position,
|
|
334
|
+
leading_comments=leading,
|
|
335
|
+
trailing_comments=trailing,
|
|
336
|
+
expr=expr,
|
|
337
|
+
)
|
|
338
|
+
if lidx is None:
|
|
339
|
+
setattr(owner, fname, wrapped)
|
|
340
|
+
else:
|
|
341
|
+
getattr(owner, fname)[lidx] = wrapped
|
|
342
|
+
|
|
343
|
+
_walk_attach(expr, comments, used)
|
|
344
|
+
|
|
345
|
+
for child in non_expr_children:
|
|
346
|
+
_walk_attach(child, comments, used)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _collect_container_exprs(container: ASTNode, expr_fields: list, non_expr_children: list):
|
|
350
|
+
"""Extract Expression fields from a non-Expression container node."""
|
|
351
|
+
found_expr = False
|
|
352
|
+
for f in dataclasses.fields(container):
|
|
353
|
+
if f.name in _SKIP_FIELDS:
|
|
354
|
+
continue
|
|
355
|
+
val = getattr(container, f.name)
|
|
356
|
+
if isinstance(val, Expression) and not isinstance(val, CommentedExpr):
|
|
357
|
+
expr_fields.append((container, f.name, None, val))
|
|
358
|
+
found_expr = True
|
|
359
|
+
elif isinstance(val, list):
|
|
360
|
+
for idx, item in enumerate(val):
|
|
361
|
+
if isinstance(item, Expression) and not isinstance(item, CommentedExpr):
|
|
362
|
+
expr_fields.append((container, f.name, idx, item))
|
|
363
|
+
found_expr = True
|
|
364
|
+
if not found_expr:
|
|
365
|
+
non_expr_children.append(container)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _find_char_skipping_comments(code: str, frm: int, ch: str, comments: list[ASTNode]) -> int:
|
|
369
|
+
"""Scan raw source from `frm` for the next occurrence of `ch`, skipping
|
|
370
|
+
over any already-extracted comment span encountered along the way (so a
|
|
371
|
+
stray '(' or ')' inside a comment's own text is never mistaken for the
|
|
372
|
+
real token). Returns -1 if not found.
|
|
373
|
+
|
|
374
|
+
Needed because the grammar captures no location for the parameter
|
|
375
|
+
list's own '(' / ')' tokens -- only NAME and the whole declaration's
|
|
376
|
+
span are available (see transformer.py's
|
|
377
|
+
function_definition/module_definition/parameter_with_default).
|
|
378
|
+
"""
|
|
379
|
+
i = frm
|
|
380
|
+
n = len(code)
|
|
381
|
+
while i < n:
|
|
382
|
+
skipped = False
|
|
383
|
+
for c in comments:
|
|
384
|
+
if c is not None and c.position.start_offset == i:
|
|
385
|
+
i = c.position.end_offset
|
|
386
|
+
skipped = True
|
|
387
|
+
break
|
|
388
|
+
if skipped:
|
|
389
|
+
continue
|
|
390
|
+
if code[i] == ch:
|
|
391
|
+
return i
|
|
392
|
+
i += 1
|
|
393
|
+
return -1
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _claim_comment_spans_in_range(lo: int, hi: int, out: list, comments: list) -> None:
|
|
397
|
+
"""Moves every CommentSpan in `comments` whose start offset falls in
|
|
398
|
+
[lo, hi) into `out`, nulling its slot in `comments` in place so a later
|
|
399
|
+
pass (the inline/standalone split) never sees it again."""
|
|
400
|
+
if lo >= hi:
|
|
401
|
+
return
|
|
402
|
+
for idx, c in enumerate(comments):
|
|
403
|
+
if c is None or not isinstance(c, CommentSpan):
|
|
404
|
+
continue
|
|
405
|
+
cs = c.position.start_offset
|
|
406
|
+
if lo <= cs < hi:
|
|
407
|
+
out.append(c)
|
|
408
|
+
comments[idx] = None
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _claim_decl_signature_comments(decl: ASTNode, name: Identifier, parameters: list[ParameterDeclaration],
|
|
412
|
+
body_start: int, pre_name: list, post_name: list, post_params: list,
|
|
413
|
+
code: str, comments: list) -> None:
|
|
414
|
+
"""Claims '/* */' comments (CommentSpan only -- these fields' own
|
|
415
|
+
declared type) in every structural gap a FunctionDeclaration/
|
|
416
|
+
ModuleDeclaration doesn't expose as any Expression field: before the
|
|
417
|
+
name, between the name and the parameter list, between adjacent
|
|
418
|
+
parameters (including a parameter with no default value, which
|
|
419
|
+
_walk_attach's own generic Expression-field scan never visits at all,
|
|
420
|
+
since it only descends into fields whose value IS an Expression),
|
|
421
|
+
between the last parameter and ')', and between ')' and the body.
|
|
422
|
+
|
|
423
|
+
ponytail: when there are zero parameters, the "between name and '('"
|
|
424
|
+
and "between '(' and ')'" gaps are both real but there's no parameter
|
|
425
|
+
to own the latter -- folded into post_params_comments rather than
|
|
426
|
+
adding a dedicated field neither language's AST declares.
|
|
427
|
+
"""
|
|
428
|
+
name_start = name.position.start_offset
|
|
429
|
+
name_end = name.position.end_offset
|
|
430
|
+
_claim_comment_spans_in_range(decl.position.start_offset, name_start, pre_name, comments)
|
|
431
|
+
|
|
432
|
+
open_paren = _find_char_skipping_comments(code, name_end, '(', comments)
|
|
433
|
+
paren_open_pos = open_paren if open_paren >= 0 else name_end
|
|
434
|
+
_claim_comment_spans_in_range(name_end, paren_open_pos, post_name, comments)
|
|
435
|
+
cursor = open_paren + 1 if open_paren >= 0 else name_end
|
|
436
|
+
|
|
437
|
+
for p in parameters:
|
|
438
|
+
_claim_comment_spans_in_range(cursor, p.position.start_offset, p.leading_comments, comments)
|
|
439
|
+
cursor = p.position.end_offset
|
|
440
|
+
|
|
441
|
+
close_paren = _find_char_skipping_comments(code, cursor, ')', comments)
|
|
442
|
+
paren_close_pos = close_paren if close_paren >= 0 else cursor
|
|
443
|
+
if parameters:
|
|
444
|
+
_claim_comment_spans_in_range(cursor, paren_close_pos, parameters[-1].trailing_comments, comments)
|
|
445
|
+
else:
|
|
446
|
+
_claim_comment_spans_in_range(cursor, paren_close_pos, post_params, comments)
|
|
447
|
+
cursor = close_paren + 1 if close_paren >= 0 else cursor
|
|
448
|
+
_claim_comment_spans_in_range(cursor, body_start, post_params, comments)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _walk_attach_decl_comments(node: ASTNode, code: str, comments: list) -> None:
|
|
452
|
+
"""Recurses through `node` looking for FunctionDeclaration/
|
|
453
|
+
ModuleDeclaration nodes (which can nest inside a module's own
|
|
454
|
+
children), claiming their signature-gap comments. A generic
|
|
455
|
+
reflection-based walk over dataclass fields (mirrors _walk_attach's own
|
|
456
|
+
field introspection), since -- unlike the C++ port, which needs an
|
|
457
|
+
explicit per-NodeKind switch -- Python's dataclasses.fields() already
|
|
458
|
+
gives a free generic tree walk.
|
|
459
|
+
"""
|
|
460
|
+
if not isinstance(node, ASTNode) or isinstance(node, (CommentedExpr, CommentLine, CommentSpan)):
|
|
461
|
+
return
|
|
462
|
+
|
|
463
|
+
if isinstance(node, FunctionDeclaration):
|
|
464
|
+
_claim_decl_signature_comments(node, node.name, node.parameters, node.expr.position.start_offset,
|
|
465
|
+
node.pre_name_comments, node.post_name_comments, node.post_params_comments,
|
|
466
|
+
code, comments)
|
|
467
|
+
elif isinstance(node, ModuleDeclaration):
|
|
468
|
+
body_start = node.children[0].position.start_offset if node.children else node.position.end_offset
|
|
469
|
+
_claim_decl_signature_comments(node, node.name, node.parameters, body_start, node.pre_name_comments,
|
|
470
|
+
node.post_name_comments, node.post_params_comments, code, comments)
|
|
471
|
+
|
|
472
|
+
for f in dataclasses.fields(node):
|
|
473
|
+
if f.name in _SKIP_FIELDS:
|
|
474
|
+
continue
|
|
475
|
+
val = getattr(node, f.name)
|
|
476
|
+
if isinstance(val, ASTNode):
|
|
477
|
+
_walk_attach_decl_comments(val, code, comments)
|
|
478
|
+
elif isinstance(val, list):
|
|
479
|
+
for item in val:
|
|
480
|
+
if isinstance(item, ASTNode):
|
|
481
|
+
_walk_attach_decl_comments(item, code, comments)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _attach_declaration_comments(ast_nodes: list[ASTNode], code: str, comments: list) -> None:
|
|
485
|
+
"""Claims declaration-signature comments across the whole AST. Must run
|
|
486
|
+
BEFORE _classify_comments/_attach_inline_comments -- a '/* */' comment
|
|
487
|
+
alone on its own line before a parameter is classified *standalone* by
|
|
488
|
+
_is_inline_comment, which this pass still needs to claim, so it operates
|
|
489
|
+
on the full extracted comment list, not just the inline subset.
|
|
490
|
+
"""
|
|
491
|
+
for node in ast_nodes:
|
|
492
|
+
_walk_attach_decl_comments(node, code, comments)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _attach_all_comments(ast: list[ASTNode], code: str, origin: str) -> list[ASTNode]:
|
|
496
|
+
"""Shared by getASTfromString/_parse_single_file: extracts comments,
|
|
497
|
+
claims declaration-signature comments first (see
|
|
498
|
+
_attach_declaration_comments), then runs the existing inline/standalone
|
|
499
|
+
pipeline over whatever's left.
|
|
500
|
+
"""
|
|
501
|
+
comments = _extract_comments(code, origin)
|
|
502
|
+
blanked = _blank_comments(code, comments)
|
|
503
|
+
_attach_declaration_comments(ast, code, comments)
|
|
504
|
+
comments = [c for c in comments if c is not None]
|
|
505
|
+
inline, standalone = _classify_comments(comments, code)
|
|
506
|
+
# Comments that sit between statements belong in the statement list, at
|
|
507
|
+
# any depth: a `//` ending a statement's line after that statement (it
|
|
508
|
+
# was wrapped round the statement's last expression and printed before
|
|
509
|
+
# the `;`, or after a child module's NAME), an own-line one where it is
|
|
510
|
+
# (it was moved out to top level, after the whole statement). Inside a
|
|
511
|
+
# statement's arguments or condition, expression attachment takes it.
|
|
512
|
+
expr_comments, top_level = [], []
|
|
513
|
+
for c in inline:
|
|
514
|
+
if not (isinstance(c, CommentLine) and _place_comment(ast, c, blanked, True, top=True) == "placed"):
|
|
515
|
+
expr_comments.append(c)
|
|
516
|
+
for c in standalone:
|
|
517
|
+
where = _place_comment(ast, c, blanked, False, top=True)
|
|
518
|
+
if where == "top":
|
|
519
|
+
top_level.append(c)
|
|
520
|
+
elif where == "head":
|
|
521
|
+
expr_comments.append(c)
|
|
522
|
+
top_level += _attach_inline_comments(ast, expr_comments)
|
|
523
|
+
top_level.sort(key=lambda c: c.position.start_offset)
|
|
524
|
+
for node in ast:
|
|
525
|
+
_blank_lines_in_blocks(node)
|
|
526
|
+
return _inject_comments(ast, top_level, code, origin)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _blank_lines_in_blocks(node) -> None:
|
|
530
|
+
"""A blank line between two `//` comments in a block, as the grammar-based
|
|
531
|
+
comment parse kept it (_insert_blank_lines); top-level ones are
|
|
532
|
+
_inject_comments' job."""
|
|
533
|
+
if not isinstance(node, ASTNode) or isinstance(node, (CommentedExpr, CommentLine, CommentSpan, BlankLine)):
|
|
534
|
+
return
|
|
535
|
+
for f in dataclasses.fields(node):
|
|
536
|
+
if f.name in _SKIP_FIELDS:
|
|
537
|
+
continue
|
|
538
|
+
val = getattr(node, f.name)
|
|
539
|
+
if isinstance(val, list):
|
|
540
|
+
if f.name in _STATEMENT_LIST_FIELDS:
|
|
541
|
+
val = _insert_blank_lines(val)
|
|
542
|
+
setattr(node, f.name, val)
|
|
543
|
+
for item in val:
|
|
544
|
+
_blank_lines_in_blocks(item)
|
|
545
|
+
elif isinstance(val, ASTNode):
|
|
546
|
+
_blank_lines_in_blocks(val)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _inject_comments(ast_nodes: list[ASTNode], comments: list[ASTNode], code: str, origin: str) -> list[ASTNode]:
|
|
550
|
+
"""Merge standalone comment nodes into top-level AST node list."""
|
|
551
|
+
if not comments:
|
|
552
|
+
return ast_nodes
|
|
553
|
+
|
|
554
|
+
result = []
|
|
555
|
+
comment_idx = 0
|
|
556
|
+
prev_end_line = 0
|
|
557
|
+
lines = code.split('\n')
|
|
558
|
+
newlines = [m.start() for m in re.finditer("\n", code)]
|
|
559
|
+
|
|
560
|
+
for node in ast_nodes:
|
|
561
|
+
node_line = node.position.line if hasattr(node, 'position') and node.position else float('inf')
|
|
562
|
+
|
|
563
|
+
while comment_idx < len(comments) and comments[comment_idx].position.line < node_line:
|
|
564
|
+
comment = comments[comment_idx]
|
|
565
|
+
cl = comment.position.line
|
|
566
|
+
if prev_end_line > 0 and cl - prev_end_line > 1:
|
|
567
|
+
# One BlankLine for the gap, however many blank lines it held.
|
|
568
|
+
for gap_line in range(prev_end_line + 1, cl):
|
|
569
|
+
line_content = lines[gap_line - 1] if gap_line <= len(lines) else ''
|
|
570
|
+
if line_content.strip() == '':
|
|
571
|
+
result.append(BlankLine(position=Position(
|
|
572
|
+
origin=origin, line=gap_line, column=1)))
|
|
573
|
+
break
|
|
574
|
+
result.append(comment)
|
|
575
|
+
if isinstance(comment, CommentLine):
|
|
576
|
+
prev_end_line = comment.position.line
|
|
577
|
+
else:
|
|
578
|
+
prev_end_line = comment.position.line + comment.text.count('\n')
|
|
579
|
+
comment_idx += 1
|
|
580
|
+
|
|
581
|
+
result.append(node)
|
|
582
|
+
node_end = node.position.line
|
|
583
|
+
if hasattr(node, 'position') and node.position and node.position.end_offset > 0:
|
|
584
|
+
node_end = _line_of(newlines, node.position.end_offset)
|
|
585
|
+
prev_end_line = max(prev_end_line, node_end)
|
|
586
|
+
|
|
587
|
+
while comment_idx < len(comments):
|
|
588
|
+
comment = comments[comment_idx]
|
|
589
|
+
result.append(comment)
|
|
590
|
+
comment_idx += 1
|
|
591
|
+
|
|
592
|
+
return result
|
|
@@ -52,8 +52,12 @@ class CommentLine(ASTNode):
|
|
|
52
52
|
|
|
53
53
|
Attributes:
|
|
54
54
|
text: The comment text without the leading // marker.
|
|
55
|
+
same_line: True for a comment that ended a statement's line in the
|
|
56
|
+
source (`x = 1; // why`). It sits in the statement list right
|
|
57
|
+
after that statement, and is printed at the end of its line.
|
|
55
58
|
"""
|
|
56
59
|
text: str
|
|
60
|
+
same_line: bool = field(default=False, kw_only=True)
|
|
57
61
|
|
|
58
62
|
def __str__(self):
|
|
59
63
|
return f"//{self.text}"
|
|
@@ -128,10 +132,12 @@ class CommentedExpr(Expression):
|
|
|
128
132
|
expr: Expression
|
|
129
133
|
|
|
130
134
|
def __str__(self):
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
+
# A `//` comment runs to the end of the line, so a line break always
|
|
136
|
+
# follows one: printed inline, it commented out whatever came next.
|
|
137
|
+
out = "".join(f"{c}\n" if isinstance(c, CommentLine) else f"{c} " for c in self.leading_comments)
|
|
138
|
+
out += str(self.expr)
|
|
139
|
+
out += "".join(f" {c}\n" if isinstance(c, CommentLine) else f" {c}" for c in self.trailing_comments)
|
|
140
|
+
return out
|
|
135
141
|
|
|
136
142
|
def build_scope(self, parent_scope: "Scope") -> None:
|
|
137
143
|
_set_scope(self, parent_scope)
|
|
@@ -313,8 +313,12 @@ def _fmt_ternary_chain(expr: TernaryOp, indent: int, w: int) -> str:
|
|
|
313
313
|
def _fmt_trailing_comments(comments: list, pad: str) -> str:
|
|
314
314
|
"""Comments after an expression. After a `//` one the line must end, or
|
|
315
315
|
it would comment out the `,` `)` or `;` its caller adds next."""
|
|
316
|
-
out = ""
|
|
317
|
-
|
|
316
|
+
out = ""
|
|
317
|
+
for c in comments:
|
|
318
|
+
out += ("" if out.endswith(f"\n{pad}") else " ") + str(c)
|
|
319
|
+
if isinstance(c, CommentLine):
|
|
320
|
+
out += f"\n{pad}" # every one: two on a line would be one comment
|
|
321
|
+
return out
|
|
318
322
|
|
|
319
323
|
|
|
320
324
|
def _fmt_expr(expr, indent: int, w: int) -> str:
|
|
@@ -5,9 +5,10 @@ editor buffers, etc.) into a single string for parsing while maintaining the
|
|
|
5
5
|
ability to map positions back to their original origin, line, and column locations.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
import bisect
|
|
8
9
|
import os
|
|
9
10
|
import re
|
|
10
|
-
from dataclasses import dataclass
|
|
11
|
+
from dataclasses import dataclass, field
|
|
11
12
|
from typing import Optional, Tuple, TYPE_CHECKING
|
|
12
13
|
|
|
13
14
|
if TYPE_CHECKING:
|
|
@@ -30,6 +31,15 @@ class SourceSegment:
|
|
|
30
31
|
start_column: int
|
|
31
32
|
content: str
|
|
32
33
|
combined_start: int # Position in combined string where this segment starts
|
|
34
|
+
# Offsets of every newline in `content`, built on first use: a position's
|
|
35
|
+
# line is then a binary search, where counting the newlines before it
|
|
36
|
+
# made building a file's AST quadratic in its length.
|
|
37
|
+
_newlines: Optional[list[int]] = field(default=None, repr=False, compare=False)
|
|
38
|
+
|
|
39
|
+
def newlines(self) -> list[int]:
|
|
40
|
+
if self._newlines is None:
|
|
41
|
+
self._newlines = [m.start() for m in re.finditer("\n", self.content)]
|
|
42
|
+
return self._newlines
|
|
33
43
|
|
|
34
44
|
|
|
35
45
|
class SourceMap:
|
|
@@ -352,9 +362,9 @@ class SourceMap:
|
|
|
352
362
|
if offset > len(segment.content):
|
|
353
363
|
offset = len(segment.content) # pragma: no cover
|
|
354
364
|
|
|
355
|
-
#
|
|
356
|
-
|
|
357
|
-
line_count =
|
|
365
|
+
# Newlines before the offset, and the last of them
|
|
366
|
+
newlines = segment.newlines()
|
|
367
|
+
line_count = bisect.bisect_left(newlines, offset)
|
|
358
368
|
|
|
359
369
|
# Calculate line number
|
|
360
370
|
line_number = segment.start_line + line_count
|
|
@@ -364,9 +374,7 @@ class SourceMap:
|
|
|
364
374
|
# Same line as start
|
|
365
375
|
column_number = segment.start_column + offset
|
|
366
376
|
else:
|
|
367
|
-
|
|
368
|
-
last_newline = content_before.rfind('\n')
|
|
369
|
-
column_number = offset - last_newline
|
|
377
|
+
column_number = offset - newlines[line_count - 1]
|
|
370
378
|
|
|
371
379
|
resolved_end = end_offset if end_offset is not None else offset
|
|
372
380
|
|
|
@@ -39,9 +39,13 @@ def strict_commas(enabled: bool = True):
|
|
|
39
39
|
_STRICT_COMMAS.reset(token)
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
def
|
|
43
|
-
"""
|
|
44
|
-
|
|
42
|
+
def _comma_list(item, trailing: bool = True):
|
|
43
|
+
"""`item`s separated by commas, possibly none, with one trailing comma
|
|
44
|
+
after at least one of them when `trailing` -- as OpenSCAD reads every
|
|
45
|
+
list: `f()`, `f(1)` and `f(1,)` parse, `f(,)` and `f(1,,)` do not."""
|
|
46
|
+
if not trailing:
|
|
47
|
+
return ZeroOrMore(item, sep=TOK_COMMA)
|
|
48
|
+
return Optional(OneOrMore(item, sep=TOK_COMMA), Optional(TOK_COMMA))
|
|
45
49
|
|
|
46
50
|
|
|
47
51
|
# --- OpenSCAD language parsing root ---
|
|
@@ -486,7 +490,7 @@ def parameter_block():
|
|
|
486
490
|
|
|
487
491
|
|
|
488
492
|
def parameters():
|
|
489
|
-
return (
|
|
493
|
+
return (_comma_list(parameter),)
|
|
490
494
|
|
|
491
495
|
|
|
492
496
|
def parameter():
|
|
@@ -511,7 +515,7 @@ def argument_block():
|
|
|
511
515
|
|
|
512
516
|
|
|
513
517
|
def arguments():
|
|
514
|
-
return (
|
|
518
|
+
return (_comma_list(argument, not _STRICT_COMMAS.get()),)
|
|
515
519
|
|
|
516
520
|
|
|
517
521
|
def argument():
|
|
@@ -532,7 +536,7 @@ def named_argument():
|
|
|
532
536
|
# --- Expressions ---
|
|
533
537
|
|
|
534
538
|
def assignments_expr():
|
|
535
|
-
return (
|
|
539
|
+
return (_comma_list(assignment_expr, not _STRICT_COMMAS.get()),)
|
|
536
540
|
|
|
537
541
|
|
|
538
542
|
def assignment_expr():
|
|
@@ -700,13 +704,13 @@ def range_expr():
|
|
|
700
704
|
|
|
701
705
|
|
|
702
706
|
def vector_expr():
|
|
703
|
-
return (TOK_BRACKET, vector_elements,
|
|
707
|
+
return (TOK_BRACKET, vector_elements, TOK_ENDBRACKET)
|
|
704
708
|
|
|
705
709
|
|
|
706
710
|
# --- Vector and list comprehension ---
|
|
707
711
|
|
|
708
712
|
def vector_elements():
|
|
709
|
-
return
|
|
713
|
+
return (_comma_list(vector_element),)
|
|
710
714
|
|
|
711
715
|
|
|
712
716
|
def vector_element():
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|