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,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]
@@ -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