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/signature.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""ProgSignature (`CR6Comp -s`): the PakBus signature of the program text.
|
|
2
|
+
|
|
3
|
+
CR6Comp seeds the signature with 0xAAAA, runs it over the main file's bytes, then continues it
|
|
4
|
+
over each included file's bytes in the order the includes are expanded (an `IncludeSection` of
|
|
5
|
+
the same file twice contributes the file twice). Verified against the exe: PT1000_tester.CR6
|
|
6
|
+
with its five IncludeSection expansions gives 53316 (docs/cli.md).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def csi_signature(data: bytes, seed: int = 0xAAAA) -> int:
|
|
13
|
+
sig = seed
|
|
14
|
+
for byte in data:
|
|
15
|
+
prev = sig
|
|
16
|
+
sig = (sig << 1) & 0x1FF
|
|
17
|
+
if sig >= 0x100:
|
|
18
|
+
sig += 1
|
|
19
|
+
sig = (((sig + (prev >> 8) + byte) & 0xFF) | (prev << 8)) & 0xFFFF
|
|
20
|
+
return sig
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def program_signature(path: Path, source=None) -> int:
|
|
24
|
+
from . import source as source_mod
|
|
25
|
+
from .diagnostics import Diagnostics
|
|
26
|
+
|
|
27
|
+
path = Path(path)
|
|
28
|
+
loader = source_mod._Loader(path.parent, Diagnostics(), source=source)
|
|
29
|
+
loader.load_file(path, section=None, in_include=False)
|
|
30
|
+
sig = 0xAAAA
|
|
31
|
+
for data in loader.files:
|
|
32
|
+
sig = csi_signature(data, sig)
|
|
33
|
+
return sig
|
opencrbasic/source.py
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
"""Load a CRBasic program: physical lines -> logical lines, with comments stripped, line
|
|
2
|
+
continuation joined, and `Include`/`IncludeSection` expanded.
|
|
3
|
+
|
|
4
|
+
Quirks verified against the real compiler (`tools/oracle.py`); see `docs/frontend.md`:
|
|
5
|
+
|
|
6
|
+
- A logical line built from `_` continuation reports CR6Comp's error line as the *last* physical
|
|
7
|
+
line it spans, not the first (`LogicalLine.line` is set accordingly; there is no evidence CR6Comp
|
|
8
|
+
tracks a finer position than "the whole logical line").
|
|
9
|
+
- Code pulled in by `Include`/`IncludeSection` is reported at *its own* line number within the
|
|
10
|
+
included file, never the line of the `Include`/`IncludeSection` call, and the main program's own
|
|
11
|
+
line numbers are unaffected by an inclusion (each file keeps its own line counter).
|
|
12
|
+
- `IncludeSection("Name", "Device:File")` splices in the *entire* file's text (like `Include`);
|
|
13
|
+
`Name` only matters because the file's own `#If Section = "..."` directives compare against it
|
|
14
|
+
(`LogicalLine.section`). Referencing `Section` anywhere else is an ordinary undefined-symbol
|
|
15
|
+
error, confirmed with the oracle (`Invalid symbol in expression: Section = "Foo" .`).
|
|
16
|
+
- A missing `Include`/`IncludeSection` target is only a *warning* (`Cannot open include file %s.`,
|
|
17
|
+
the double space is the real message text) and the inclusion contributes nothing; compilation
|
|
18
|
+
continues.
|
|
19
|
+
- An `Include` or `IncludeSection` inside an already-included file (either kind, any depth, a file
|
|
20
|
+
including itself too) is a hard error in CR6Comp on all four loggers: `Include file within an
|
|
21
|
+
Include file is illegal.`, reported at that statement's line in the included file. The nested
|
|
22
|
+
file is not loaded (`_nested_include`; oracle, 2026-10-12, `docs/frontend.md`).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import dataclasses
|
|
26
|
+
import re
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import NamedTuple
|
|
30
|
+
|
|
31
|
+
from .diagnostics import Diagnostics
|
|
32
|
+
from .sources import DiskSource, SourceProvider
|
|
33
|
+
|
|
34
|
+
_INCLUDE_RE = re.compile(r'^\s*Include\s*\(?\s*"([^"]*)"\s*\)?\s*$', re.IGNORECASE)
|
|
35
|
+
# A third (or later) argument is tolerated and ignored, matching the real compiler
|
|
36
|
+
# (oracle-confirmed, `docs/fuzz-findings.md` "False reject": `IncludeSection("Rail",
|
|
37
|
+
# "CPU:BenchLib.crb",(1+2))` compiles -- `(1+2)` is accepted and has no effect). `(?:,.*)?` is
|
|
38
|
+
# greedy and the trailing `\)\s*$` still anchors the match to the statement's own closing paren,
|
|
39
|
+
# so a parenthesised extra argument (nested parens included) does not break the match.
|
|
40
|
+
_INCLUDE_SECTION_RE = re.compile(r'^\s*IncludeSection\s*\(\s*"([^"]*)"\s*,\s*"([^"]*)"\s*(?:,.*)?\)\s*$', re.IGNORECASE)
|
|
41
|
+
# An `Include`/`IncludeSection` statement as the real compiler recognises it inside an included
|
|
42
|
+
# file, well formed or not (oracle, 2026-10-12, `docs/frontend.md`): the keyword followed by
|
|
43
|
+
# blank, `(`, `=` or the end of the line. `IncludeVar`, `Includes`, `Include"x"` are other tokens;
|
|
44
|
+
# a lone `IncludeSection` crashes CR6Comp (no verdict) and is left alone.
|
|
45
|
+
_NESTED_INCLUDE_RE = re.compile(r"^\s*(?:Include(?:Section)?(?=[\s(=])|Include\s*$)", re.IGNORECASE)
|
|
46
|
+
_DRIVE_RE = re.compile(r"^(CPU|USR|CRD|USB):", re.IGNORECASE)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Piece(NamedTuple):
|
|
50
|
+
"""Provenance: `text[start:end]` of a `LogicalLine` is the decoded characters of physical line
|
|
51
|
+
`line` (1-based, the true line in its file) from character `col` (0-based) on. Characters of
|
|
52
|
+
`text` that no piece covers were inserted by the loader (the `" = "`/`" "` joins of a pull)."""
|
|
53
|
+
|
|
54
|
+
start: int
|
|
55
|
+
end: int
|
|
56
|
+
file: str # the file's display name, as `LogicalLine.file`
|
|
57
|
+
line: int
|
|
58
|
+
col: int
|
|
59
|
+
occurrence: int # which load of that file (`Occurrence.id`)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Occurrence(NamedTuple):
|
|
63
|
+
"""One load of a file: a file included twice has two. `parent` is the occurrence whose
|
|
64
|
+
`Include`/`IncludeSection` statement loaded it (None: the entry) and `site` the physical line
|
|
65
|
+
span (first, last) of that statement in the parent."""
|
|
66
|
+
|
|
67
|
+
id: int
|
|
68
|
+
file: str
|
|
69
|
+
path: Path | None
|
|
70
|
+
parent: int | None
|
|
71
|
+
site: tuple[int, int] | None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class LineSpan(NamedTuple):
|
|
75
|
+
"""The physical lines (first, last) of a logical line reported at some line of its file."""
|
|
76
|
+
|
|
77
|
+
first: int
|
|
78
|
+
last: int
|
|
79
|
+
occurrence: int
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _clip(pieces: list[Piece], n: int) -> list[Piece]:
|
|
83
|
+
out = []
|
|
84
|
+
for p in pieces:
|
|
85
|
+
if p.start >= n:
|
|
86
|
+
break
|
|
87
|
+
out.append(p if p.end <= n else p._replace(end=n))
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class LogicalLine:
|
|
93
|
+
text: str # joined, comment-stripped source text
|
|
94
|
+
line: int # physical line to report (see module docstring)
|
|
95
|
+
file: str # originating file's name, for our own richer diagnostics
|
|
96
|
+
section: str | None = None # the Name argument of the enclosing IncludeSection(), if any
|
|
97
|
+
had_trailing_comment: bool = False # the *last* physical line contributing to `text` had a
|
|
98
|
+
# `'` comment stripped off it -- so `text` stops short of that physical line's real end, rather
|
|
99
|
+
# than reaching it. Lets the generic "Invalid, or out of place expression" fallback's own
|
|
100
|
+
# CR300/CR350 newline quirk (`checker/structure.py`'s `_out_of_place`) tell a statement that
|
|
101
|
+
# truly runs to the physical end of line from one a trailing comment cut short (oracle-
|
|
102
|
+
# confirmed, `docs/loggers.md` "Generic fallback echo on CR300/CR350").
|
|
103
|
+
kind: str = "code" # code | overflow | include_begin | include_end | include_warning | include_error
|
|
104
|
+
pulls: int = 0 # CR6/CR1000X initializer pull: physical lines folded into `text`
|
|
105
|
+
unpulled: list[LogicalLine] | None = None # the lines as written, for when `text` is not read
|
|
106
|
+
bad_init: tuple[str, str] | None = None # (message, variable) reported only when read
|
|
107
|
+
# Provenance (positions only; nothing in the compiler reads these): see `positions.py`.
|
|
108
|
+
pieces: tuple[Piece, ...] = () # where each character of `text` came from, in order
|
|
109
|
+
phys: tuple[int, int] | None = None # physical lines (first, last) the statement spans
|
|
110
|
+
occurrence: int = 0 # `Occurrence.id` of the load this line belongs to
|
|
111
|
+
stops_includes: bool = False # an `IncludeSection` extension error (`parser`, round 66)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _split_physical_lines(raw: str) -> list[str]:
|
|
115
|
+
return raw.replace("\r\n", "\n").split("\n")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _strip_comment(line: str) -> str:
|
|
119
|
+
"""Remove a trailing `'` comment, respecting `"..."` strings (with `""` escapes)."""
|
|
120
|
+
in_string = False
|
|
121
|
+
i = 0
|
|
122
|
+
n = len(line)
|
|
123
|
+
while i < n:
|
|
124
|
+
ch = line[i]
|
|
125
|
+
if in_string:
|
|
126
|
+
if ch == '"':
|
|
127
|
+
if i + 1 < n and line[i + 1] == '"':
|
|
128
|
+
i += 2
|
|
129
|
+
continue
|
|
130
|
+
in_string = False
|
|
131
|
+
i += 1
|
|
132
|
+
continue
|
|
133
|
+
if ch == '"':
|
|
134
|
+
in_string = True
|
|
135
|
+
i += 1
|
|
136
|
+
continue
|
|
137
|
+
if ch == "'":
|
|
138
|
+
return line[:i]
|
|
139
|
+
i += 1
|
|
140
|
+
return line
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _is_continuation(line: str) -> tuple[bool, str]:
|
|
144
|
+
"""`(True, prefix)` when `line` ends with whitespace + `_`; `prefix` has it removed."""
|
|
145
|
+
t = line.rstrip(" \t")
|
|
146
|
+
if t.endswith("_") and (len(t) == 1 or t[-2] in " \t"):
|
|
147
|
+
return True, t[:-1].rstrip(" \t")
|
|
148
|
+
return False, line
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _resolve_include_path(spec: str, directory: Path | None, source: SourceProvider | None = None) -> Path | None:
|
|
152
|
+
"""The file `spec` names, searched from the main program's directory. `/` and `\\` both
|
|
153
|
+
separate directories; `.` and empty parts are skipped and `..` cancels the part before it, even a missing one (oracle, 2026-10-12,
|
|
154
|
+
all four loggers: `CPU:sub/Lib.crb`, `CPU:sub\\Lib.crb`, `./`, `..`, `a/b\\Lib.crb` and
|
|
155
|
+
`sub//Lib.crb` open the file, `docs/checker.md` "Include paths with a directory"). A spec
|
|
156
|
+
starting with a separator, or naming a drive-like part, is not found. Names match
|
|
157
|
+
case-insensitively."""
|
|
158
|
+
name = _DRIVE_RE.sub("", spec.strip())
|
|
159
|
+
if directory is None or not name or name[0] in "/\\":
|
|
160
|
+
return None
|
|
161
|
+
source = source or DiskSource()
|
|
162
|
+
*folders, leaf = name.replace("\\", "/").split("/")
|
|
163
|
+
parts: list[str] = []
|
|
164
|
+
for part in folders:
|
|
165
|
+
if part in ("", "."):
|
|
166
|
+
continue
|
|
167
|
+
if ":" in part:
|
|
168
|
+
return None
|
|
169
|
+
if part == "..": # lexical: `nosub/../Lib.crb` opens `Lib.crb` (oracle, 2026-10-12)
|
|
170
|
+
if parts:
|
|
171
|
+
parts.pop()
|
|
172
|
+
else:
|
|
173
|
+
directory = directory.absolute().parent
|
|
174
|
+
else:
|
|
175
|
+
parts.append(part)
|
|
176
|
+
for part in parts:
|
|
177
|
+
directory = _child_directory(directory, part, source)
|
|
178
|
+
if directory is None:
|
|
179
|
+
return None
|
|
180
|
+
if leaf in ("", ".", "..") or ":" in leaf:
|
|
181
|
+
return None
|
|
182
|
+
names = source.list_dir(directory)
|
|
183
|
+
if names is None:
|
|
184
|
+
return None
|
|
185
|
+
for entry in names:
|
|
186
|
+
if entry.lower() == leaf.lower():
|
|
187
|
+
return directory / entry
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _child_directory(directory: Path, part: str, source: SourceProvider) -> Path | None:
|
|
192
|
+
"""`directory/part`, matched case-insensitively when the provider can list subdirectories."""
|
|
193
|
+
exact = directory / part
|
|
194
|
+
if source.list_dir(exact) is not None:
|
|
195
|
+
return exact
|
|
196
|
+
subdirs = getattr(source, "list_subdirs", None)
|
|
197
|
+
for entry in (subdirs(directory) if subdirs else None) or ():
|
|
198
|
+
if entry.lower() == part.lower():
|
|
199
|
+
return directory / entry
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
_DECL_START_RE = re.compile(r"^\s*(?:Public|Dim)\b", re.IGNORECASE)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _mask_strings(text: str) -> str:
|
|
207
|
+
return re.sub(r'"[^"]*"', lambda m: "s" * len(m.group()), text)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _split_top_commas(text: str) -> list[str]:
|
|
211
|
+
parts, depth, start = [], 0, 0
|
|
212
|
+
for k, ch in enumerate(_mask_strings(text)):
|
|
213
|
+
if ch in "({":
|
|
214
|
+
depth += 1
|
|
215
|
+
elif ch in ")}":
|
|
216
|
+
depth -= 1
|
|
217
|
+
elif ch == "," and depth == 0:
|
|
218
|
+
parts.append(text[start:k])
|
|
219
|
+
start = k + 1
|
|
220
|
+
parts.append(text[start:])
|
|
221
|
+
return parts
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _pull_decl_initializer(text: str, physical: list[str], i: int, trace: list | None = None):
|
|
225
|
+
"""CR6/CR1000X `Public`/`Dim` initializer line pulling (oracle, 2026-10-10, `docs/checker.md`
|
|
226
|
+
"Round 7 CR6/CR1000X help-corpus families"): a declaration ending in `=` pulls the next line
|
|
227
|
+
into itself, and a brace list ending in `,` or `{` keeps pulling; every pulled line is
|
|
228
|
+
consumed without advancing the reported line counter. Returns `(text, i, pulls, bad)`; `bad`
|
|
229
|
+
is `(message, variable)` when the pulled line opens with `{` after a trailing `=` on an array.
|
|
230
|
+
`trace`, when given, receives the provenance edits in order: `("cut", n)` keeps the first `n`
|
|
231
|
+
characters of `text`; `("add", gap, index, col, length)` appends `gap` inserted characters and
|
|
232
|
+
then `length` characters of physical line index `index` from column `col`."""
|
|
233
|
+
pulls = 0
|
|
234
|
+
n = len(physical)
|
|
235
|
+
|
|
236
|
+
def next_text() -> tuple[str, str] | None:
|
|
237
|
+
if i + pulls + 1 >= n:
|
|
238
|
+
return None
|
|
239
|
+
raw = physical[i + pulls + 1]
|
|
240
|
+
stripped = _strip_comment(raw)
|
|
241
|
+
return None if stripped.strip() == "" else (raw, stripped)
|
|
242
|
+
|
|
243
|
+
masked = _mask_strings(text).rstrip()
|
|
244
|
+
if masked.endswith("=") and not masked.endswith(("<=", ">=")):
|
|
245
|
+
nxt = next_text()
|
|
246
|
+
if nxt is None:
|
|
247
|
+
return text, i, 0, None
|
|
248
|
+
raw, stripped = nxt
|
|
249
|
+
head = text.rstrip()[:-1].rstrip()
|
|
250
|
+
if trace is not None:
|
|
251
|
+
trace.append(("cut", len(head)))
|
|
252
|
+
body = stripped.lstrip(" \t")
|
|
253
|
+
names = _split_top_commas(head)
|
|
254
|
+
first = names[-1]
|
|
255
|
+
if len(names) == 1:
|
|
256
|
+
first = re.sub(r"^\s*(?:Public|Dim)\b", "", first, count=1, flags=re.I)
|
|
257
|
+
m = re.match(r"\s*(\w+)\s*(\(?)", first)
|
|
258
|
+
unbal = body.count("{") > body.count("}") and _mask_strings(body).rstrip().endswith((",", "{"))
|
|
259
|
+
if body.startswith("{") and m and (m.group(2) or unbal):
|
|
260
|
+
seg = _split_top_commas(body)
|
|
261
|
+
echo = seg[0] + ("\n" if len(seg) == 1 and len(stripped) == len(raw) else "")
|
|
262
|
+
kind = "Invalid, or out of place expression" if seg[0].strip() == "{" else "Incomplete expression"
|
|
263
|
+
return head, i + 1, 1, (f"{kind}: {echo}.", m.group(1))
|
|
264
|
+
if not body.startswith("{"):
|
|
265
|
+
body = _split_top_commas(body)[0] # the rest of the pulled line is dropped, unread
|
|
266
|
+
if trace is not None:
|
|
267
|
+
trace.append(("add", 3, i + 1, len(stripped) - len(stripped.lstrip(" \t")), len(body)))
|
|
268
|
+
return head + " = " + body, i + 1, 1, None
|
|
269
|
+
while True:
|
|
270
|
+
m = _mask_strings(text).rstrip()
|
|
271
|
+
after = m[m.rfind("=") + 1 :] if "=" in m else ""
|
|
272
|
+
if not (after.count("{") > after.count("}") and m.endswith((",", "{"))):
|
|
273
|
+
break
|
|
274
|
+
nxt = next_text()
|
|
275
|
+
if nxt is None:
|
|
276
|
+
break
|
|
277
|
+
if trace is not None:
|
|
278
|
+
trace.append(("cut", len(text.rstrip())))
|
|
279
|
+
trace.append(("add", 1, i + pulls + 1, len(nxt[1]) - len(nxt[1].lstrip(" \t")), len(nxt[1].lstrip(" \t"))))
|
|
280
|
+
text = text.rstrip() + " " + nxt[1].lstrip(" \t")
|
|
281
|
+
pulls += 1
|
|
282
|
+
return text, i + pulls, pulls, None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def bad_include_extension(spec: str, own_ext: str | None, cr3xx_exts: tuple[str, ...] | None) -> bool:
|
|
286
|
+
"""Shared by literal includes in the loader and folded include names in the parser."""
|
|
287
|
+
if cr3xx_exts is not None:
|
|
288
|
+
ext = spec.rpartition(".")[2].upper() if "." in spec else ""
|
|
289
|
+
return (len(ext) == 3 and ext != "CRB") or (len(ext) == 5 and ext.startswith("CR3") and ext not in cr3xx_exts)
|
|
290
|
+
return own_ext is not None and not spec.rpartition(".")[2].upper().startswith(("CRB", own_ext))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class _Loader:
|
|
294
|
+
"""Turns one file's raw text into `LogicalLine`s, expanding includes as it goes."""
|
|
295
|
+
|
|
296
|
+
def __init__(
|
|
297
|
+
self,
|
|
298
|
+
directory: Path | None,
|
|
299
|
+
diags: Diagnostics,
|
|
300
|
+
own_ext: str | None = None,
|
|
301
|
+
pull_init: bool = False,
|
|
302
|
+
cr3xx_exts: tuple[str, ...] | None = None,
|
|
303
|
+
source: SourceProvider | None = None,
|
|
304
|
+
provenance: bool = True,
|
|
305
|
+
):
|
|
306
|
+
self.provenance = provenance # False: skip `LogicalLine.pieces` (only the language server reads them)
|
|
307
|
+
self.source = source or DiskSource()
|
|
308
|
+
self.includes: dict[str, Path] = {} # include display name -> the path it resolved to
|
|
309
|
+
self.directory = directory
|
|
310
|
+
self.pull_init = pull_init
|
|
311
|
+
self.pull_limit: tuple[int, ...] | None = None # pulls only at positions below this (CR300/CR350)
|
|
312
|
+
self.pull_log: list[tuple[tuple[int, ...], tuple[int, ...]]] = [] # (position, end position) of pulls
|
|
313
|
+
self.site: dict[str, tuple[int, ...]] = {} # included file -> position of its Include line
|
|
314
|
+
self.diags = diags
|
|
315
|
+
self.own_ext = own_ext # CR6/CR1000X only: the logger's own program extension
|
|
316
|
+
self.cr3xx_exts = cr3xx_exts # CR300/CR350 only: the extensions an include may load
|
|
317
|
+
self.files: list[bytes] = [] # every file read, in order (ProgSignature input)
|
|
318
|
+
self.occurrences: list[Occurrence] = [] # every load of a file, in order (provenance)
|
|
319
|
+
self._cur: int | None = None # the occurrence being loaded (provenance)
|
|
320
|
+
self._path: Path | None = None # path of the file `load_file` is about to load
|
|
321
|
+
self._site_phys: tuple[int, int] | None = None # physical span of the include being expanded
|
|
322
|
+
|
|
323
|
+
def load_file(
|
|
324
|
+
self, path: Path, *, section: str | None, in_include: bool, display: str | None = None
|
|
325
|
+
) -> list[LogicalLine]:
|
|
326
|
+
"""`display` is an included file's name as written in the `Include`, drive included
|
|
327
|
+
(`CPU:Lib.crb`); `diagnostics.Diagnostic.cr6comp` renders it per logger."""
|
|
328
|
+
data = self.source.read(path)
|
|
329
|
+
if data is None:
|
|
330
|
+
return []
|
|
331
|
+
self.files.append(data)
|
|
332
|
+
saved = self._cur
|
|
333
|
+
self._path = path
|
|
334
|
+
try:
|
|
335
|
+
return self.load_text(data.decode("latin-1"), display or path.name, section=section, in_include=in_include)
|
|
336
|
+
finally:
|
|
337
|
+
self._cur = saved
|
|
338
|
+
|
|
339
|
+
def load_text(self, raw: str, filename: str, *, section: str | None, in_include: bool) -> list[LogicalLine]:
|
|
340
|
+
physical = _split_physical_lines(raw)
|
|
341
|
+
prov = self.provenance
|
|
342
|
+
occ = len(self.occurrences)
|
|
343
|
+
self.occurrences.append(
|
|
344
|
+
Occurrence(occ, filename, self._path, self._cur, self._site_phys if self._cur is not None else None)
|
|
345
|
+
)
|
|
346
|
+
self._cur, self._path = occ, None
|
|
347
|
+
out: list[LogicalLine] = []
|
|
348
|
+
i = 0
|
|
349
|
+
n = len(physical)
|
|
350
|
+
# the empty piece after a final newline is no line: a continuation into it joins nothing
|
|
351
|
+
real_n = n - 1 if n > 1 and physical[-1] == "" else n
|
|
352
|
+
shift = 0
|
|
353
|
+
while i < n:
|
|
354
|
+
start_line = i + 1
|
|
355
|
+
raw_line = physical[i]
|
|
356
|
+
buffer_text = raw_line
|
|
357
|
+
# The buffer includes an LF when present; see docs/frontend.md#source-line-buffer.
|
|
358
|
+
if len(buffer_text) + (i < n - 1) > 510:
|
|
359
|
+
out.append(
|
|
360
|
+
LogicalLine(
|
|
361
|
+
"",
|
|
362
|
+
start_line - shift,
|
|
363
|
+
filename,
|
|
364
|
+
section,
|
|
365
|
+
kind="overflow",
|
|
366
|
+
phys=(start_line, start_line),
|
|
367
|
+
occurrence=occ,
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
break
|
|
371
|
+
text = _strip_comment(raw_line)
|
|
372
|
+
had_comment = len(text) != len(raw_line)
|
|
373
|
+
pieces = [Piece(0, len(text), filename, start_line, 0, occ)] if text and prov else []
|
|
374
|
+
last_line = start_line
|
|
375
|
+
while True:
|
|
376
|
+
cont, prefix = _is_continuation(text)
|
|
377
|
+
if cont and i + 1 >= real_n:
|
|
378
|
+
text = "" # a continuation into the end of the file: the statement is not read
|
|
379
|
+
pieces = []
|
|
380
|
+
break
|
|
381
|
+
if not cont or i + 1 >= n:
|
|
382
|
+
text = prefix if cont else text
|
|
383
|
+
pieces = _clip(pieces, len(text)) if prov else pieces
|
|
384
|
+
break
|
|
385
|
+
buffer_text = _strip_comment(buffer_text).rstrip(" \t")[:-1]
|
|
386
|
+
i += 1
|
|
387
|
+
last_line = i + 1
|
|
388
|
+
raw_line = physical[i]
|
|
389
|
+
buffer_text += raw_line
|
|
390
|
+
if len(buffer_text) + (i < n - 1) > 510:
|
|
391
|
+
out.append(
|
|
392
|
+
LogicalLine(
|
|
393
|
+
"",
|
|
394
|
+
last_line - shift,
|
|
395
|
+
filename,
|
|
396
|
+
section,
|
|
397
|
+
kind="overflow",
|
|
398
|
+
phys=(start_line, last_line),
|
|
399
|
+
occurrence=occ,
|
|
400
|
+
)
|
|
401
|
+
)
|
|
402
|
+
return out
|
|
403
|
+
stripped = _strip_comment(raw_line)
|
|
404
|
+
had_comment = len(stripped) != len(raw_line)
|
|
405
|
+
# the text before the `_` with its blanks, then the next line verbatim, no
|
|
406
|
+
# separator (oracle, 2026-10-10, `docs/checker.md` "Round 7"; after a text part
|
|
407
|
+
# too: `foo bar _` / ` baz` echoes `foo bar baz`)
|
|
408
|
+
lone = (
|
|
409
|
+
not prefix.strip() and _is_continuation(stripped)[0] and not _is_continuation(stripped)[1].strip()
|
|
410
|
+
)
|
|
411
|
+
joined = len(text.rstrip(" \t")) - 1
|
|
412
|
+
text = stripped if lone else text.rstrip(" \t")[:-1] + stripped
|
|
413
|
+
pieces = [] if lone or not prov else _clip(pieces, joined)
|
|
414
|
+
if stripped and prov:
|
|
415
|
+
pieces.append(Piece(0 if lone else joined, len(text), filename, last_line, 0, occ))
|
|
416
|
+
if text.strip() == "":
|
|
417
|
+
i += 1
|
|
418
|
+
continue
|
|
419
|
+
pulls, bad = 0, None
|
|
420
|
+
orig = text
|
|
421
|
+
orig_pieces = pieces
|
|
422
|
+
pos = (*self.site.get(filename, ()), start_line - shift)
|
|
423
|
+
if self.pull_init and _DECL_START_RE.match(text) and (self.pull_limit is None or pos < self.pull_limit):
|
|
424
|
+
trace: list = []
|
|
425
|
+
text, i, pulls, bad = _pull_decl_initializer(text, physical, i, trace)
|
|
426
|
+
size = len(orig)
|
|
427
|
+
for edit in trace if prov else ():
|
|
428
|
+
if edit[0] == "cut":
|
|
429
|
+
pieces, size = _clip(pieces, edit[1]), edit[1]
|
|
430
|
+
else:
|
|
431
|
+
_, gap, index, col, length = edit
|
|
432
|
+
pieces = pieces + [Piece(size + gap, size + gap + length, filename, index + 1, col, occ)]
|
|
433
|
+
size += gap + length
|
|
434
|
+
if pulls:
|
|
435
|
+
self.pull_log.append((pos, (*self.site.get(filename, ()), last_line - shift)))
|
|
436
|
+
if pulls:
|
|
437
|
+
had_comment = len(_strip_comment(physical[i])) != len(physical[i])
|
|
438
|
+
self._site_phys = (start_line, i + 1)
|
|
439
|
+
new = self._expand_or_keep(text, last_line - shift, filename, section, in_include, had_comment)
|
|
440
|
+
for made in new:
|
|
441
|
+
if made.phys is None:
|
|
442
|
+
made.phys, made.occurrence = (start_line, i + 1), occ
|
|
443
|
+
if len(new) == 1 and new[0].kind == "code":
|
|
444
|
+
new[0].pieces = tuple(pieces)
|
|
445
|
+
if pulls and len(new) == 1 and new[0].kind == "code":
|
|
446
|
+
# the parser decides whether this text is read (not in an inactive #If, not after
|
|
447
|
+
# EndProg; oracle, 2026-10-10) and otherwise restores the lines as written
|
|
448
|
+
base = last_line - shift - pulls
|
|
449
|
+
new[0].pulls, new[0].bad_init = pulls, bad
|
|
450
|
+
new[0].unpulled = [
|
|
451
|
+
LogicalLine(
|
|
452
|
+
orig,
|
|
453
|
+
base,
|
|
454
|
+
filename,
|
|
455
|
+
section,
|
|
456
|
+
pieces=tuple(orig_pieces),
|
|
457
|
+
phys=(start_line, last_line),
|
|
458
|
+
occurrence=occ,
|
|
459
|
+
)
|
|
460
|
+
] + [
|
|
461
|
+
LogicalLine(
|
|
462
|
+
_strip_comment(physical[last_line - 1 + k]),
|
|
463
|
+
base + k,
|
|
464
|
+
filename,
|
|
465
|
+
section,
|
|
466
|
+
pieces=(self._whole(physical[last_line - 1 + k], filename, last_line + k, occ) if prov else ()),
|
|
467
|
+
phys=(last_line + k, last_line + k),
|
|
468
|
+
occurrence=occ,
|
|
469
|
+
)
|
|
470
|
+
for k in range(1, pulls + 1)
|
|
471
|
+
]
|
|
472
|
+
out.extend(new)
|
|
473
|
+
shift += pulls
|
|
474
|
+
i += 1
|
|
475
|
+
return out
|
|
476
|
+
|
|
477
|
+
@staticmethod
|
|
478
|
+
def _whole(raw: str, filename: str, line: int, occ: int) -> tuple[Piece, ...]:
|
|
479
|
+
n = len(_strip_comment(raw))
|
|
480
|
+
return (Piece(0, n, filename, line, 0, occ),) if n else ()
|
|
481
|
+
|
|
482
|
+
def _expand_or_keep(
|
|
483
|
+
self,
|
|
484
|
+
text: str,
|
|
485
|
+
line: int,
|
|
486
|
+
filename: str,
|
|
487
|
+
section: str | None,
|
|
488
|
+
in_include: bool,
|
|
489
|
+
had_comment: bool = False,
|
|
490
|
+
) -> list[LogicalLine]:
|
|
491
|
+
if in_include and _NESTED_INCLUDE_RE.match(text):
|
|
492
|
+
return [self._nested_include(line, filename, section)]
|
|
493
|
+
m = _INCLUDE_SECTION_RE.match(text)
|
|
494
|
+
if m:
|
|
495
|
+
name, spec = m.group(1), m.group(2)
|
|
496
|
+
if self._bad_extension(spec):
|
|
497
|
+
return [dataclasses.replace(self._extension_error(spec, line, filename, section), stops_includes=True)]
|
|
498
|
+
target = None if self._unloadable(spec) else _resolve_include_path(spec, self.directory, self.source)
|
|
499
|
+
if target is None:
|
|
500
|
+
return [self._missing(spec, line, filename, section)]
|
|
501
|
+
self.site.setdefault(spec.strip(), (*self.site.get(filename, ()), line))
|
|
502
|
+
self.includes.setdefault(spec.strip(), target)
|
|
503
|
+
included = self.load_file(target, section=name, in_include=True, display=spec.strip())
|
|
504
|
+
return self._include_lines(included, line, filename, section)
|
|
505
|
+
m = _INCLUDE_RE.match(text)
|
|
506
|
+
if m:
|
|
507
|
+
spec = m.group(1)
|
|
508
|
+
if self._bad_extension(spec):
|
|
509
|
+
return [self._extension_error(spec, line, filename, section)]
|
|
510
|
+
target = None if self._unloadable(spec) else _resolve_include_path(spec, self.directory, self.source)
|
|
511
|
+
if target is None:
|
|
512
|
+
return [self._missing(spec, line, filename, section)]
|
|
513
|
+
self.site.setdefault(spec.strip(), (*self.site.get(filename, ()), line))
|
|
514
|
+
self.includes.setdefault(spec.strip(), target)
|
|
515
|
+
included = self.load_file(target, section=None, in_include=True, display=spec.strip())
|
|
516
|
+
return self._include_lines(included, line, filename, section)
|
|
517
|
+
return [LogicalLine(text, line, filename, section, had_trailing_comment=had_comment)]
|
|
518
|
+
|
|
519
|
+
def _bad_extension(self, spec: str) -> bool:
|
|
520
|
+
"""CR6/CR1000X: an include name whose text after the last `.` (none: empty) starts with
|
|
521
|
+
neither `crb` nor the logger's own extension is an error whether or not the file exists, and
|
|
522
|
+
an existing file contributes nothing (oracle, 2026-10-08/10)."""
|
|
523
|
+
return bad_include_extension(spec, self.own_ext, self.cr3xx_exts)
|
|
524
|
+
|
|
525
|
+
def _unloadable(self, spec: str) -> bool:
|
|
526
|
+
"""CR300/CR350: an include whose extension passes `_bad_extension` still opens only with
|
|
527
|
+
`crb` or the logger's own extension; anything else is the missing-file warning even if the
|
|
528
|
+
file exists (oracle, 2026-10-10, `docs/checker.md` "Round 9")."""
|
|
529
|
+
ext = spec.rpartition(".")[2].upper() if "." in spec else ""
|
|
530
|
+
return self.cr3xx_exts is not None and ext != "CRB" and ext not in self.cr3xx_exts
|
|
531
|
+
|
|
532
|
+
@staticmethod
|
|
533
|
+
def _nested_include(line: int, filename: str, section: str | None) -> LogicalLine:
|
|
534
|
+
"""An include inside an included file is an error whatever it names (a marker like the
|
|
535
|
+
other include errors: not reported after `EndProg` or in an inactive `#If`)."""
|
|
536
|
+
return LogicalLine(
|
|
537
|
+
"Include file within an Include file is illegal.", line, filename, section, kind="include_error"
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
@staticmethod
|
|
541
|
+
def _extension_error(spec: str, line: int, filename: str, section: str | None) -> LogicalLine:
|
|
542
|
+
return LogicalLine(
|
|
543
|
+
f"Include file {spec} must have valid program file extension.",
|
|
544
|
+
line,
|
|
545
|
+
filename,
|
|
546
|
+
section,
|
|
547
|
+
kind="include_error",
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
@staticmethod
|
|
551
|
+
def _missing(spec: str, line: int, filename: str, section: str | None) -> LogicalLine:
|
|
552
|
+
"""A missing include is a marker the parser reports only if it reads that far: text after
|
|
553
|
+
`EndProg` or in an inactive `#If` branch is never looked at (oracle, 2026-10-10)."""
|
|
554
|
+
return LogicalLine(f"Cannot open include file {spec}.", line, filename, section, kind="include_warning")
|
|
555
|
+
|
|
556
|
+
@staticmethod
|
|
557
|
+
def _include_lines(included: list[LogicalLine], line: int, filename: str, section: str | None) -> list[LogicalLine]:
|
|
558
|
+
return [
|
|
559
|
+
LogicalLine("", line, filename, section, kind="include_begin"),
|
|
560
|
+
*included,
|
|
561
|
+
LogicalLine("", line, filename, section, kind="include_end"),
|
|
562
|
+
]
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def load_program(
|
|
566
|
+
path: Path,
|
|
567
|
+
own_ext: str | None = None,
|
|
568
|
+
pull_init: bool = False,
|
|
569
|
+
pull_limit: tuple[int, ...] | None = None,
|
|
570
|
+
cr3xx_exts: tuple[str, ...] | None = None,
|
|
571
|
+
source: SourceProvider | None = None,
|
|
572
|
+
provenance: bool = True,
|
|
573
|
+
) -> tuple[list[LogicalLine], Diagnostics]:
|
|
574
|
+
"""Load `path` and every file it `Include`s/`IncludeSection`s, as one logical-line stream."""
|
|
575
|
+
path = Path(path)
|
|
576
|
+
diags = Diagnostics()
|
|
577
|
+
loader = _Loader(path.parent, diags, own_ext, pull_init, cr3xx_exts, source, provenance)
|
|
578
|
+
loader.pull_limit = pull_limit
|
|
579
|
+
lines = loader.load_file(path, section=None, in_include=False)
|
|
580
|
+
diags.pull_log = loader.pull_log
|
|
581
|
+
diags.pull_site = loader.site
|
|
582
|
+
diags.include_paths = loader.includes
|
|
583
|
+
diags.occurrences = loader.occurrences
|
|
584
|
+
return lines, diags
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def load_text(
|
|
588
|
+
text: str,
|
|
589
|
+
name: str = "t.cr6",
|
|
590
|
+
directory: Path | None = None,
|
|
591
|
+
own_ext: str | None = None,
|
|
592
|
+
pull_init: bool = False,
|
|
593
|
+
cr3xx_exts: tuple[str, ...] | None = None,
|
|
594
|
+
) -> tuple[list[LogicalLine], Diagnostics]:
|
|
595
|
+
"""Like `load_program`, for source text that may not be on disk (`directory` resolves includes)."""
|
|
596
|
+
diags = Diagnostics()
|
|
597
|
+
loader = _Loader(directory, diags, own_ext, pull_init, cr3xx_exts)
|
|
598
|
+
lines = loader.load_text(text, name, section=None, in_include=False)
|
|
599
|
+
diags.occurrences = loader.occurrences
|
|
600
|
+
return lines, diags
|