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.
Files changed (117) hide show
  1. opencrbasic/__init__.py +3 -0
  2. opencrbasic/__main__.py +5 -0
  3. opencrbasic/analysis.py +184 -0
  4. opencrbasic/bindings.py +1034 -0
  5. opencrbasic/catalog.py +173 -0
  6. opencrbasic/checkconfig.py +144 -0
  7. opencrbasic/checker/__init__.py +977 -0
  8. opencrbasic/checker/cr3xx.py +3118 -0
  9. opencrbasic/checker/ess.py +81 -0
  10. opencrbasic/checker/expressions.py +3581 -0
  11. opencrbasic/checker/instr_data.py +1802 -0
  12. opencrbasic/checker/instr_measure.py +1547 -0
  13. opencrbasic/checker/instr_rules.py +3540 -0
  14. opencrbasic/checker/instructions.py +17 -0
  15. opencrbasic/checker/intervals.py +678 -0
  16. opencrbasic/checker/longconv.py +133 -0
  17. opencrbasic/checker/scan_time.py +580 -0
  18. opencrbasic/checker/storage.py +456 -0
  19. opencrbasic/checker/structs.py +240 -0
  20. opencrbasic/checker/structure.py +6213 -0
  21. opencrbasic/checker/symbols.py +609 -0
  22. opencrbasic/checker/terminals.py +249 -0
  23. opencrbasic/checker/unused.py +186 -0
  24. opencrbasic/checker/var_types.py +564 -0
  25. opencrbasic/checker/warnings.py +661 -0
  26. opencrbasic/checks.py +491 -0
  27. opencrbasic/cli.py +391 -0
  28. opencrbasic/commands.py +128 -0
  29. opencrbasic/compiler.py +320 -0
  30. opencrbasic/consteval.py +327 -0
  31. opencrbasic/data/background_calibration.json +4 -0
  32. opencrbasic/data/before_beginprog.json +99 -0
  33. opencrbasic/data/cr1000x/background_calibration.json +4 -0
  34. opencrbasic/data/cr1000x/before_beginprog.json +138 -0
  35. opencrbasic/data/cr1000x/ess_variables.json +1087 -0
  36. opencrbasic/data/cr1000x/fnotch.json +29 -0
  37. opencrbasic/data/cr1000x/functions.json +1337 -0
  38. opencrbasic/data/cr1000x/instructions.json +4836 -0
  39. opencrbasic/data/cr1000x/measure.json +121 -0
  40. opencrbasic/data/cr1000x/mode.json +111 -0
  41. opencrbasic/data/cr1000x/pipeline_tasks.json +88 -0
  42. opencrbasic/data/cr1000x/settings_fields.json +321 -0
  43. opencrbasic/data/cr1000x/status_members.json +318 -0
  44. opencrbasic/data/cr1000x/symbols.json +872 -0
  45. opencrbasic/data/cr1000x/terminals.json +225 -0
  46. opencrbasic/data/cr300/before_beginprog.json +42 -0
  47. opencrbasic/data/cr300/ess_variables.json +1087 -0
  48. opencrbasic/data/cr300/fnotch.json +22 -0
  49. opencrbasic/data/cr300/functions.json +1261 -0
  50. opencrbasic/data/cr300/instructions.json +2837 -0
  51. opencrbasic/data/cr300/measure.json +33 -0
  52. opencrbasic/data/cr300/mode.json +121 -0
  53. opencrbasic/data/cr300/settings_fields.json +259 -0
  54. opencrbasic/data/cr300/status_members.json +303 -0
  55. opencrbasic/data/cr300/symbols.json +584 -0
  56. opencrbasic/data/cr300/terminals.json +193 -0
  57. opencrbasic/data/cr350/before_beginprog.json +44 -0
  58. opencrbasic/data/cr350/ess_variables.json +1087 -0
  59. opencrbasic/data/cr350/fnotch.json +22 -0
  60. opencrbasic/data/cr350/functions.json +1262 -0
  61. opencrbasic/data/cr350/instructions.json +2848 -0
  62. opencrbasic/data/cr350/measure.json +35 -0
  63. opencrbasic/data/cr350/mode.json +121 -0
  64. opencrbasic/data/cr350/settings_fields.json +292 -0
  65. opencrbasic/data/cr350/status_members.json +303 -0
  66. opencrbasic/data/cr350/symbols.json +572 -0
  67. opencrbasic/data/cr350/terminals.json +190 -0
  68. opencrbasic/data/ess_variables.json +1087 -0
  69. opencrbasic/data/fnotch.json +34 -0
  70. opencrbasic/data/functions.json +1337 -0
  71. opencrbasic/data/instructions.json +4965 -0
  72. opencrbasic/data/measure.json +123 -0
  73. opencrbasic/data/mode.json +121 -0
  74. opencrbasic/data/notices_cr6.json +890 -0
  75. opencrbasic/data/pipeline_tasks.json +84 -0
  76. opencrbasic/data/settings_fields.json +322 -0
  77. opencrbasic/data/status_members.json +319 -0
  78. opencrbasic/data/status_table.json +684 -0
  79. opencrbasic/data/symbols.json +902 -0
  80. opencrbasic/data/terminals.json +195 -0
  81. opencrbasic/data/type_codes.json +249 -0
  82. opencrbasic/diagnostics.py +196 -0
  83. opencrbasic/editing.py +827 -0
  84. opencrbasic/formatting.py +310 -0
  85. opencrbasic/helpdocs.py +677 -0
  86. opencrbasic/hover.py +667 -0
  87. opencrbasic/inlay.py +169 -0
  88. opencrbasic/lexer.py +183 -0
  89. opencrbasic/lsp/__init__.py +5 -0
  90. opencrbasic/lsp/__main__.py +7 -0
  91. opencrbasic/lsp/config.py +202 -0
  92. opencrbasic/lsp/features.py +602 -0
  93. opencrbasic/lsp/server.py +741 -0
  94. opencrbasic/lsp/session.py +1189 -0
  95. opencrbasic/lsp/worker.py +128 -0
  96. opencrbasic/mode.py +143 -0
  97. opencrbasic/navigation.py +401 -0
  98. opencrbasic/parser.py +3264 -0
  99. opencrbasic/positions.py +274 -0
  100. opencrbasic/preprocess.py +269 -0
  101. opencrbasic/profiles.py +266 -0
  102. opencrbasic/provenance.py +17 -0
  103. opencrbasic/quickfix.py +331 -0
  104. opencrbasic/rename.py +662 -0
  105. opencrbasic/signature.py +33 -0
  106. opencrbasic/source.py +600 -0
  107. opencrbasic/sources.py +125 -0
  108. opencrbasic/syntax.py +337 -0
  109. opencrbasic/tables.py +2173 -0
  110. opencrbasic/tables_hook.py +14 -0
  111. opencrbasic/tdf.py +142 -0
  112. opencrbasic/timing.py +565 -0
  113. opencrbasic-1.0.0.dist-info/METADATA +223 -0
  114. opencrbasic-1.0.0.dist-info/RECORD +117 -0
  115. opencrbasic-1.0.0.dist-info/WHEEL +4 -0
  116. opencrbasic-1.0.0.dist-info/entry_points.txt +3 -0
  117. opencrbasic-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -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")