opencrbasic 1.0.0__py3-none-any.whl
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.
- opencrbasic/__init__.py +3 -0
- opencrbasic/__main__.py +5 -0
- opencrbasic/analysis.py +184 -0
- opencrbasic/bindings.py +1034 -0
- opencrbasic/catalog.py +173 -0
- opencrbasic/checkconfig.py +144 -0
- opencrbasic/checker/__init__.py +977 -0
- opencrbasic/checker/cr3xx.py +3118 -0
- opencrbasic/checker/ess.py +81 -0
- opencrbasic/checker/expressions.py +3581 -0
- opencrbasic/checker/instr_data.py +1802 -0
- opencrbasic/checker/instr_measure.py +1547 -0
- opencrbasic/checker/instr_rules.py +3540 -0
- opencrbasic/checker/instructions.py +17 -0
- opencrbasic/checker/intervals.py +678 -0
- opencrbasic/checker/longconv.py +133 -0
- opencrbasic/checker/scan_time.py +580 -0
- opencrbasic/checker/storage.py +456 -0
- opencrbasic/checker/structs.py +240 -0
- opencrbasic/checker/structure.py +6213 -0
- opencrbasic/checker/symbols.py +609 -0
- opencrbasic/checker/terminals.py +249 -0
- opencrbasic/checker/unused.py +186 -0
- opencrbasic/checker/var_types.py +564 -0
- opencrbasic/checker/warnings.py +661 -0
- opencrbasic/checks.py +491 -0
- opencrbasic/cli.py +391 -0
- opencrbasic/commands.py +128 -0
- opencrbasic/compiler.py +320 -0
- opencrbasic/consteval.py +327 -0
- opencrbasic/data/background_calibration.json +4 -0
- opencrbasic/data/before_beginprog.json +99 -0
- opencrbasic/data/cr1000x/background_calibration.json +4 -0
- opencrbasic/data/cr1000x/before_beginprog.json +138 -0
- opencrbasic/data/cr1000x/ess_variables.json +1087 -0
- opencrbasic/data/cr1000x/fnotch.json +29 -0
- opencrbasic/data/cr1000x/functions.json +1337 -0
- opencrbasic/data/cr1000x/instructions.json +4836 -0
- opencrbasic/data/cr1000x/measure.json +121 -0
- opencrbasic/data/cr1000x/mode.json +111 -0
- opencrbasic/data/cr1000x/pipeline_tasks.json +88 -0
- opencrbasic/data/cr1000x/settings_fields.json +321 -0
- opencrbasic/data/cr1000x/status_members.json +318 -0
- opencrbasic/data/cr1000x/symbols.json +872 -0
- opencrbasic/data/cr1000x/terminals.json +225 -0
- opencrbasic/data/cr300/before_beginprog.json +42 -0
- opencrbasic/data/cr300/ess_variables.json +1087 -0
- opencrbasic/data/cr300/fnotch.json +22 -0
- opencrbasic/data/cr300/functions.json +1261 -0
- opencrbasic/data/cr300/instructions.json +2837 -0
- opencrbasic/data/cr300/measure.json +33 -0
- opencrbasic/data/cr300/mode.json +121 -0
- opencrbasic/data/cr300/settings_fields.json +259 -0
- opencrbasic/data/cr300/status_members.json +303 -0
- opencrbasic/data/cr300/symbols.json +584 -0
- opencrbasic/data/cr300/terminals.json +193 -0
- opencrbasic/data/cr350/before_beginprog.json +44 -0
- opencrbasic/data/cr350/ess_variables.json +1087 -0
- opencrbasic/data/cr350/fnotch.json +22 -0
- opencrbasic/data/cr350/functions.json +1262 -0
- opencrbasic/data/cr350/instructions.json +2848 -0
- opencrbasic/data/cr350/measure.json +35 -0
- opencrbasic/data/cr350/mode.json +121 -0
- opencrbasic/data/cr350/settings_fields.json +292 -0
- opencrbasic/data/cr350/status_members.json +303 -0
- opencrbasic/data/cr350/symbols.json +572 -0
- opencrbasic/data/cr350/terminals.json +190 -0
- opencrbasic/data/ess_variables.json +1087 -0
- opencrbasic/data/fnotch.json +34 -0
- opencrbasic/data/functions.json +1337 -0
- opencrbasic/data/instructions.json +4965 -0
- opencrbasic/data/measure.json +123 -0
- opencrbasic/data/mode.json +121 -0
- opencrbasic/data/notices_cr6.json +890 -0
- opencrbasic/data/pipeline_tasks.json +84 -0
- opencrbasic/data/settings_fields.json +322 -0
- opencrbasic/data/status_members.json +319 -0
- opencrbasic/data/status_table.json +684 -0
- opencrbasic/data/symbols.json +902 -0
- opencrbasic/data/terminals.json +195 -0
- opencrbasic/data/type_codes.json +249 -0
- opencrbasic/diagnostics.py +196 -0
- opencrbasic/editing.py +827 -0
- opencrbasic/formatting.py +310 -0
- opencrbasic/helpdocs.py +677 -0
- opencrbasic/hover.py +667 -0
- opencrbasic/inlay.py +169 -0
- opencrbasic/lexer.py +183 -0
- opencrbasic/lsp/__init__.py +5 -0
- opencrbasic/lsp/__main__.py +7 -0
- opencrbasic/lsp/config.py +202 -0
- opencrbasic/lsp/features.py +602 -0
- opencrbasic/lsp/server.py +741 -0
- opencrbasic/lsp/session.py +1189 -0
- opencrbasic/lsp/worker.py +128 -0
- opencrbasic/mode.py +143 -0
- opencrbasic/navigation.py +401 -0
- opencrbasic/parser.py +3264 -0
- opencrbasic/positions.py +274 -0
- opencrbasic/preprocess.py +269 -0
- opencrbasic/profiles.py +266 -0
- opencrbasic/provenance.py +17 -0
- opencrbasic/quickfix.py +331 -0
- opencrbasic/rename.py +662 -0
- opencrbasic/signature.py +33 -0
- opencrbasic/source.py +600 -0
- opencrbasic/sources.py +125 -0
- opencrbasic/syntax.py +337 -0
- opencrbasic/tables.py +2173 -0
- opencrbasic/tables_hook.py +14 -0
- opencrbasic/tdf.py +142 -0
- opencrbasic/timing.py +565 -0
- opencrbasic-1.0.0.dist-info/METADATA +223 -0
- opencrbasic-1.0.0.dist-info/RECORD +117 -0
- opencrbasic-1.0.0.dist-info/WHEEL +4 -0
- opencrbasic-1.0.0.dist-info/entry_points.txt +3 -0
- opencrbasic-1.0.0.dist-info/licenses/LICENSE +21 -0
opencrbasic/positions.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Source positions for editor tooling: compiler-reported lines and logical columns -> physical
|
|
2
|
+
file ranges -> LSP positions. Read-only over the provenance the loader records (`source.Piece`,
|
|
3
|
+
`source.LineSpan`, `source.Occurrence`); it changes no compiler output. See `docs/lsp-api.md`
|
|
4
|
+
"Source positions".
|
|
5
|
+
|
|
6
|
+
Three levels:
|
|
7
|
+
|
|
8
|
+
- `reported_spans` / `line_span`: a compiler-reported (file, line) -> the physical line span(s) of
|
|
9
|
+
the logical line(s) reported there (`Program.line_map`).
|
|
10
|
+
- `logical_ranges`: a column range of a `LogicalLine.text` -> physical (line, column) ranges,
|
|
11
|
+
through continuation joins and initializer pulls.
|
|
12
|
+
- `lsp_position` / `lsp_range`: a physical column counted in decoded (Latin-1) characters, i.e. a
|
|
13
|
+
byte offset in the line, -> an LSP `Position` for the editor's file encoding and the negotiated
|
|
14
|
+
position encoding.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import codecs
|
|
18
|
+
from collections.abc import Mapping, Sequence
|
|
19
|
+
from typing import NamedTuple
|
|
20
|
+
|
|
21
|
+
from .source import LineSpan, LogicalLine, Occurrence, Piece
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"LineSpan",
|
|
25
|
+
"Occurrence",
|
|
26
|
+
"Piece",
|
|
27
|
+
"PhysicalRange",
|
|
28
|
+
"reported_spans",
|
|
29
|
+
"line_span",
|
|
30
|
+
"logical_ranges",
|
|
31
|
+
"exact_range",
|
|
32
|
+
"physical_lines",
|
|
33
|
+
"lsp_position",
|
|
34
|
+
"lsp_range",
|
|
35
|
+
"decoded_position",
|
|
36
|
+
"POSITION_ENCODINGS",
|
|
37
|
+
"units",
|
|
38
|
+
"EditorBytes",
|
|
39
|
+
"text_lines",
|
|
40
|
+
"compiler_column",
|
|
41
|
+
"editor_character",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
POSITION_ENCODINGS = ("utf-16", "utf-8", "utf-32")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class EditorBytes(bytes):
|
|
48
|
+
"""A document's compiler bytes (its editor `text` encoded with `errors="replace"`) that keep
|
|
49
|
+
the editor text they came from. Feature positions are mapped through that text when it is
|
|
50
|
+
present (`compiler_column` / `editor_character`), so a replaced character keeps its editor
|
|
51
|
+
width. `text` exists only in the process that encoded it: pickling (the analysis job sent to a
|
|
52
|
+
worker process) and copying give plain `bytes`, so a job carries each document's bytes once."""
|
|
53
|
+
|
|
54
|
+
text: str | None = None
|
|
55
|
+
|
|
56
|
+
def __new__(cls, data: bytes, text: str | None = None) -> EditorBytes:
|
|
57
|
+
out = super().__new__(cls, data)
|
|
58
|
+
out.text = text
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
def __reduce__(self):
|
|
62
|
+
return bytes, (bytes(self),)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def units(text: str, encoding: str) -> int:
|
|
66
|
+
"""Length of `text` in position-encoding units: UTF-16 code units, UTF-8 bytes or code points."""
|
|
67
|
+
if encoding == "utf-16":
|
|
68
|
+
return len(text.encode("utf-16-le", errors="surrogatepass")) // 2
|
|
69
|
+
if encoding == "utf-8":
|
|
70
|
+
return len(text.encode("utf-8", errors="replace"))
|
|
71
|
+
if encoding == "utf-32":
|
|
72
|
+
return len(text)
|
|
73
|
+
raise ValueError(f"unsupported position encoding {encoding!r}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def text_lines(text: str) -> list[str]:
|
|
77
|
+
"""Editor text split into physical lines exactly as `physical_lines` splits the bytes (CRLF
|
|
78
|
+
and LF), so line N here is line N of the compiler bytes."""
|
|
79
|
+
return text.replace("\r\n", "\n").split("\n")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def compiler_column(line: str, character: int, *, encoding: str = "utf-16", file_encoding: str = "utf-8") -> int:
|
|
83
|
+
"""Decoded (byte) column in the compiler bytes of editor `line` for `character` (position
|
|
84
|
+
encoding units). Each editor character is `len(ch.encode(file_encoding, "replace"))` bytes, so
|
|
85
|
+
this is exact for a replaced character too. A `character` inside an editor character snaps back
|
|
86
|
+
to its start; one past the line end clamps to the line's byte length."""
|
|
87
|
+
target = max(character, 0)
|
|
88
|
+
seen = col = 0
|
|
89
|
+
for ch in line:
|
|
90
|
+
step = units(ch, encoding)
|
|
91
|
+
if seen + step > target:
|
|
92
|
+
return col
|
|
93
|
+
seen += step
|
|
94
|
+
col += len(ch.encode(file_encoding, errors="replace"))
|
|
95
|
+
if seen == target:
|
|
96
|
+
return col
|
|
97
|
+
return col
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def editor_character(line: str, column: int, *, encoding: str = "utf-16", file_encoding: str = "utf-8") -> int:
|
|
101
|
+
"""The inverse of `compiler_column`: decoded (byte) `column` of the compiler bytes of editor
|
|
102
|
+
`line` -> `character` in `encoding` units. A column inside an encoded character snaps back to
|
|
103
|
+
its start; one past the end clamps to the line's length in units."""
|
|
104
|
+
target = max(column, 0)
|
|
105
|
+
col = seen = 0
|
|
106
|
+
for ch in line:
|
|
107
|
+
size = len(ch.encode(file_encoding, errors="replace"))
|
|
108
|
+
if col + size > target:
|
|
109
|
+
return seen
|
|
110
|
+
col += size
|
|
111
|
+
seen += units(ch, encoding)
|
|
112
|
+
if col == target:
|
|
113
|
+
return seen
|
|
114
|
+
return seen
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
LineMap = Mapping[str, Mapping[int, Sequence[LineSpan]]]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class PhysicalRange(NamedTuple):
|
|
121
|
+
"""`columns [start_col, end_col)` of physical line `line` (1-based) of `file`'s `occurrence`.
|
|
122
|
+
Columns are 0-based decoded (Latin-1) characters, i.e. byte offsets into the line."""
|
|
123
|
+
|
|
124
|
+
file: str
|
|
125
|
+
occurrence: int
|
|
126
|
+
line: int
|
|
127
|
+
start_col: int
|
|
128
|
+
end_col: int
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def reported_spans(line_map: LineMap | None, file: str, line: int) -> list[LineSpan]:
|
|
132
|
+
"""Every physical span whose logical line the compiler reports at `line` of `file` (a display
|
|
133
|
+
name; the empty name is not resolved here). Several logical lines can share a reported line
|
|
134
|
+
(after initializer pulls) and a file loaded twice contributes one span per occurrence."""
|
|
135
|
+
if not line_map:
|
|
136
|
+
return []
|
|
137
|
+
by_line = line_map.get(file)
|
|
138
|
+
if by_line is None:
|
|
139
|
+
lowered = file.lower()
|
|
140
|
+
by_line = next((m for name, m in line_map.items() if name.lower() == lowered), None)
|
|
141
|
+
return list(by_line.get(line, ())) if by_line else []
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def line_span(line_map: LineMap | None, file: str, line: int) -> tuple[int, int] | None:
|
|
145
|
+
"""The covering physical (first, last) line of `reported_spans`, or None when unknown."""
|
|
146
|
+
spans = reported_spans(line_map, file, line)
|
|
147
|
+
if not spans:
|
|
148
|
+
return None
|
|
149
|
+
return min(s.first for s in spans), max(s.last for s in spans)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def logical_ranges(ll: LogicalLine, start: int, end: int) -> list[PhysicalRange]:
|
|
153
|
+
"""Physical ranges for `ll.text[start:end]`, in order. A range that crosses a continuation
|
|
154
|
+
join, or a loader-inserted `" = "`/`" "`, comes back as several ranges (inserted characters
|
|
155
|
+
map to nothing); an empty `start == end` gives one empty range, or none when that offset is
|
|
156
|
+
inserted text. Characters with no piece are skipped."""
|
|
157
|
+
out: list[PhysicalRange] = []
|
|
158
|
+
for p in ll.pieces:
|
|
159
|
+
lo, hi = max(start, p.start), min(end, p.end)
|
|
160
|
+
if start == end:
|
|
161
|
+
if p.start <= start <= p.end:
|
|
162
|
+
return [PhysicalRange(p.file, p.occurrence, p.line, p.col + start - p.start, p.col + start - p.start)]
|
|
163
|
+
continue
|
|
164
|
+
if lo >= hi:
|
|
165
|
+
continue
|
|
166
|
+
piece = PhysicalRange(p.file, p.occurrence, p.line, p.col + lo - p.start, p.col + hi - p.start)
|
|
167
|
+
if out and out[-1][:3] == piece[:3] and out[-1].end_col == piece.start_col:
|
|
168
|
+
piece = piece._replace(start_col=out.pop().start_col)
|
|
169
|
+
out.append(piece)
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def exact_range(ll: LogicalLine, start: int, end: int) -> PhysicalRange | None:
|
|
174
|
+
"""`logical_ranges` when the whole of `ll.text[start:end]` is one contiguous physical slice
|
|
175
|
+
(a token from a single piece), else None."""
|
|
176
|
+
ranges = logical_ranges(ll, start, end)
|
|
177
|
+
if len(ranges) != 1:
|
|
178
|
+
return None
|
|
179
|
+
r = ranges[0]
|
|
180
|
+
return r if start == end or r.end_col - r.start_col == end - start else None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def physical_lines(data: bytes) -> list[bytes]:
|
|
184
|
+
"""`data` split into physical lines exactly as the loader does (CRLF and LF; a lone CR stays
|
|
185
|
+
inside its line, which an editor would split -- see the limits in `docs/lsp-api.md`)."""
|
|
186
|
+
return data.replace(b"\r\n", b"\n").split(b"\n")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def lsp_position(
|
|
190
|
+
data: bytes | Sequence[bytes], line: int, col: int, *, encoding: str = "utf-16", editor_encoding: str = "utf-8"
|
|
191
|
+
) -> tuple[int, int]:
|
|
192
|
+
"""LSP `(line, character)` for decoded column `col` of physical `line` (1-based).
|
|
193
|
+
|
|
194
|
+
`data` is the file's bytes (or `physical_lines(data)`); `col` counts the compiler's Latin-1
|
|
195
|
+
characters, so it is a byte offset into that line. The prefix `line_bytes[:col]` is decoded with
|
|
196
|
+
`editor_encoding` (errors replaced; an incomplete trailing sequence is dropped, so a column
|
|
197
|
+
inside a multi-byte character snaps back to that character's start) and its length is counted
|
|
198
|
+
in `encoding` units: UTF-16 code units, UTF-8 bytes, or UTF-32 code points. Only ASCII-
|
|
199
|
+
compatible editor encodings are meaningful. A column past the line end is clamped to it."""
|
|
200
|
+
if encoding not in POSITION_ENCODINGS:
|
|
201
|
+
raise ValueError(f"unsupported position encoding {encoding!r}")
|
|
202
|
+
lines = physical_lines(data) if isinstance(data, (bytes, bytearray)) else data
|
|
203
|
+
if not 1 <= line <= len(lines):
|
|
204
|
+
raise IndexError(f"line {line} outside 1..{len(lines)}")
|
|
205
|
+
prefix = lines[line - 1][: max(col, 0)]
|
|
206
|
+
decoder = codecs.getincrementaldecoder(editor_encoding)(errors="replace")
|
|
207
|
+
text = decoder.decode(prefix, final=False)
|
|
208
|
+
if encoding == "utf-16":
|
|
209
|
+
return line - 1, len(text.encode("utf-16-le")) // 2
|
|
210
|
+
if encoding == "utf-8":
|
|
211
|
+
return line - 1, len(text.encode("utf-8", errors="replace"))
|
|
212
|
+
return line - 1, len(text)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def lsp_range(
|
|
216
|
+
data: bytes | Sequence[bytes], r: PhysicalRange, *, encoding: str = "utf-16", editor_encoding: str = "utf-8"
|
|
217
|
+
) -> tuple[tuple[int, int], tuple[int, int]]:
|
|
218
|
+
"""`((line, character), (line, character))` for a `PhysicalRange` of the same file."""
|
|
219
|
+
lines = physical_lines(data) if isinstance(data, (bytes, bytearray)) else data
|
|
220
|
+
return (
|
|
221
|
+
lsp_position(lines, r.line, r.start_col, encoding=encoding, editor_encoding=editor_encoding),
|
|
222
|
+
lsp_position(lines, r.line, r.end_col, encoding=encoding, editor_encoding=editor_encoding),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _units(text: str, encoding: str) -> int:
|
|
227
|
+
if encoding == "utf-16":
|
|
228
|
+
return len(text.encode("utf-16-le")) // 2
|
|
229
|
+
if encoding == "utf-8":
|
|
230
|
+
return len(text.encode("utf-8", errors="replace"))
|
|
231
|
+
return len(text)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def decoded_position(
|
|
235
|
+
data: bytes | Sequence[bytes],
|
|
236
|
+
line: int,
|
|
237
|
+
character: int,
|
|
238
|
+
*,
|
|
239
|
+
encoding: str = "utf-16",
|
|
240
|
+
editor_encoding: str = "utf-8",
|
|
241
|
+
) -> tuple[int, int]:
|
|
242
|
+
"""The inverse of `lsp_position`: LSP `(line, character)` (0-based line, `character` counted
|
|
243
|
+
in `encoding` units) -> `(line, col)` with `line` 1-based and `col` the decoded (Latin-1)
|
|
244
|
+
column, i.e. the byte offset into that physical line.
|
|
245
|
+
|
|
246
|
+
`character` on a character boundary gives the offset of that boundary, so
|
|
247
|
+
`decoded_position(data, *lsp_position(data, l, c)) == (l, c)` for every `c` on a boundary.
|
|
248
|
+
A `character` inside a character (the middle of a UTF-16 surrogate pair) snaps back to that
|
|
249
|
+
character's start, as `lsp_position` snaps a column; one past the line end is clamped to the
|
|
250
|
+
line length. A line outside the file raises `IndexError`; an unknown encoding `ValueError`."""
|
|
251
|
+
if encoding not in POSITION_ENCODINGS:
|
|
252
|
+
raise ValueError(f"unsupported position encoding {encoding!r}")
|
|
253
|
+
lines = physical_lines(data) if isinstance(data, (bytes, bytearray)) else data
|
|
254
|
+
if not 0 <= line < len(lines):
|
|
255
|
+
raise IndexError(f"line {line} outside 0..{len(lines) - 1}")
|
|
256
|
+
raw = lines[line]
|
|
257
|
+
target = max(character, 0)
|
|
258
|
+
if raw.isascii():
|
|
259
|
+
return line + 1, min(target, len(raw))
|
|
260
|
+
if target == 0:
|
|
261
|
+
return line + 1, 0
|
|
262
|
+
decoder = codecs.getincrementaldecoder(editor_encoding)(errors="replace")
|
|
263
|
+
units = start = 0
|
|
264
|
+
for col in range(len(raw)):
|
|
265
|
+
step = _units(decoder.decode(raw[col : col + 1], final=False), encoding)
|
|
266
|
+
if not step:
|
|
267
|
+
continue # an incomplete multi-byte sequence so far
|
|
268
|
+
if units + step > target:
|
|
269
|
+
return line + 1, start # inside a character: snap back to its start
|
|
270
|
+
units += step
|
|
271
|
+
start = col + 1
|
|
272
|
+
if units == target:
|
|
273
|
+
return line + 1, start
|
|
274
|
+
return line + 1, len(raw)
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""`-p FILE` / `-i FILE`: CR6Comp's preprocessed-source output.
|
|
2
|
+
|
|
3
|
+
Independent of the compiler's own compile pipeline (`source.py`/`parser.py`), which always
|
|
4
|
+
comment-strips and fully expands `Include`/`IncludeSection` before symbol resolution: the exe's
|
|
5
|
+
`-p`/`-i` text dump is a much shallower transformation of the raw file. Oracle-verified
|
|
6
|
+
byte-for-byte on the repository's 16 programs and hand-written probes (`docs/cli.md`
|
|
7
|
+
"Preprocessed output (-p / -i)"; this replaces that section's earlier, unverified draft, which
|
|
8
|
+
wrongly assumed comments were stripped and includes expanded under `-p`):
|
|
9
|
+
|
|
10
|
+
- comments are kept verbatim (trailing `'...` and whole-line) -- NOT stripped.
|
|
11
|
+
- blank lines are kept.
|
|
12
|
+
- a line continuation (an unquoted, uncommented `_` whose only preceding characters on the line
|
|
13
|
+
are whitespace) is resolved by deleting just the underscore and directly concatenating the
|
|
14
|
+
following physical line's raw text: no separator is inserted and the continuing line's own
|
|
15
|
+
leading whitespace is kept. A comment trailing the `_` on the same physical line is dropped
|
|
16
|
+
along with the marker (oracle-confirmed: unlike an ordinary trailing comment, it does not
|
|
17
|
+
survive into the output).
|
|
18
|
+
- `#If`/`#ElseIf`/`#Else`/`#EndIf`/`#If_no_remove`/`#UnDef` directive lines are removed; only the
|
|
19
|
+
taken branch's own lines remain. Resolution uses the same Const environment as a real compile,
|
|
20
|
+
so a Const defined via an `IncludeSection()` -- itself left unexpanded in `-p`'s own output --
|
|
21
|
+
still resolves correctly (oracle-confirmed: `Main.CR6`'s `#If BatteryTempSensor > 0 Then` picks
|
|
22
|
+
the `#Else` branch because `BatteryTempSensor` comes from `StationConfig.crb`).
|
|
23
|
+
- `-p`: a bare `Include(...)` or `IncludeSection(...)` statement is left as literal, unexpanded
|
|
24
|
+
text.
|
|
25
|
+
- `-i`: both are expanded recursively, splicing in the target file's own preprocessed text in
|
|
26
|
+
place (oracle-confirmed on both forms).
|
|
27
|
+
- output is CRLF throughout with one trailing CRLF, `latin-1`-decoded/encoded like `source.py`.
|
|
28
|
+
|
|
29
|
+
This reuses `parser._Parser`'s directive-handling and Const-folding (`_handle_directive`,
|
|
30
|
+
`_parse_const`, `_active`) so `#If`/Const resolution is the same oracle-verified engine the real
|
|
31
|
+
front end uses for compilation, not a second implementation of CRBasic's operator precedence
|
|
32
|
+
that could quietly drift from it. `parser.py` itself is not modified here, only called into.
|
|
33
|
+
|
|
34
|
+
Not yet verified against the oracle (documented gaps, `docs/cli.md`):
|
|
35
|
+
- a bare `Include(...)` nested inside an already-`-i`-expanded file (illegal for compilation,
|
|
36
|
+
`Include file within an Include file is illegal.`) falls back to literal text rather than
|
|
37
|
+
guessing at behaviour with no oracle sample.
|
|
38
|
+
- a dangling continuation marker on a file's last physical line (nothing follows to join).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
from . import lexer, source
|
|
44
|
+
from .diagnostics import Diagnostics
|
|
45
|
+
from .parser import _DIRECTIVE_RE, _Parser, _split_text_segments, _TokStream
|
|
46
|
+
from .profiles import Profile
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _split_physical(raw: str) -> list[str]:
|
|
50
|
+
"""Physical lines, without the extra empty trailing element `str.split("\\n")` produces for
|
|
51
|
+
input that (as is normal) ends with a line terminator -- unlike `source.py`'s own splitter,
|
|
52
|
+
which can leave that artifact in because its caller always skips blank logical lines anyway.
|
|
53
|
+
"""
|
|
54
|
+
parts = raw.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
55
|
+
if parts and parts[-1] == "":
|
|
56
|
+
parts.pop()
|
|
57
|
+
return parts
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_continuation_raw(stripped: str) -> tuple[bool, str]:
|
|
61
|
+
"""Like `source._is_continuation`, but keeps the whitespace before the `_` -- oracle-
|
|
62
|
+
confirmed the exe's own `-p`/`-i` join keeps it, where `source.py`'s own variant strips it
|
|
63
|
+
further (harmless there: it only feeds the lexer, which doesn't see whitespace).
|
|
64
|
+
"""
|
|
65
|
+
t = stripped.rstrip(" \t")
|
|
66
|
+
if t.endswith("_") and (len(t) == 1 or t[-2] in " \t"):
|
|
67
|
+
return True, t[:-1]
|
|
68
|
+
return False, stripped
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _Group:
|
|
72
|
+
"""One continuation-joined logical line: `raw` is what `-p`/`-i` emits for it (verbatim
|
|
73
|
+
except for the continuation markers themselves); `eval` is the comment-stripped, single-
|
|
74
|
+
space-joined text used only to recognise directives/Const/Include, matching `source.py`'s own
|
|
75
|
+
joining rule so `#If`/Const evaluation agrees with a real compile.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
__slots__ = ("raw", "eval", "line")
|
|
79
|
+
|
|
80
|
+
def __init__(self, raw: str, eval_text: str, line: int):
|
|
81
|
+
self.raw = raw
|
|
82
|
+
self.eval = eval_text
|
|
83
|
+
self.line = line
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _read_groups(physical: list[str]) -> list[_Group]:
|
|
87
|
+
groups: list[_Group] = []
|
|
88
|
+
i = 0
|
|
89
|
+
n = len(physical)
|
|
90
|
+
while i < n:
|
|
91
|
+
raw_parts: list[str] = []
|
|
92
|
+
eval_text = source._strip_comment(physical[i])
|
|
93
|
+
while True:
|
|
94
|
+
cont, eval_prefix = source._is_continuation(eval_text)
|
|
95
|
+
if not cont or i + 1 >= n:
|
|
96
|
+
raw_parts.append(physical[i])
|
|
97
|
+
if cont:
|
|
98
|
+
eval_text = eval_prefix
|
|
99
|
+
break
|
|
100
|
+
_, raw_prefix = _is_continuation_raw(source._strip_comment(physical[i]))
|
|
101
|
+
raw_parts.append(raw_prefix)
|
|
102
|
+
i += 1
|
|
103
|
+
eval_text = eval_prefix + " " + source._strip_comment(physical[i]).lstrip(" \t")
|
|
104
|
+
groups.append(_Group("".join(raw_parts), eval_text, i + 1))
|
|
105
|
+
i += 1
|
|
106
|
+
return groups
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _fold_consts_and_check_endprog(eval_text: str, line: int, filename: str, parser_instance: _Parser) -> bool:
|
|
110
|
+
"""Folds any `Const` segment into `parser_instance.env` and returns True the moment an
|
|
111
|
+
`EndProg` statement is seen. CR6Comp stops reading the file at the first top-level `EndProg`
|
|
112
|
+
(`parser.py`'s own docstring, oracle-confirmed there); the same is true of `-p`/`-i`'s own
|
|
113
|
+
text dump -- oracle-confirmed on the CHM corpus, several of whose examples concatenate a
|
|
114
|
+
second, unrelated program (or bare prose) after a first program's `EndProg`, none of which
|
|
115
|
+
appears in `-p`/`-i`'s output.
|
|
116
|
+
"""
|
|
117
|
+
for seg in _split_text_segments(eval_text):
|
|
118
|
+
if not seg.strip():
|
|
119
|
+
continue
|
|
120
|
+
try:
|
|
121
|
+
tokens = lexer.tokenize(seg, line)
|
|
122
|
+
except lexer.LexError:
|
|
123
|
+
continue
|
|
124
|
+
real = [t for t in tokens if t.kind != "EOF"]
|
|
125
|
+
if not real or real[0].kind != "IDENT":
|
|
126
|
+
continue
|
|
127
|
+
if real[0].upper == "CONST":
|
|
128
|
+
try:
|
|
129
|
+
parser_instance._parse_const(_TokStream(tokens), line, filename)
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
elif real[0].upper == "ENDPROG":
|
|
133
|
+
return True
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _walk_file(
|
|
138
|
+
groups: list[_Group],
|
|
139
|
+
filename: str,
|
|
140
|
+
section: str | None,
|
|
141
|
+
directory: Path | None,
|
|
142
|
+
parser_instance: _Parser,
|
|
143
|
+
expand_includes: bool,
|
|
144
|
+
in_include: bool,
|
|
145
|
+
seen: frozenset[str],
|
|
146
|
+
stop: dict | None = None, # {"at": (file name lower, line), "hit": bool}
|
|
147
|
+
) -> list[str]:
|
|
148
|
+
out: list[str] = []
|
|
149
|
+
for g in groups:
|
|
150
|
+
if parser_instance._done:
|
|
151
|
+
break
|
|
152
|
+
parser_instance.env.section = section
|
|
153
|
+
m = _DIRECTIVE_RE.match(g.eval)
|
|
154
|
+
if m:
|
|
155
|
+
kind = m.group(1).upper()
|
|
156
|
+
if kind == "IF_NO_REMOVE":
|
|
157
|
+
kind = "IF"
|
|
158
|
+
parser_instance._handle_directive(kind, m.group(2), g.line, filename)
|
|
159
|
+
continue
|
|
160
|
+
if not parser_instance._active():
|
|
161
|
+
continue
|
|
162
|
+
if stop is not None and stop["at"] == (filename.lower(), g.line):
|
|
163
|
+
# the reference stops preprocessing with the pass-1 error its message buffer cannot hold,
|
|
164
|
+
# at its first active occurrence (oracle, CR6, round 74, `docs/artifacts.md`)
|
|
165
|
+
out.append(g.raw)
|
|
166
|
+
parser_instance._done = stop["hit"] = True
|
|
167
|
+
break
|
|
168
|
+
if _fold_consts_and_check_endprog(g.eval, g.line, filename, parser_instance):
|
|
169
|
+
out.append(g.raw)
|
|
170
|
+
parser_instance._done = True
|
|
171
|
+
break
|
|
172
|
+
m_sec = source._INCLUDE_SECTION_RE.match(g.eval)
|
|
173
|
+
m_inc = None if m_sec else source._INCLUDE_RE.match(g.eval)
|
|
174
|
+
if (m_sec or m_inc) and not expand_includes:
|
|
175
|
+
# `-p` ends its output after an include whose file is missing (oracle, CR6, round 74,
|
|
176
|
+
# artifact matrix `fail_include`/`fail_section`)
|
|
177
|
+
spec = m_sec.group(2) if m_sec else m_inc.group(1) if m_inc else ""
|
|
178
|
+
out.append(g.raw)
|
|
179
|
+
target = source._resolve_include_path(spec, directory)
|
|
180
|
+
if target is None:
|
|
181
|
+
parser_instance._done = True
|
|
182
|
+
break
|
|
183
|
+
key = str(target.resolve())
|
|
184
|
+
if key not in seen and not (m_inc and in_include):
|
|
185
|
+
# not written, but its `Const`s still decide later `#If`s (`Main.CR6`'s
|
|
186
|
+
# `#If BatteryTempSensor > 0` with the Const in an included `StationConfig.crb`)
|
|
187
|
+
try:
|
|
188
|
+
child = _read_groups(_split_physical(target.read_bytes().decode("latin-1")))
|
|
189
|
+
except OSError:
|
|
190
|
+
continue
|
|
191
|
+
_walk_file(
|
|
192
|
+
child,
|
|
193
|
+
target.name,
|
|
194
|
+
m_sec.group(1) if m_sec else None,
|
|
195
|
+
target.parent,
|
|
196
|
+
parser_instance,
|
|
197
|
+
False,
|
|
198
|
+
in_include or bool(m_inc),
|
|
199
|
+
seen | {key},
|
|
200
|
+
stop,
|
|
201
|
+
)
|
|
202
|
+
if stop is not None and stop["hit"]:
|
|
203
|
+
break
|
|
204
|
+
parser_instance._done = False # an `EndProg` in the include does not end this file
|
|
205
|
+
continue
|
|
206
|
+
if (m_sec or m_inc) and expand_includes:
|
|
207
|
+
if m_sec:
|
|
208
|
+
spec = m_sec.group(2)
|
|
209
|
+
new_section, new_in_include = m_sec.group(1), in_include
|
|
210
|
+
else:
|
|
211
|
+
if in_include:
|
|
212
|
+
out.append(g.raw)
|
|
213
|
+
continue
|
|
214
|
+
assert m_inc is not None # (`m_sec or m_inc`)
|
|
215
|
+
spec = m_inc.group(1)
|
|
216
|
+
new_section, new_in_include = None, True
|
|
217
|
+
target = source._resolve_include_path(spec, directory)
|
|
218
|
+
if target is None:
|
|
219
|
+
out.append(g.raw) # `-i` keeps a missing include as written (oracle, CR6, round 74)
|
|
220
|
+
continue
|
|
221
|
+
key = str(target.resolve())
|
|
222
|
+
if key in seen:
|
|
223
|
+
out.append(g.raw)
|
|
224
|
+
continue
|
|
225
|
+
try:
|
|
226
|
+
data = target.read_bytes()
|
|
227
|
+
except OSError:
|
|
228
|
+
continue
|
|
229
|
+
child_groups = _read_groups(_split_physical(data.decode("latin-1")))
|
|
230
|
+
out.extend(
|
|
231
|
+
_walk_file(
|
|
232
|
+
child_groups,
|
|
233
|
+
target.name,
|
|
234
|
+
new_section,
|
|
235
|
+
target.parent,
|
|
236
|
+
parser_instance,
|
|
237
|
+
expand_includes,
|
|
238
|
+
new_in_include,
|
|
239
|
+
seen | {key},
|
|
240
|
+
stop,
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
if parser_instance._done:
|
|
244
|
+
break
|
|
245
|
+
continue
|
|
246
|
+
out.append(g.raw)
|
|
247
|
+
return out
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def preprocess(path: Path, profile: Profile, *, expand_includes: bool, stop: tuple[str, int] | None = None) -> bytes:
|
|
251
|
+
"""The `-p` (`expand_includes=False`) or `-i` (`expand_includes=True`) output bytes for
|
|
252
|
+
`path`: CRLF, one trailing CRLF, `latin-1`. With `stop` (file name, line), the output ends with
|
|
253
|
+
that statement: where a dropped pass-1 error ends the reference compile."""
|
|
254
|
+
path = Path(path)
|
|
255
|
+
data = path.read_bytes()
|
|
256
|
+
groups = _read_groups(_split_physical(data.decode("latin-1")))
|
|
257
|
+
parser_instance = _Parser(profile, Diagnostics())
|
|
258
|
+
lines = _walk_file(
|
|
259
|
+
groups,
|
|
260
|
+
path.name,
|
|
261
|
+
None,
|
|
262
|
+
path.parent,
|
|
263
|
+
parser_instance,
|
|
264
|
+
expand_includes,
|
|
265
|
+
False,
|
|
266
|
+
frozenset({str(path.resolve())}),
|
|
267
|
+
None if stop is None else {"at": (stop[0].lower(), stop[1]), "hit": False},
|
|
268
|
+
)
|
|
269
|
+
return ("\r\n".join(lines) + "\r\n").encode("latin-1", "replace")
|