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/profiles.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Logger profiles: the per-model data and conventions the compiler is parameterised by.
|
|
2
|
+
|
|
3
|
+
Each profile names the directory of tables extracted from that model's compiler
|
|
4
|
+
(`tools/extract_tables.py`) and the few output conventions that differ between models.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from functools import cached_property
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
DATA = Path(__file__).resolve().parent / "data"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DottedName(str):
|
|
16
|
+
"""An undeclared name used as `Name.member` in an expression (CR300/CR350 read it as a table)."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DottedAwareMessage(str):
|
|
20
|
+
"""`{name} is not defined.`, except that a `DottedName` is `Could not find DataTable <name>.`
|
|
21
|
+
(oracle, CR300/CR350, 2026-10-11, `docs/checker.md` "Undeclared dotted names")."""
|
|
22
|
+
|
|
23
|
+
def format(self, *args, **kw):
|
|
24
|
+
name = kw.get("name")
|
|
25
|
+
if isinstance(name, DottedName):
|
|
26
|
+
return f"Could not find DataTable {name}."
|
|
27
|
+
return str.format(self, *args, **kw)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Profile:
|
|
32
|
+
name: str # "cr6", "cr1000x", ...
|
|
33
|
+
display: str # logger name as the exe prints it
|
|
34
|
+
version: str # OS version string of the reference compiler
|
|
35
|
+
date: str # build date string of the reference compiler
|
|
36
|
+
data_dir: Path
|
|
37
|
+
reports_mode: bool # True: "Compiled in <X>Mode."; False: "Compiled OK."
|
|
38
|
+
exe_name: str # reference compiler file name in compiler/
|
|
39
|
+
# An unresolved predefined-symbol name at a checked expression position (`checker/expressions.py`):
|
|
40
|
+
# CR6/CR1000X word it "Invalid symbol in expression: {name}."; CR300/CR350 use "{name} is not
|
|
41
|
+
# defined." for the same case (oracle evidence: `docs/loggers.md`). `{name}` = the identifier
|
|
42
|
+
# as written in source (not case-normalised).
|
|
43
|
+
unresolved_symbol_message: str = "Invalid symbol in expression: {name}."
|
|
44
|
+
# `checker/__init__.py`'s call to `warnings.check_early` (reachability/fnotch/background-
|
|
45
|
+
# calibration): CR6/CR1000X run it unconditionally, oracle-confirmed genuinely ungated even
|
|
46
|
+
# when a later, unrelated tier-B error exists in the same file (`docs/instructions.md` "Two
|
|
47
|
+
# ungated call sites"; the `caf5c15cd16b.CR6` fuzz reproducer explicitly shows a tier-B
|
|
48
|
+
# placement/fallback error *and* a reachability warning together on CR6). CR300/CR350 do
|
|
49
|
+
# **not** share this: an undefined-symbol-class error anywhere in the file (tier A's
|
|
50
|
+
# `check_consts`, or tier B's `expressions.check`) suppresses every `check_early` warning in
|
|
51
|
+
# the *whole* file, including ones on lines textually before the error and on statements that
|
|
52
|
+
# are themselves otherwise clean (oracle, this round, `docs/loggers.md`: `PanelTemp(PTemp,
|
|
53
|
+
# 15000)` on its own line 4 loses its own fnotch warning merely because a *different*,
|
|
54
|
+
# unrelated `TCDiff` two lines later references an undefined name; reachability shows the same
|
|
55
|
+
# suppression). A same-file error that is *not* itself an undefined-symbol-class finding (e.g.
|
|
56
|
+
# `FileManage`'s own drive-string validation, not implemented by this project but visible in
|
|
57
|
+
# the corpus's own oracle transcripts) does **not** suppress it (`tablefile_1.CR6` keeps its
|
|
58
|
+
# own fnotch warning alongside an unrelated `Invalid drive` error) -- consistent with gating
|
|
59
|
+
# `check_early` on CR300/CR350 behind the *same* "tier A and tier B both clean" condition tier
|
|
60
|
+
# C already uses, rather than a blanket "any error anywhere" rule.
|
|
61
|
+
early_warnings_gated: bool = False
|
|
62
|
+
# CR300/CR350 only (`checker/structure.py`'s `_check_call_pass2`, `docs/loggers.md`): a bare
|
|
63
|
+
# call whose head name resolves to nothing (not a declared `Sub`, built-in function, or
|
|
64
|
+
# recognised instruction -- most often an instruction name this logger's own smaller table
|
|
65
|
+
# lacks entirely, e.g. `DisplayMenu`/`CardOut`/`ACPower`) gets its own `{name} is not
|
|
66
|
+
# defined.` line immediately before the existing generic `Invalid, or out of place
|
|
67
|
+
# expression: %s.` fallback line, and this shape's before-`BeginProg` placement follow-up
|
|
68
|
+
# (`Expr illegally occurs before BeginProg.`) and pass-2 stop do **not** fire (oracle-
|
|
69
|
+
# confirmed, `docs/loggers.md`) -- unlike CR6/CR1000X, which show only the fallback line and
|
|
70
|
+
# do apply the placement follow-up/stop there (unchanged). The *other* two branches of the
|
|
71
|
+
# same check (a bare call resolving to a declared `Sub` or a built-in function) are
|
|
72
|
+
# unaffected either way -- oracle-confirmed identical on all four loggers, `docs/loggers.md`.
|
|
73
|
+
undefined_call_message: bool = False
|
|
74
|
+
# CR300/CR350: the generic `Invalid, or out of place expression: %s.` echo keeps the raw line's
|
|
75
|
+
# terminator, so the closing `.` is printed on the next line (oracle: 144 help examples,
|
|
76
|
+
# e.g. acpower_1.CR6 on cr300comp.exe; `checker/structure.py` `_out_of_place`).
|
|
77
|
+
fallback_echo_newline: bool = False
|
|
78
|
+
# CR6/CR1000X: a statement whose first token is a known name is syntax-checked in pass 1, so an
|
|
79
|
+
# unclosed `(` there is a pass-1 `No Matching, or out of place ) in expression: %s.`
|
|
80
|
+
# (`checker/structure.py`, `docs/checker.md` "Pass-1 fallback: statements that start with a
|
|
81
|
+
# known word"). CR300/CR350 report that message on every such line regardless (not modelled).
|
|
82
|
+
known_first_word_pass1: bool = True
|
|
83
|
+
# CR300/CR350 print an included file's name with its drive (`<CPU:Lib.crb>`, `<cpu:Lib.crb>`
|
|
84
|
+
# when none was written); CR6/CR1000X without it (`diagnostics.Diagnostic.cr6comp`).
|
|
85
|
+
include_drive_prefix: bool = False
|
|
86
|
+
# CR6/CR1000X: a missing main-file `Include` warning is printed before every other message of
|
|
87
|
+
# the file, whatever line the other message is on (`compiler.CompileResult.result_lines`,
|
|
88
|
+
# `docs/checker.md` "Missing include warnings come first"). CR300/CR350 print it in line order.
|
|
89
|
+
include_warning_first: bool = False
|
|
90
|
+
# CR300/CR350 report pass-2 findings (undefined names, the generic fallback) even after a
|
|
91
|
+
# pass-1 error (oracle: `Dim Z banana` then `X = Q` prints both); CR6/CR1000X do not.
|
|
92
|
+
single_pass: bool = False
|
|
93
|
+
# Identifier exceptions to the predefined-CONST namespace rule, observed in
|
|
94
|
+
# docs/checker.md#predefined-constant-name-collisions. STRING and FP2 stay reserved.
|
|
95
|
+
declarable_predefined_names: frozenset[str] = frozenset()
|
|
96
|
+
|
|
97
|
+
def _load(self, name: str) -> list[dict]:
|
|
98
|
+
return json.loads((self.data_dir / f"{name}.json").read_text())
|
|
99
|
+
|
|
100
|
+
@cached_property
|
|
101
|
+
def instructions(self) -> dict[str, dict]:
|
|
102
|
+
"""Upper-case name -> record (with `id` = table index + 1)."""
|
|
103
|
+
rows = self._load("instructions")
|
|
104
|
+
return {r["name"].upper(): {**r, "id": r["index"] + 1} for r in rows}
|
|
105
|
+
|
|
106
|
+
@cached_property
|
|
107
|
+
def functions(self) -> dict[str, dict]:
|
|
108
|
+
return {r["name"].upper(): r for r in self._load("functions")}
|
|
109
|
+
|
|
110
|
+
@cached_property
|
|
111
|
+
def symbols(self) -> dict[str, dict]:
|
|
112
|
+
return {r["name"].upper(): r for r in self._load("symbols")}
|
|
113
|
+
|
|
114
|
+
@cached_property
|
|
115
|
+
def status_members(self) -> frozenset[str]:
|
|
116
|
+
"""Upper-case `Status.<name>` read names (`data/**/status_members.json`); empty: unchecked."""
|
|
117
|
+
path = self.data_dir / "status_members.json"
|
|
118
|
+
return frozenset(json.loads(path.read_text())["names"]) if path.exists() else frozenset()
|
|
119
|
+
|
|
120
|
+
@cached_property
|
|
121
|
+
def mode_rules(self) -> dict:
|
|
122
|
+
"""Sequential/PipelineMode trigger lists (`mode.py`, `docs/modes.md`)."""
|
|
123
|
+
return json.loads((self.data_dir / "mode.json").read_text())
|
|
124
|
+
|
|
125
|
+
@cached_property
|
|
126
|
+
def pipeline_tasks(self) -> dict:
|
|
127
|
+
"""PipeLine Mode placement rules (`structure.pipeline_task_errors`) with only fixture-backed
|
|
128
|
+
instructions.
|
|
129
|
+
|
|
130
|
+
CR6/CR1000X have separate observed subsets and same-line ordering exceptions; CR300/CR350
|
|
131
|
+
have no file and therefore no placement rule.
|
|
132
|
+
"""
|
|
133
|
+
path = self.data_dir / "pipeline_tasks.json"
|
|
134
|
+
return json.loads(path.read_text()) if path.is_file() else {"tasks": {}}
|
|
135
|
+
|
|
136
|
+
@cached_property
|
|
137
|
+
def terminals(self) -> dict:
|
|
138
|
+
"""Channel-argument (`U1..U12`-style) range checks (`checker/terminals.py`)."""
|
|
139
|
+
path = self.data_dir / "terminals.json"
|
|
140
|
+
return json.loads(path.read_text()) if path.is_file() else {"channels": {}, "instructions": {}}
|
|
141
|
+
|
|
142
|
+
@cached_property
|
|
143
|
+
def fnotch(self) -> dict:
|
|
144
|
+
"""`fN1`/fnotch argument check, `docs/instructions.md`: CR300/CR350 a fixed value set
|
|
145
|
+
(warning, `checker/warnings.py`), CR6/CR1000X a minimum frequency (error, `min_hz`,
|
|
146
|
+
`checker/instr_measure.py`)."""
|
|
147
|
+
path = self.data_dir / "fnotch.json"
|
|
148
|
+
return json.loads(path.read_text()) if path.is_file() else {}
|
|
149
|
+
|
|
150
|
+
@cached_property
|
|
151
|
+
def measure(self) -> dict:
|
|
152
|
+
"""Value-set/value-range checks for `Range` codes, `SW12`, `Delay`, `CS616`,
|
|
153
|
+
`TimerInput` and `ExciteV`/`ExciteI` (`checker/instr_measure.py`, `docs/instructions.md`).
|
|
154
|
+
Absent (`{}`) means none of these are oracle-confirmed for this logger yet."""
|
|
155
|
+
path = self.data_dir / "measure.json"
|
|
156
|
+
return json.loads(path.read_text()) if path.is_file() else {}
|
|
157
|
+
|
|
158
|
+
@cached_property
|
|
159
|
+
def settings_fields(self) -> dict:
|
|
160
|
+
"""`SetSetting`/`SetStatus` field-name lists and spelling rules (`checker/instr_rules.py`'s
|
|
161
|
+
`check_settings_fields`, `docs/instructions.md` "SetSetting and SetStatus field names")."""
|
|
162
|
+
return json.loads((self.data_dir / "settings_fields.json").read_text())
|
|
163
|
+
|
|
164
|
+
@cached_property
|
|
165
|
+
def before_beginprog(self) -> frozenset[str]:
|
|
166
|
+
"""Instructions rejected with `%s illegally occurs before BeginProg.` when they are the
|
|
167
|
+
first executable statement before `BeginProg` (`checker/structure.py`, `docs/checker.md`
|
|
168
|
+
"Instructions before BeginProg"). Only oracle-probed keywords are listed."""
|
|
169
|
+
path = self.data_dir / "before_beginprog.json"
|
|
170
|
+
return frozenset(json.loads(path.read_text())["instructions"]) if path.is_file() else frozenset()
|
|
171
|
+
|
|
172
|
+
@cached_property
|
|
173
|
+
def background_calibration(self) -> dict:
|
|
174
|
+
"""`Scan` interval threshold, in whole milliseconds, below which the "background
|
|
175
|
+
calibration will be disabled" warning fires (CR6/CR1000X only, `checker/warnings.py`,
|
|
176
|
+
`docs/instructions.md`). Absent (`{}`) for CR300/CR350, which were oracle-probed down to a
|
|
177
|
+
100us scan interval with no such warning (`docs/instructions.md`)."""
|
|
178
|
+
path = self.data_dir / "background_calibration.json"
|
|
179
|
+
return json.loads(path.read_text()) if path.is_file() else {}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
_DECLARABLE_NUMERIC_TYPES = frozenset(
|
|
183
|
+
{
|
|
184
|
+
"FLOAT",
|
|
185
|
+
"LONG",
|
|
186
|
+
"BOOLEAN",
|
|
187
|
+
"IEEE8",
|
|
188
|
+
"IEEE4",
|
|
189
|
+
"BOOL8",
|
|
190
|
+
"UINT1",
|
|
191
|
+
"UINT2",
|
|
192
|
+
"UINT4",
|
|
193
|
+
"DOUBLE",
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
PROFILES = {
|
|
198
|
+
"cr6": Profile(
|
|
199
|
+
"cr6",
|
|
200
|
+
"CR6",
|
|
201
|
+
"CR6.14.2.0",
|
|
202
|
+
"03/04/2025",
|
|
203
|
+
DATA,
|
|
204
|
+
True,
|
|
205
|
+
"CR6Comp.exe",
|
|
206
|
+
include_warning_first=True,
|
|
207
|
+
declarable_predefined_names=_DECLARABLE_NUMERIC_TYPES,
|
|
208
|
+
),
|
|
209
|
+
"cr1000x": Profile(
|
|
210
|
+
"cr1000x",
|
|
211
|
+
"CR1000X",
|
|
212
|
+
"CR1000X.8.2.0",
|
|
213
|
+
"03/04/2025",
|
|
214
|
+
DATA / "cr1000x",
|
|
215
|
+
True,
|
|
216
|
+
"cr1000xcomp.exe",
|
|
217
|
+
include_warning_first=True,
|
|
218
|
+
declarable_predefined_names=_DECLARABLE_NUMERIC_TYPES,
|
|
219
|
+
),
|
|
220
|
+
"cr300": Profile(
|
|
221
|
+
"cr300",
|
|
222
|
+
"CR300",
|
|
223
|
+
"CR300.Std.11.02",
|
|
224
|
+
"02/04/2025",
|
|
225
|
+
DATA / "cr300",
|
|
226
|
+
False,
|
|
227
|
+
"cr300comp.exe",
|
|
228
|
+
unresolved_symbol_message=DottedAwareMessage("{name} is not defined."),
|
|
229
|
+
early_warnings_gated=True,
|
|
230
|
+
undefined_call_message=True,
|
|
231
|
+
fallback_echo_newline=True,
|
|
232
|
+
known_first_word_pass1=False,
|
|
233
|
+
include_drive_prefix=True,
|
|
234
|
+
single_pass=True,
|
|
235
|
+
),
|
|
236
|
+
"cr350": Profile(
|
|
237
|
+
"cr350",
|
|
238
|
+
"CR350",
|
|
239
|
+
"CR350.Std.1.07",
|
|
240
|
+
"02/04/2025",
|
|
241
|
+
DATA / "cr350",
|
|
242
|
+
False,
|
|
243
|
+
"cr350comp.exe",
|
|
244
|
+
unresolved_symbol_message=DottedAwareMessage("{name} is not defined."),
|
|
245
|
+
early_warnings_gated=True,
|
|
246
|
+
undefined_call_message=True,
|
|
247
|
+
fallback_echo_newline=True,
|
|
248
|
+
known_first_word_pass1=False,
|
|
249
|
+
include_drive_prefix=True,
|
|
250
|
+
single_pass=True,
|
|
251
|
+
),
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
EXTENSIONS = {".cr6": "cr6", ".cr1x": "cr1000x", ".cr300": "cr300", ".cr350": "cr350"}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def get(name: str) -> Profile:
|
|
258
|
+
try:
|
|
259
|
+
return PROFILES[name.lower()]
|
|
260
|
+
except KeyError:
|
|
261
|
+
raise ValueError(f"unknown logger {name!r}; choose from {', '.join(PROFILES)}") from None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def for_file(path: Path, default: str = "cr6") -> Profile:
|
|
265
|
+
"""Profile implied by a program's extension (`.CR6`, `.CR1X`, `.CR300`, `.CR350`)."""
|
|
266
|
+
return get(EXTENSIONS.get(path.suffix.lower(), default))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Types for loader metadata attached after parsing, without adding runtime defaults."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from .source import LineSpan, Occurrence
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LoadProvenance:
|
|
11
|
+
"""Annotations only: loaders populate these attributes when the corresponding data exists."""
|
|
12
|
+
|
|
13
|
+
pull_log: list[tuple[tuple[int, ...], tuple[int, ...]]]
|
|
14
|
+
pull_site: dict[str, tuple[int, ...]]
|
|
15
|
+
include_paths: dict[str, Path]
|
|
16
|
+
line_map: dict[str, dict[int, list[LineSpan]]]
|
|
17
|
+
occurrences: list[Occurrence]
|
opencrbasic/quickfix.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Quick fixes for editor tooling: candidate code actions for a bounded set of compiler messages.
|
|
2
|
+
Protocol independent (no pygls): an `Edit` is a physical range, a 0-based line and decoded-character
|
|
3
|
+
columns (byte offsets into the line), exactly as `navigation.py` reports them; the server converts
|
|
4
|
+
to the client's position encoding. See `docs/lsp-api.md` "Quick fixes (LSP-07c)" for the message
|
|
5
|
+
families, the ranking and the limits.
|
|
6
|
+
|
|
7
|
+
Every edit replaces one identifier token (or the file-name part of an include string) by a name
|
|
8
|
+
that already exists: a declared name visible at the use (`bindings.BindingIndex.lookup`), a catalog
|
|
9
|
+
name, or a file in the include directory's listing. Nothing is inserted or deleted otherwise, and
|
|
10
|
+
a token without an exact one-slice range (`navigation`) gets no action.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import NamedTuple
|
|
18
|
+
|
|
19
|
+
from . import bindings, navigation, positions, profiles
|
|
20
|
+
from .catalog import InstructionCatalog
|
|
21
|
+
from .sources import DiskSource, OverlaySource, SourceProvider
|
|
22
|
+
|
|
23
|
+
__all__ = ["Edit", "CodeAction", "code_actions", "apply_edits", "edit_distance", "MAX_ACTIONS", "FAMILIES"]
|
|
24
|
+
|
|
25
|
+
MAX_ACTIONS = 5
|
|
26
|
+
|
|
27
|
+
# Message families (docs/lsp-api.md). Each: (id, compiled pattern, name group or None).
|
|
28
|
+
FAMILIES = (
|
|
29
|
+
("undefined-symbol", re.compile(r"^Invalid symbol in expression: ", re.S), None),
|
|
30
|
+
("undefined-symbol", re.compile(r"^(?P<n>[A-Za-z_][A-Za-z0-9_]*\$?) is not defined\.$"), "n"),
|
|
31
|
+
("undefined-call", re.compile(r"^Invalid, or out of place expression: ", re.S), None),
|
|
32
|
+
("undefined-sub", re.compile(r"^Sub (?P<n>\S+) not defined\.$"), "n"),
|
|
33
|
+
("undefined-sub", re.compile(r"^(?P<n>\S+) is not declared as a Subroutine\.$"), "n"),
|
|
34
|
+
("missing-include", re.compile(r"^Cannot open include file (?P<n>.*)\.$", re.S), "n"),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
_NAME_ROLES = (
|
|
38
|
+
bindings.READ,
|
|
39
|
+
bindings.WRITE,
|
|
40
|
+
bindings.CALL,
|
|
41
|
+
bindings.ARGUMENT,
|
|
42
|
+
bindings.MEMBER_BASE,
|
|
43
|
+
bindings.TABLE_REF,
|
|
44
|
+
bindings.ALIAS_TARGET,
|
|
45
|
+
bindings.UNITS_TARGET,
|
|
46
|
+
)
|
|
47
|
+
_VARIABLES = ("public", "dim", "static", "param", "alias")
|
|
48
|
+
_TABLES = ("datatable", "consttable")
|
|
49
|
+
_EXTERNAL = {"instruction", "function", "symbol"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Edit(NamedTuple):
|
|
53
|
+
path: Path
|
|
54
|
+
line: int # physical, 0-based
|
|
55
|
+
start: int # decoded column, inclusive
|
|
56
|
+
end: int # decoded column, exclusive
|
|
57
|
+
new_text: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class CodeAction:
|
|
62
|
+
title: str
|
|
63
|
+
edits: tuple[Edit, ...]
|
|
64
|
+
kind: str = "quickfix"
|
|
65
|
+
family: str = ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def edit_distance(a: str, b: str) -> int:
|
|
69
|
+
"""Damerau-Levenshtein distance (unrestricted transpositions), case-insensitive."""
|
|
70
|
+
a, b = a.upper(), b.upper()
|
|
71
|
+
if a == b:
|
|
72
|
+
return 0
|
|
73
|
+
inf = len(a) + len(b)
|
|
74
|
+
last: dict[str, int] = {}
|
|
75
|
+
d = [[inf] * (len(b) + 2) for _ in range(len(a) + 2)]
|
|
76
|
+
for i in range(len(a) + 1):
|
|
77
|
+
d[i + 1][0] = inf
|
|
78
|
+
d[i + 1][1] = i
|
|
79
|
+
for j in range(len(b) + 1):
|
|
80
|
+
d[0][j + 1] = inf
|
|
81
|
+
d[1][j + 1] = j
|
|
82
|
+
for i in range(1, len(a) + 1):
|
|
83
|
+
last_match = 0
|
|
84
|
+
for j in range(1, len(b) + 1):
|
|
85
|
+
i1 = last.get(b[j - 1], 0)
|
|
86
|
+
j1 = last_match
|
|
87
|
+
cost = 1
|
|
88
|
+
if a[i - 1] == b[j - 1]:
|
|
89
|
+
cost = 0
|
|
90
|
+
last_match = j
|
|
91
|
+
d[i + 1][j + 1] = min(
|
|
92
|
+
d[i][j] + cost, d[i + 1][j] + 1, d[i][j + 1] + 1, d[i1][j1] + (i - i1 - 1) + 1 + (j - j1 - 1)
|
|
93
|
+
)
|
|
94
|
+
last[a[i - 1]] = i
|
|
95
|
+
return d[len(a) + 1][len(b) + 1]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _max_distance(name: str) -> int:
|
|
99
|
+
return 1 if len(name) <= 3 else 2
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def apply_edits(data: bytes, edits) -> bytes:
|
|
103
|
+
"""`data` with `edits` (all for one file) applied; columns are Latin-1 characters of a line."""
|
|
104
|
+
lines = data.split(b"\n")
|
|
105
|
+
for e in sorted(edits, key=lambda e: (e.line, e.start), reverse=True):
|
|
106
|
+
text = lines[e.line].decode("latin-1")
|
|
107
|
+
lines[e.line] = (text[: e.start] + e.new_text + text[e.end :]).encode("latin-1")
|
|
108
|
+
return b"\n".join(lines)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------ names
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _profile_name(profile) -> str:
|
|
115
|
+
return profiles.get(profile if isinstance(profile, str) else profile.name).name
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _kind_ok(role: str, explicit_call: bool, decl) -> bool:
|
|
119
|
+
kind = decl.external.kind if decl.kind == "external" and decl.external else decl.kind
|
|
120
|
+
external = decl.kind == "external"
|
|
121
|
+
if explicit_call:
|
|
122
|
+
return kind == "sub" and not external
|
|
123
|
+
if role == bindings.WRITE:
|
|
124
|
+
return kind in _VARIABLES and not external
|
|
125
|
+
if role == bindings.CALL:
|
|
126
|
+
return kind in ("sub", "function", "instruction") or (external and kind == "function")
|
|
127
|
+
if role == bindings.TABLE_REF:
|
|
128
|
+
return kind in _TABLES
|
|
129
|
+
if role == bindings.MEMBER_BASE:
|
|
130
|
+
return kind in _VARIABLES + _TABLES or decl.kind == "system_table"
|
|
131
|
+
if role in (bindings.ALIAS_TARGET, bindings.UNITS_TARGET):
|
|
132
|
+
return kind in _VARIABLES
|
|
133
|
+
read = kind in _VARIABLES + ("const", "function") or (external and kind == "symbol")
|
|
134
|
+
if role == bindings.ARGUMENT:
|
|
135
|
+
return read or kind in _TABLES
|
|
136
|
+
return read # READ
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _pool(index: bindings.BindingIndex, catalog: InstructionCatalog) -> dict[str, str]:
|
|
140
|
+
"""upper-case name -> spelling: declared names (first spelling wins) and catalog names."""
|
|
141
|
+
pool: dict[str, str] = {}
|
|
142
|
+
for d in index.declarations:
|
|
143
|
+
if d.kind in ("field", "structure_member", "label", "external", "system_table") or d.scope in (
|
|
144
|
+
"structure",
|
|
145
|
+
"table",
|
|
146
|
+
):
|
|
147
|
+
continue
|
|
148
|
+
pool.setdefault(d.name.upper(), d.name)
|
|
149
|
+
used: dict[str, str] = {} # a catalog name keeps the spelling the program uses
|
|
150
|
+
for r in index.references:
|
|
151
|
+
if len(r.resolved) == 1 and r.resolved[0].kind == "external":
|
|
152
|
+
used.setdefault(r.name.upper(), r.name)
|
|
153
|
+
for entry in catalog:
|
|
154
|
+
if entry.kind in _EXTERNAL:
|
|
155
|
+
pool.setdefault(entry.name.upper(), used.get(entry.name.upper(), entry.name))
|
|
156
|
+
for extra in bindings._EXTRA_PREDEFINED:
|
|
157
|
+
pool.setdefault(extra, extra)
|
|
158
|
+
return pool
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def suggest(
|
|
162
|
+
index: bindings.BindingIndex,
|
|
163
|
+
catalog: InstructionCatalog,
|
|
164
|
+
ref: bindings.Reference,
|
|
165
|
+
pool: dict[str, str] | None = None,
|
|
166
|
+
) -> list[str]:
|
|
167
|
+
"""Up to `MAX_ACTIONS` spellings for the unresolved reference `ref`: Damerau-Levenshtein <= 2
|
|
168
|
+
(<= 1 for a name of up to 3 characters; a distance of 2 needs the same first letter),
|
|
169
|
+
case-insensitive, nearest first, then user-declared
|
|
170
|
+
before catalog names, then alphabetical; each must resolve at the use, with no ambiguity, to a
|
|
171
|
+
declaration whose kind fits the reference's role."""
|
|
172
|
+
pool = pool if pool is not None else _pool(index, catalog)
|
|
173
|
+
name = ref.name
|
|
174
|
+
limit = _max_distance(name)
|
|
175
|
+
explicit = bool(getattr(ref.node, "explicit", False)) and ref.role == bindings.CALL
|
|
176
|
+
ranked = []
|
|
177
|
+
for upper, spelling in pool.items():
|
|
178
|
+
if abs(len(upper) - len(name)) > limit or upper == name.upper():
|
|
179
|
+
continue
|
|
180
|
+
dist = edit_distance(name, spelling)
|
|
181
|
+
if dist > limit or (dist == 2 and upper[0] != name[0].upper()):
|
|
182
|
+
continue
|
|
183
|
+
res = index.lookup(spelling, ref.routine, ref.role)
|
|
184
|
+
if not res.candidates or res.ambiguity:
|
|
185
|
+
continue
|
|
186
|
+
if not all(_kind_ok(ref.role, explicit, c) for c in res.candidates):
|
|
187
|
+
continue
|
|
188
|
+
user = any(c.kind != "external" for c in res.candidates)
|
|
189
|
+
if (
|
|
190
|
+
not user
|
|
191
|
+
and ref.role in (bindings.READ, bindings.ARGUMENT)
|
|
192
|
+
and (catalog.get(spelling, "instruction") or catalog.get(spelling, "keyword") or upper == "TO")
|
|
193
|
+
):
|
|
194
|
+
continue # `Do`, `For`, `Scan`, `To`: a statement word, not the value meant
|
|
195
|
+
ranked.append((dist, not user, upper, spelling))
|
|
196
|
+
ranked.sort()
|
|
197
|
+
return [r[3] for r in ranked[:MAX_ACTIONS]]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ------------------------------------------------------------------ entry point
|
|
201
|
+
|
|
202
|
+
_cache: tuple | None = None # (result, profile name, index, navigation)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _tools(result, profile_name: str, index):
|
|
206
|
+
global _cache
|
|
207
|
+
if index is None and _cache is not None and _cache[0] is result and _cache[1] == profile_name:
|
|
208
|
+
return _cache[2], _cache[3]
|
|
209
|
+
ix = index if index is not None else bindings.build(result.program, profile_name)
|
|
210
|
+
nav = navigation.navigation(result, ix)
|
|
211
|
+
if index is None:
|
|
212
|
+
_cache = (result, profile_name, ix, nav)
|
|
213
|
+
return ix, nav
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _norm(path) -> str:
|
|
217
|
+
return os.path.normcase(str(Path(path).absolute()))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def code_actions(
|
|
221
|
+
result, path, diagnostic, profile, *, index=None, source: SourceProvider | None = None
|
|
222
|
+
) -> list[CodeAction]:
|
|
223
|
+
"""Quick fixes for `diagnostic` (an `AnalysisDiagnostic` of `result`) in the file `path`.
|
|
224
|
+
Empty for any message outside `FAMILIES` or when no exact range exists. `profile` is a profile
|
|
225
|
+
name or `Profile`. `source` (default: the disk plus `result.request.overlays`) is read for the
|
|
226
|
+
include families. Never raises for a program."""
|
|
227
|
+
try:
|
|
228
|
+
return _code_actions(result, Path(path), diagnostic, _profile_name(profile), index, source)
|
|
229
|
+
except Exception:
|
|
230
|
+
return []
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _code_actions(result, path: Path, diag, profile_name: str, index, source) -> list[CodeAction]:
|
|
234
|
+
message = diag.message
|
|
235
|
+
family = name = None
|
|
236
|
+
for fam, pattern, group in FAMILIES:
|
|
237
|
+
m = pattern.match(message)
|
|
238
|
+
if m:
|
|
239
|
+
family, name = fam, (m.group(group) if group else None)
|
|
240
|
+
break
|
|
241
|
+
if family is None:
|
|
242
|
+
return []
|
|
243
|
+
if family == "missing-include":
|
|
244
|
+
assert name is not None # its pattern always captures the include spec
|
|
245
|
+
return _include_actions(result, path, diag, name, source)
|
|
246
|
+
if getattr(result, "program", None) is None:
|
|
247
|
+
return []
|
|
248
|
+
ix, nav = _tools(result, profile_name, index)
|
|
249
|
+
if diag.line and diag.span is None:
|
|
250
|
+
return []
|
|
251
|
+
if diag.line and diag.path is not None and _norm(diag.path) != _norm(path):
|
|
252
|
+
return []
|
|
253
|
+
catalog = InstructionCatalog.for_profile(profile_name)
|
|
254
|
+
pool = _pool(ix, catalog)
|
|
255
|
+
nav._build()
|
|
256
|
+
here = _norm(path)
|
|
257
|
+
actions: list[CodeAction] = []
|
|
258
|
+
seen: set = set()
|
|
259
|
+
for ref in ix.references:
|
|
260
|
+
if ref.status != "unresolved" or ref.role not in _NAME_ROLES:
|
|
261
|
+
continue
|
|
262
|
+
if name is not None and ref.name.upper() != name.upper():
|
|
263
|
+
continue
|
|
264
|
+
if family in ("undefined-sub", "undefined-call") and ref.role not in (bindings.CALL, bindings.WRITE):
|
|
265
|
+
continue
|
|
266
|
+
site = nav._site_of.get(id(ref))
|
|
267
|
+
if site is None or site.path is None or _norm(site.path) != here:
|
|
268
|
+
continue
|
|
269
|
+
if diag.line and not (diag.span[0] - 1 <= site.line <= diag.span[1] - 1):
|
|
270
|
+
continue
|
|
271
|
+
for spelling in suggest(ix, catalog, ref, pool):
|
|
272
|
+
edit = Edit(site.path, site.line, site.start, site.end, spelling)
|
|
273
|
+
if edit in seen:
|
|
274
|
+
continue
|
|
275
|
+
seen.add(edit)
|
|
276
|
+
actions.append(CodeAction(f"Change to `{spelling}`", (edit,), family=family))
|
|
277
|
+
return actions
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ------------------------------------------------------------------ include family
|
|
281
|
+
|
|
282
|
+
_DRIVE = re.compile(r"^(?:CPU|USR|CRD|USB):", re.I)
|
|
283
|
+
_STRING = re.compile(r'"([^"]*)"')
|
|
284
|
+
_INCLUDE_EXTENSIONS = (".crb", ".cr6")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _include_actions(result, path: Path, diag, spec: str, source) -> list[CodeAction]:
|
|
288
|
+
if diag.path is None or _norm(diag.path) != _norm(path) or diag.span is None:
|
|
289
|
+
return []
|
|
290
|
+
src = source or OverlaySource(DiskSource(), getattr(result.request, "overlays", None))
|
|
291
|
+
data = src.read(path)
|
|
292
|
+
listing = src.list_dir(path.parent)
|
|
293
|
+
if data is None or not listing:
|
|
294
|
+
return []
|
|
295
|
+
wanted = _DRIVE.sub("", spec.strip())
|
|
296
|
+
if not wanted or "/" in wanted or "\\" in wanted:
|
|
297
|
+
return []
|
|
298
|
+
entry = Path(result.request.entry).absolute().name.lower()
|
|
299
|
+
ranked = []
|
|
300
|
+
for fname in listing:
|
|
301
|
+
if fname.lower() == entry or not fname.lower().endswith(_INCLUDE_EXTENSIONS) or fname == wanted:
|
|
302
|
+
continue
|
|
303
|
+
dist = edit_distance(wanted, fname)
|
|
304
|
+
if dist <= _max_distance(wanted):
|
|
305
|
+
ranked.append((dist, fname.upper(), fname))
|
|
306
|
+
ranked.sort()
|
|
307
|
+
if not ranked:
|
|
308
|
+
return []
|
|
309
|
+
lines = positions.physical_lines(data)
|
|
310
|
+
actions: list[CodeAction] = []
|
|
311
|
+
for lineno in range(diag.span[0], diag.span[1] + 1):
|
|
312
|
+
if not 1 <= lineno <= len(lines):
|
|
313
|
+
continue
|
|
314
|
+
text = lines[lineno - 1].decode("latin-1")
|
|
315
|
+
for m in _STRING.finditer(text):
|
|
316
|
+
inner = m.group(1)
|
|
317
|
+
if _DRIVE.sub("", inner.strip()) != wanted:
|
|
318
|
+
continue
|
|
319
|
+
at = m.start(1) + inner.find(wanted)
|
|
320
|
+
if at < m.start(1):
|
|
321
|
+
continue
|
|
322
|
+
for _, _, fname in ranked[:MAX_ACTIONS]:
|
|
323
|
+
actions.append(
|
|
324
|
+
CodeAction(
|
|
325
|
+
f"Change to `{fname}`",
|
|
326
|
+
(Edit(path, lineno - 1, at, at + len(wanted), fname),),
|
|
327
|
+
family="missing-include",
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
return actions
|
|
331
|
+
return actions
|