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/rename.py
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
"""Rename logic for editor tooling: `prepare_rename` and `rename` over the `AnalysisResult` of every
|
|
2
|
+
configured entry that depends on the file. Protocol independent (no pygls): positions in and out
|
|
3
|
+
are physical, a 0-based line and a decoded-character column (a byte offset into the line, as in
|
|
4
|
+
`positions.py`). See `docs/lsp-api.md` "Rename (LSP-07a)".
|
|
5
|
+
|
|
6
|
+
The edits come only from the binding index (`bindings`) and the ranges of `navigation`: the
|
|
7
|
+
declaration's range plus the range of every reference that resolves to it, in every entry. No
|
|
8
|
+
source text is searched to find occurrences. Anything that cannot be proven a single, fully
|
|
9
|
+
ranged binding is refused with a reason (`Refusal`), never guessed. A few conservative refusal
|
|
10
|
+
checks read identifiers of lines the parser did not turn into statements (inactive `#If`
|
|
11
|
+
branches, directives, text after `EndProg`) only to refuse; they never produce an edit.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import re
|
|
16
|
+
from collections import OrderedDict
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import NamedTuple
|
|
21
|
+
|
|
22
|
+
from . import bindings, lexer, navigation
|
|
23
|
+
from .catalog import InstructionCatalog
|
|
24
|
+
from .source import _strip_comment
|
|
25
|
+
from .sources import DiskSource, OverlaySource
|
|
26
|
+
|
|
27
|
+
__all__ = ["TextEdit", "Refusal", "PrepareResult", "RenameResult", "prepare_rename", "rename", "REASONS"]
|
|
28
|
+
|
|
29
|
+
REASONS = (
|
|
30
|
+
"no_results",
|
|
31
|
+
"no_ast",
|
|
32
|
+
"stale",
|
|
33
|
+
"index_failure",
|
|
34
|
+
"not_a_name",
|
|
35
|
+
"unresolved",
|
|
36
|
+
"ambiguous",
|
|
37
|
+
"unmodelled",
|
|
38
|
+
"external",
|
|
39
|
+
"quoted",
|
|
40
|
+
"kind",
|
|
41
|
+
"no_range",
|
|
42
|
+
"text_mismatch",
|
|
43
|
+
"occurrences_differ",
|
|
44
|
+
"entries_disagree",
|
|
45
|
+
"unparsed",
|
|
46
|
+
"unindexed_code",
|
|
47
|
+
"string_mention",
|
|
48
|
+
"table_field",
|
|
49
|
+
"unchanged",
|
|
50
|
+
"invalid_name",
|
|
51
|
+
"reserved_name",
|
|
52
|
+
"collision",
|
|
53
|
+
"overlap",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Declaration kinds whose every use the index records. Labels are declared but their uses (`GoTo`)
|
|
57
|
+
# are not references, so they are not renamable.
|
|
58
|
+
_RENAMABLE = {
|
|
59
|
+
"public",
|
|
60
|
+
"dim",
|
|
61
|
+
"static",
|
|
62
|
+
"const",
|
|
63
|
+
"sub",
|
|
64
|
+
"function",
|
|
65
|
+
"param",
|
|
66
|
+
"alias",
|
|
67
|
+
"datatable",
|
|
68
|
+
"consttable",
|
|
69
|
+
"structuretype",
|
|
70
|
+
"objecttype",
|
|
71
|
+
"structure_member",
|
|
72
|
+
}
|
|
73
|
+
_VARIABLE = {"public", "dim", "static", "alias"}
|
|
74
|
+
# kinds whose names can become data table field names (`Sample(1, P, Pt)` names fields after members)
|
|
75
|
+
_FIELD_SOURCES = _VARIABLE | {"structure_member"}
|
|
76
|
+
_WORD = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\$?")
|
|
77
|
+
_STRING = re.compile(r'"(?:[^"]|"")*"?')
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TextEdit(NamedTuple):
|
|
81
|
+
line: int # physical, 0-based
|
|
82
|
+
start: int # decoded column, inclusive
|
|
83
|
+
end: int # decoded column, exclusive
|
|
84
|
+
new_text: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class Refusal:
|
|
89
|
+
code: str # one of REASONS
|
|
90
|
+
message: str
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class PrepareResult:
|
|
95
|
+
ok: bool
|
|
96
|
+
refusal: Refusal | None = None
|
|
97
|
+
path: Path | None = None
|
|
98
|
+
line: int = 0
|
|
99
|
+
start: int = 0
|
|
100
|
+
end: int = 0
|
|
101
|
+
placeholder: str = "" # the name as spelled at the position
|
|
102
|
+
files: frozenset = frozenset() # every file with the declaration or a reference to it
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class RenameResult:
|
|
107
|
+
ok: bool
|
|
108
|
+
refusal: Refusal | None = None
|
|
109
|
+
edits: dict = field(default_factory=dict) # Path -> [TextEdit], sorted, no overlap
|
|
110
|
+
versions: dict = field(default_factory=dict) # Path -> sha256 hex of the bytes the edits fit
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class _Refuse(Exception):
|
|
114
|
+
def __init__(self, code: str, message: str):
|
|
115
|
+
super().__init__(message)
|
|
116
|
+
self.refusal = Refusal(code, message)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------- per-result cache
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class _State:
|
|
123
|
+
"""Navigation and file facts of one `AnalysisResult`, computed once."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, result):
|
|
126
|
+
self.result = result
|
|
127
|
+
self.nav = navigation.Navigation(result)
|
|
128
|
+
self._unindexed: dict | None = None
|
|
129
|
+
self._strings: dict[str, tuple[str, int]] | None = None
|
|
130
|
+
self._parsed: set[tuple[str, int]] | None = None
|
|
131
|
+
self._text: dict[Path, bytes] = {}
|
|
132
|
+
|
|
133
|
+
def data(self, path: Path) -> bytes:
|
|
134
|
+
got = self._text.get(path)
|
|
135
|
+
if got is None:
|
|
136
|
+
overlay = self.result.request.overlays.get(path)
|
|
137
|
+
got = overlay if overlay is not None else path.read_bytes()
|
|
138
|
+
self._text[path] = got
|
|
139
|
+
return got
|
|
140
|
+
|
|
141
|
+
def parses(self, path: Path, line: int) -> bool:
|
|
142
|
+
"""Whether physical `line` (0-based) of `path` is in a statement of this program (not in an
|
|
143
|
+
`IncludeSection` that is not read, an inactive `#If` branch, a directive or text after
|
|
144
|
+
`EndProg`)."""
|
|
145
|
+
if self._parsed is None:
|
|
146
|
+
parsed = set()
|
|
147
|
+
program = self.result.program
|
|
148
|
+
paths = self.nav.occurrence_paths()
|
|
149
|
+
line_map = getattr(program, "line_map", None) or {}
|
|
150
|
+
for st in program.statements:
|
|
151
|
+
for sp in line_map.get(st.file, {}).get(st.line, ()):
|
|
152
|
+
p = paths.get(sp.occurrence)
|
|
153
|
+
if p is not None:
|
|
154
|
+
parsed.update((k, n - 1) for k in navigation.path_keys(p) for n in range(sp.first, sp.last + 1))
|
|
155
|
+
self._parsed = parsed
|
|
156
|
+
return any((k, line) in self._parsed for k in navigation.path_keys(path))
|
|
157
|
+
|
|
158
|
+
def unindexed(self) -> dict[str, list[tuple[Path, int, int, int]]]:
|
|
159
|
+
"""Words of the physical lines that are in no statement (inactive `#If`, directives, text
|
|
160
|
+
after `EndProg`, includes): upper-case word -> [(path, 0-based line, start, end)]. Strings
|
|
161
|
+
and comments are blanked first."""
|
|
162
|
+
if self._unindexed is not None:
|
|
163
|
+
return self._unindexed
|
|
164
|
+
out: dict[str, list] = {}
|
|
165
|
+
done = set()
|
|
166
|
+
for path, n in self._unparsed_lines():
|
|
167
|
+
lines = self.data(path).decode("latin-1").replace("\r\n", "\n").split("\n")
|
|
168
|
+
if (path, n) in done or not 0 <= n < len(lines):
|
|
169
|
+
continue
|
|
170
|
+
done.add((path, n))
|
|
171
|
+
text = _STRING.sub(lambda m: " " * len(m.group()), _strip_comment(lines[n]))
|
|
172
|
+
for m in _WORD.finditer(text):
|
|
173
|
+
out.setdefault(m.group().upper(), []).append((path, n, m.start(), m.end()))
|
|
174
|
+
self._unindexed = out
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
def string_words(self) -> dict[str, tuple[str, int]]:
|
|
178
|
+
"""Identifier-shaped words inside string literals, upper-case -> (file, line) of one
|
|
179
|
+
occurrence: statements via the lexer, other code lines by their quotes. A string such as
|
|
180
|
+
`SetSetting("Table.Const", ...)` names a declaration; the index does not model that."""
|
|
181
|
+
if self._strings is not None:
|
|
182
|
+
return self._strings
|
|
183
|
+
out: dict[str, tuple[str, int]] = {}
|
|
184
|
+
for st in self.result.program.statements:
|
|
185
|
+
toks = bindings.classified_tokens(st)
|
|
186
|
+
for t, kind in toks or ():
|
|
187
|
+
if kind == "string":
|
|
188
|
+
for w in _WORD.findall(str(t.value)):
|
|
189
|
+
out.setdefault(w.upper(), (st.file, st.line))
|
|
190
|
+
for path, n in set(self._unparsed_lines()):
|
|
191
|
+
lines = self.data(path).decode("latin-1").replace("\r\n", "\n").split("\n")
|
|
192
|
+
if 0 <= n < len(lines):
|
|
193
|
+
for m in _STRING.finditer(_strip_comment(lines[n])):
|
|
194
|
+
for w in _WORD.findall(m.group()):
|
|
195
|
+
out.setdefault(w.upper(), (path.name, n + 1))
|
|
196
|
+
self._strings = out
|
|
197
|
+
return out
|
|
198
|
+
|
|
199
|
+
def _unparsed_lines(self):
|
|
200
|
+
program = self.result.program
|
|
201
|
+
covered = {(st.file, st.line) for st in program.statements}
|
|
202
|
+
paths = self.nav.occurrence_paths()
|
|
203
|
+
for file, by_line in (getattr(program, "line_map", None) or {}).items():
|
|
204
|
+
for line, spans in by_line.items():
|
|
205
|
+
if (file, line) in covered:
|
|
206
|
+
continue
|
|
207
|
+
for span in spans:
|
|
208
|
+
path = paths.get(span.occurrence)
|
|
209
|
+
if path is not None:
|
|
210
|
+
for n in range(span.first, span.last + 1):
|
|
211
|
+
yield path, n - 1
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
_states: OrderedDict[int, _State] = OrderedDict()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _state(result) -> _State:
|
|
218
|
+
key = id(result)
|
|
219
|
+
st = _states.get(key)
|
|
220
|
+
if st is not None and st.result is result:
|
|
221
|
+
_states.move_to_end(key)
|
|
222
|
+
return st
|
|
223
|
+
st = _State(result)
|
|
224
|
+
_states[key] = st
|
|
225
|
+
while len(_states) > 64:
|
|
226
|
+
_states.popitem(last=False)
|
|
227
|
+
return st
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ------------------------------------------------------------------- one entry
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class _Entry(NamedTuple):
|
|
234
|
+
state: _State
|
|
235
|
+
name: str # upper-case current name
|
|
236
|
+
targets: tuple # bindings.Declaration
|
|
237
|
+
ranges: frozenset # {(path, line, start, end)} to edit
|
|
238
|
+
refs: frozenset # id() of the references to edit
|
|
239
|
+
key: frozenset # the declaration set, comparable across entries
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _site_key(site) -> tuple:
|
|
243
|
+
return (site.path, site.line, site.start, site.end)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _entry(state: _State, path: Path, line: int, col: int) -> tuple[_Entry, str, tuple]:
|
|
247
|
+
result, nav = state.result, state.nav
|
|
248
|
+
if getattr(result, "program", None) is None or nav.index is None:
|
|
249
|
+
raise _Refuse("no_ast", "the program could not be parsed")
|
|
250
|
+
ix = nav.index
|
|
251
|
+
if ix.failures:
|
|
252
|
+
raise _Refuse("index_failure", f"the binding index failed for a statement: {ix.failures[0].message}")
|
|
253
|
+
sites = nav.sites_at(path, line, col)
|
|
254
|
+
if not sites:
|
|
255
|
+
raise _Refuse("not_a_name", "the position is not on a name the program declares or uses")
|
|
256
|
+
targets: list[bindings.Declaration] = []
|
|
257
|
+
clicked = sites[0]
|
|
258
|
+
for s in sites:
|
|
259
|
+
rec = s.rec
|
|
260
|
+
if isinstance(rec, bindings.Declaration):
|
|
261
|
+
cands: Sequence = (rec,)
|
|
262
|
+
else:
|
|
263
|
+
status = rec.status
|
|
264
|
+
if status == "unmodelled":
|
|
265
|
+
raise _Refuse("unmodelled", f"{rec.name!r} is a table field name; field names are not modelled")
|
|
266
|
+
if status == "unresolved":
|
|
267
|
+
raise _Refuse("unresolved", f"{rec.name!r} does not resolve to a declaration")
|
|
268
|
+
if status == "ambiguous":
|
|
269
|
+
raise _Refuse(
|
|
270
|
+
"ambiguous", f"{rec.name!r} is ambiguous: {rec.ambiguity or 'several candidate declarations'}"
|
|
271
|
+
)
|
|
272
|
+
cands = rec.resolved
|
|
273
|
+
for d in cands:
|
|
274
|
+
if all(d is not o for o in targets):
|
|
275
|
+
targets.append(d)
|
|
276
|
+
if not targets:
|
|
277
|
+
raise _Refuse("not_a_name", "the position is not on a name the program declares or uses")
|
|
278
|
+
for d in targets:
|
|
279
|
+
if d.kind == "field": # `Tab.Field`, `Status.X`: field names are not modelled (LSP-05c)
|
|
280
|
+
raise _Refuse("unmodelled", f"{d.name!r} is a table field name; field names are not modelled")
|
|
281
|
+
if d.kind in ("external", "system_table") or d.scope == "predefined" or d.external is not None:
|
|
282
|
+
raise _Refuse("external", f"{d.name!r} is predefined (catalog or system table)")
|
|
283
|
+
if d.quoted:
|
|
284
|
+
raise _Refuse("quoted", f"table {d.name!r} is named by a string; string names are never rewritten")
|
|
285
|
+
if d.kind not in _RENAMABLE:
|
|
286
|
+
raise _Refuse("kind", f"a {d.kind} is not renamable")
|
|
287
|
+
if d.occurrence is None:
|
|
288
|
+
raise _Refuse("no_range", f"{d.name!r} has no token position")
|
|
289
|
+
names = {d.name.upper() for d in targets}
|
|
290
|
+
if len(names) != 1:
|
|
291
|
+
raise _Refuse("ambiguous", "the position resolves to differently named declarations")
|
|
292
|
+
name = names.pop()
|
|
293
|
+
placeholder = clicked.rec.name
|
|
294
|
+
if clicked.end - clicked.start != len(placeholder) or placeholder.upper() != name:
|
|
295
|
+
raise _Refuse("no_range", f"the range at the position is not the name {name!r}")
|
|
296
|
+
sets = []
|
|
297
|
+
ref_ids: set[int] = set()
|
|
298
|
+
for d in targets:
|
|
299
|
+
dsite = nav.site_of(d)
|
|
300
|
+
if dsite is None or dsite.path is None:
|
|
301
|
+
raise _Refuse("no_range", f"the declaration of {d.name!r} has no exact physical range")
|
|
302
|
+
rs = {_site_key(dsite)}
|
|
303
|
+
for r in ix.references_to(d):
|
|
304
|
+
if r.status != "resolved":
|
|
305
|
+
raise _Refuse(
|
|
306
|
+
"ambiguous",
|
|
307
|
+
f"a reference to {d.name!r} at {r.file}:{r.line} is "
|
|
308
|
+
f"{r.status}: {r.ambiguity or 'not a single proven target'}",
|
|
309
|
+
)
|
|
310
|
+
rsite = nav.site_of(r)
|
|
311
|
+
if rsite is None or rsite.path is None:
|
|
312
|
+
raise _Refuse(
|
|
313
|
+
"no_range", f"the reference to {d.name!r} at {r.file}:{r.line} has no exact physical range"
|
|
314
|
+
)
|
|
315
|
+
rs.add(_site_key(rsite))
|
|
316
|
+
ref_ids.add(id(r))
|
|
317
|
+
sets.append(frozenset(rs))
|
|
318
|
+
if any(s != sets[0] for s in sets):
|
|
319
|
+
raise _Refuse(
|
|
320
|
+
"occurrences_differ", f"{name!r} is declared by a file loaded more than once and the loads differ"
|
|
321
|
+
)
|
|
322
|
+
_check_ordinals(ix, targets)
|
|
323
|
+
for u in ix.unparsed:
|
|
324
|
+
if name in {t.upper for t in lexer_tokens(u)}:
|
|
325
|
+
raise _Refuse(
|
|
326
|
+
"unparsed", f"a statement the parser could not take apart ({u.file}:{u.line}) mentions {name!r}"
|
|
327
|
+
)
|
|
328
|
+
if targets[0].kind in _FIELD_SOURCES:
|
|
329
|
+
_check_field_names(ix, name)
|
|
330
|
+
key = frozenset((rk[0], rk[1], rk[2], rk[3], d.kind, name) for d in targets for rk in [_site_key(nav.site_of(d))])
|
|
331
|
+
return (
|
|
332
|
+
_Entry(state, name, tuple(targets), sets[0], frozenset(ref_ids), key),
|
|
333
|
+
placeholder,
|
|
334
|
+
(clicked.path, clicked.line, clicked.start, clicked.end),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _check_ordinals(ix, targets) -> None:
|
|
339
|
+
"""`token_of` picks a record's token by its ordinal among the same-name tokens of its unit, so a
|
|
340
|
+
token of that name the index has no record for (a name the parser skipped, such as the ignored
|
|
341
|
+
words of `Alias A = X junk, Y`) shifts every later ordinal onto the wrong token. In every unit an
|
|
342
|
+
edit falls in, the tokens of each edited name must not outnumber the records of it."""
|
|
343
|
+
seen: set[tuple] = set()
|
|
344
|
+
for d in targets:
|
|
345
|
+
for rec in (d, *ix.references_to(d)):
|
|
346
|
+
unit, key = rec.unit, rec.tok_key
|
|
347
|
+
if unit is None or not key or (id(unit), key) in seen:
|
|
348
|
+
continue
|
|
349
|
+
seen.add((id(unit), key))
|
|
350
|
+
toks = bindings.classified_tokens(unit) or ()
|
|
351
|
+
n_tok = sum(1 for t, k in toks if k == key[1] and bindings._tok_name(t, k) == key[0])
|
|
352
|
+
n_rec = sum(1 for o in ix.records_in(unit) if o.tok_key == key)
|
|
353
|
+
n_rec += sum(1 for k in bindings.skipped_keys(unit) if k == key)
|
|
354
|
+
if n_tok > n_rec:
|
|
355
|
+
raise _Refuse(
|
|
356
|
+
"no_range",
|
|
357
|
+
f"{unit.file}:{unit.line}: the statement spells "
|
|
358
|
+
f"{rec.name!r} {n_tok} times but the index records {n_rec}; the "
|
|
359
|
+
"token of each occurrence is not proven",
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def lexer_tokens(stmt):
|
|
364
|
+
toks = bindings.classified_tokens(stmt)
|
|
365
|
+
if toks is not None:
|
|
366
|
+
return [t for t, _ in toks]
|
|
367
|
+
out = []
|
|
368
|
+
for w in _WORD.findall(_STRING.sub(" ", getattr(stmt, "text", "") or "")):
|
|
369
|
+
out.append(lexer.Token("IDENT", w, w.upper(), 0))
|
|
370
|
+
return out
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _check_field_names(ix, name: str) -> None:
|
|
374
|
+
"""A variable's name can be a data table field name (`Sample`, `Average` -> `name_Avg`), so a
|
|
375
|
+
`Table.Field` use derived from the name would not follow a rename. Field names are not renamed
|
|
376
|
+
(`unmodelled`), so every table member use is checked, whether it is unmodelled or resolves to a
|
|
377
|
+
field declaration (LSP-05c)."""
|
|
378
|
+
for r in ix.references:
|
|
379
|
+
if r.role == bindings.MEMBER and any(c.kind == "datatable" for c in r.container):
|
|
380
|
+
up = r.name.upper()
|
|
381
|
+
if up == name or up.startswith(name + "_"):
|
|
382
|
+
raise _Refuse(
|
|
383
|
+
"table_field",
|
|
384
|
+
f"the table field use {r.file}:{r.line} "
|
|
385
|
+
f"({r.name!r}) may derive from {name!r}; field names are not modelled",
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# ------------------------------------------------------------------- new name
|
|
390
|
+
|
|
391
|
+
_SYSTEM_NAMES = ("STATUS", "PUBLIC", "SETTINGS", "DATATABLEINFO")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _check_new_name(entry: _Entry, new_name: str) -> None:
|
|
395
|
+
state = entry.state
|
|
396
|
+
ix = state.nav.index
|
|
397
|
+
assert ix is not None # an entry exists only for a program with an index
|
|
398
|
+
up = new_name.upper()
|
|
399
|
+
cat = InstructionCatalog.for_profile(state.result.request.logger)
|
|
400
|
+
if cat.get(new_name) is not None or up in _SYSTEM_NAMES or up in bindings._EXTRA_PREDEFINED:
|
|
401
|
+
raise _Refuse(
|
|
402
|
+
"reserved_name", f"{new_name!r} is a keyword, instruction, function or predefined symbol of {cat.profile}"
|
|
403
|
+
)
|
|
404
|
+
if up == entry.name:
|
|
405
|
+
return # a case-only change binds nothing new; the old name was checked in `_entry`
|
|
406
|
+
for u in ix.unparsed:
|
|
407
|
+
if up in {t.upper for t in lexer_tokens(u)}:
|
|
408
|
+
raise _Refuse(
|
|
409
|
+
"unparsed", f"a statement the parser could not take apart ({u.file}:{u.line}) mentions {new_name!r}"
|
|
410
|
+
)
|
|
411
|
+
ours = {id(d) for d in entry.targets}
|
|
412
|
+
for t in entry.targets:
|
|
413
|
+
for d in ix.declarations_named(up):
|
|
414
|
+
if id(d) in ours:
|
|
415
|
+
continue
|
|
416
|
+
if t.kind == "structure_member":
|
|
417
|
+
clash = d.kind == "structure_member" and d.container is t.container
|
|
418
|
+
elif d.kind == "structure_member":
|
|
419
|
+
clash = False
|
|
420
|
+
elif t.scope == "routine":
|
|
421
|
+
clash = d.scope == "global" or d.unverified_scope or (d.scope == "routine" and d.routine is t.routine)
|
|
422
|
+
else:
|
|
423
|
+
clash = True # a global is visible in every routine
|
|
424
|
+
if clash:
|
|
425
|
+
raise _Refuse("collision", f"{new_name!r} is already declared ({d.kind} at {d.file}:{d.line})")
|
|
426
|
+
for r in ix.references:
|
|
427
|
+
if r.name.upper() != up or id(r) in entry.refs:
|
|
428
|
+
continue
|
|
429
|
+
if r.role == bindings.MEMBER:
|
|
430
|
+
# a member use binds the new name when its container is the namespace of the target:
|
|
431
|
+
# the owning type, or the Public table for a Public variable or alias
|
|
432
|
+
clash = (t.container is not None and any(c is t.container for c in r.container)) or (
|
|
433
|
+
t.kind in ("public", "alias")
|
|
434
|
+
and any(c.kind == "system_table" and c.name.upper() == "PUBLIC" for c in r.container)
|
|
435
|
+
)
|
|
436
|
+
else:
|
|
437
|
+
clash = t.kind != "structure_member"
|
|
438
|
+
if clash:
|
|
439
|
+
raise _Refuse("collision", f"{new_name!r} is already used at {r.file}:{r.line} ({r.status})")
|
|
440
|
+
if entry.targets[0].kind in _FIELD_SOURCES:
|
|
441
|
+
_check_field_names(ix, up)
|
|
442
|
+
hit = state.string_words().get(up)
|
|
443
|
+
if hit is not None:
|
|
444
|
+
raise _Refuse("string_mention", f"{new_name!r} occurs inside a string literal ({hit[0]}:{hit[1]})")
|
|
445
|
+
for path, ln, _, _ in state.unindexed().get(up, ()):
|
|
446
|
+
raise _Refuse("unindexed_code", f"{new_name!r} occurs in code the index does not cover ({path.name}:{ln + 1})")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _check_unchanged(entries: list[_Entry], new_name: str) -> None:
|
|
450
|
+
"""`unchanged` only when every range to edit is already spelled `new_name` exactly; a case-only
|
|
451
|
+
change of any occurrence (`TOTAL` -> `total` for `Public total`) is a rename."""
|
|
452
|
+
if new_name.upper() != entries[0].name:
|
|
453
|
+
return
|
|
454
|
+
for e in entries:
|
|
455
|
+
for p, ln, a, b in e.ranges:
|
|
456
|
+
text = e.state.data(p).decode("latin-1").replace("\r\n", "\n").split("\n")
|
|
457
|
+
if not 0 <= ln < len(text) or text[ln][a:b] != new_name:
|
|
458
|
+
return
|
|
459
|
+
raise _Refuse("unchanged", "the new name equals the current name")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _valid_identifier(new_name: str) -> bool:
|
|
463
|
+
m = _WORD.fullmatch(new_name)
|
|
464
|
+
return m is not None and not new_name.endswith("$")
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
# ------------------------------------------------------------------- API
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
class _Current:
|
|
471
|
+
"""The bytes the files have now, read at the operation boundary (never the `_State` cache, which
|
|
472
|
+
serves only checks derived from the analysis). `source` is the caller's provider, else each
|
|
473
|
+
result's own overlays on the disk."""
|
|
474
|
+
|
|
475
|
+
def __init__(self, source):
|
|
476
|
+
self.source = source
|
|
477
|
+
self._own: dict[int, OverlaySource] = {}
|
|
478
|
+
self._bytes: dict[tuple, bytes | None] = {}
|
|
479
|
+
|
|
480
|
+
def _provider(self, result):
|
|
481
|
+
if self.source is not None:
|
|
482
|
+
return self.source
|
|
483
|
+
got = self._own.get(id(result))
|
|
484
|
+
if got is None:
|
|
485
|
+
got = self._own[id(result)] = OverlaySource(DiskSource(), result.request.overlays)
|
|
486
|
+
return got
|
|
487
|
+
|
|
488
|
+
def read(self, result, path) -> bytes | None:
|
|
489
|
+
key = (id(result) if self.source is None else 0, Path(path))
|
|
490
|
+
if key not in self._bytes:
|
|
491
|
+
self._bytes[key] = self._provider(result).read(Path(path))
|
|
492
|
+
return self._bytes[key]
|
|
493
|
+
|
|
494
|
+
def verify(self, result) -> None:
|
|
495
|
+
"""Every file the analysis read, edited or not, must have the analysed bytes now (and a
|
|
496
|
+
missing one must still be missing): bindings and collisions depend on all of them."""
|
|
497
|
+
for q, analysed in result.dependencies.reads.items():
|
|
498
|
+
data = self.read(result, q)
|
|
499
|
+
now = None if data is None else hashlib.sha256(data).hexdigest()
|
|
500
|
+
if now != analysed:
|
|
501
|
+
raise _Refuse("stale", f"{Path(q).name} differs from the bytes the analysis read")
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _reads(result, path: Path) -> bool:
|
|
505
|
+
return any(Path(p) == path or _same(p, path) for p in result.dependencies.reads)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _collect(results, path, line, col, current):
|
|
509
|
+
"""The entries of `results` that the rename concerns: those that read `path` and parse `line`
|
|
510
|
+
(the clicked binding), then, until nothing changes, every other result that reads the
|
|
511
|
+
declaration's file or an edited file. Such a result is identified with the same declaration
|
|
512
|
+
by the declaration token's file and position; one that parses an edited line but not the
|
|
513
|
+
declaration is refused (`entries_disagree`), one that parses none of them is skipped."""
|
|
514
|
+
path = Path(path).absolute()
|
|
515
|
+
if not results:
|
|
516
|
+
raise _Refuse("no_results", "no analysis result was given")
|
|
517
|
+
entries: list[_Entry] = []
|
|
518
|
+
used: set[int] = set()
|
|
519
|
+
first = None
|
|
520
|
+
readers = 0
|
|
521
|
+
for r in results:
|
|
522
|
+
st = _state(r)
|
|
523
|
+
if not _reads(r, path):
|
|
524
|
+
continue # this entry does not depend on the file
|
|
525
|
+
readers += 1
|
|
526
|
+
if getattr(r, "program", None) is not None and not st.parses(path, line):
|
|
527
|
+
continue # the declaration is not in this entry's program
|
|
528
|
+
current.verify(r)
|
|
529
|
+
e, placeholder, rng = _entry(st, path, line, col)
|
|
530
|
+
if first is None:
|
|
531
|
+
first = (placeholder, rng)
|
|
532
|
+
entries.append(e)
|
|
533
|
+
used.add(id(r))
|
|
534
|
+
if not entries and readers:
|
|
535
|
+
raise _Refuse(
|
|
536
|
+
"not_a_name",
|
|
537
|
+
"the position is not in code any entry parses (a comment "
|
|
538
|
+
"line, an inactive #If branch, a directive or text after EndProg)",
|
|
539
|
+
)
|
|
540
|
+
if not entries:
|
|
541
|
+
raise _Refuse("no_results", "no given analysis depends on the file")
|
|
542
|
+
assert first is not None # set with the first entry
|
|
543
|
+
decl = min(entries[0].key, key=lambda k: (str(k[0]), k[1], k[2]))[:4]
|
|
544
|
+
grown = True
|
|
545
|
+
while grown: # entries reached through the declaration or an edit
|
|
546
|
+
grown = False
|
|
547
|
+
union = set().union(*(e.ranges for e in entries))
|
|
548
|
+
for r in results:
|
|
549
|
+
if id(r) in used:
|
|
550
|
+
continue
|
|
551
|
+
touched = [k for k in union | {decl} if _reads(r, k[0])]
|
|
552
|
+
if not touched:
|
|
553
|
+
continue
|
|
554
|
+
st = _state(r)
|
|
555
|
+
if getattr(r, "program", None) is not None:
|
|
556
|
+
if not _reads(r, decl[0]) or not st.parses(decl[0], decl[1]):
|
|
557
|
+
if any(st.parses(k[0], k[1]) for k in touched):
|
|
558
|
+
raise _Refuse(
|
|
559
|
+
"entries_disagree", "an entry uses an edited line but does not declare the target"
|
|
560
|
+
)
|
|
561
|
+
continue
|
|
562
|
+
current.verify(r)
|
|
563
|
+
e, _, _ = _entry(st, decl[0], decl[1], decl[2])
|
|
564
|
+
entries.append(e)
|
|
565
|
+
used.add(id(r))
|
|
566
|
+
grown = True
|
|
567
|
+
if any(e.key != entries[0].key or e.name != entries[0].name for e in entries):
|
|
568
|
+
raise _Refuse("entries_disagree", "the entries disagree on the declaration set")
|
|
569
|
+
for e in entries:
|
|
570
|
+
hit = e.state.string_words().get(e.name)
|
|
571
|
+
if hit is not None:
|
|
572
|
+
raise _Refuse(
|
|
573
|
+
"string_mention",
|
|
574
|
+
f"{e.name!r} occurs inside a string literal ({hit[0]}:{hit[1]}); names in strings are never rewritten",
|
|
575
|
+
)
|
|
576
|
+
union = set().union(*(e.ranges for e in entries))
|
|
577
|
+
for e in entries: # the old name in code no entry indexes cannot be renamed
|
|
578
|
+
for p, ln, a, b in e.state.unindexed().get(e.name, ()):
|
|
579
|
+
if (p, ln, a, b) not in union:
|
|
580
|
+
raise _Refuse(
|
|
581
|
+
"unindexed_code",
|
|
582
|
+
f"{e.name!r} occurs in code the index does not "
|
|
583
|
+
f"cover ({p.name}:{ln + 1}: inactive #If branch, directive or text "
|
|
584
|
+
"after EndProg)",
|
|
585
|
+
)
|
|
586
|
+
return entries, first
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _same(a, b) -> bool:
|
|
590
|
+
try:
|
|
591
|
+
return Path(a).resolve() == Path(b).resolve()
|
|
592
|
+
except OSError:
|
|
593
|
+
return False
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def prepare_rename(results, path, line: int, column: int, source=None) -> PrepareResult:
|
|
597
|
+
"""The range and current name at (`line`, `column`) when a rename of it can be offered, else
|
|
598
|
+
the reason it cannot. `results`: the `AnalysisResult` of every configured entry that depends
|
|
599
|
+
on `path`. `source`: optional `SourceProvider` giving the current bytes of every file (default:
|
|
600
|
+
each result's overlays on the disk)."""
|
|
601
|
+
try:
|
|
602
|
+
current = _Current(source)
|
|
603
|
+
entries, (placeholder, rng) = _collect(results, path, line, column, current)
|
|
604
|
+
_versions(entries, {rng[0]}, current)
|
|
605
|
+
except _Refuse as e:
|
|
606
|
+
return PrepareResult(False, e.refusal)
|
|
607
|
+
p, ln, a, b = rng
|
|
608
|
+
files = frozenset(k[0] for e in entries for k in e.ranges)
|
|
609
|
+
return PrepareResult(True, None, p, ln, a, b, placeholder, files)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def rename(results, path, line: int, column: int, new_name: str, source=None) -> RenameResult:
|
|
613
|
+
"""Edits renaming the declaration at (`line`, `column`) and every reference to it, across
|
|
614
|
+
every entry in `results`, or a `Refusal`. Edits are deduplicated; `versions` maps each edited
|
|
615
|
+
path to the sha256 of the bytes the edits were computed against (equal in every entry). Every
|
|
616
|
+
file any entry read must still have the analysed bytes now, read through `source` (optional
|
|
617
|
+
`SourceProvider`; default each result's overlays on the disk), else `stale`."""
|
|
618
|
+
try:
|
|
619
|
+
current = _Current(source)
|
|
620
|
+
entries, (placeholder, rng) = _collect(results, path, line, column, current)
|
|
621
|
+
if not _valid_identifier(new_name):
|
|
622
|
+
raise _Refuse("invalid_name", f"{new_name!r} is not a valid identifier")
|
|
623
|
+
_check_unchanged(entries, new_name)
|
|
624
|
+
for e in entries:
|
|
625
|
+
_check_new_name(e, new_name)
|
|
626
|
+
ranges = sorted(set().union(*(e.ranges for e in entries)), key=lambda k: (str(k[0]), k[1], k[2]))
|
|
627
|
+
versions = _versions(entries, {k[0] for k in ranges}, current)
|
|
628
|
+
edits: dict[Path, list[TextEdit]] = {}
|
|
629
|
+
for p, ln, a, b in ranges:
|
|
630
|
+
data = current.read(entries[0].state.result, p)
|
|
631
|
+
if data is None:
|
|
632
|
+
raise _Refuse("stale", f"{p.name} can no longer be read")
|
|
633
|
+
text = data.decode("latin-1").replace("\r\n", "\n").split("\n")[ln]
|
|
634
|
+
if text[a:b].upper() != entries[0].name:
|
|
635
|
+
raise _Refuse(
|
|
636
|
+
"text_mismatch", f"{p.name}:{ln + 1}: the text at the range is {text[a:b]!r}, not the name"
|
|
637
|
+
)
|
|
638
|
+
lst = edits.setdefault(p, [])
|
|
639
|
+
if lst and lst[-1].line == ln and lst[-1].end > a:
|
|
640
|
+
raise _Refuse("overlap", f"{p.name}:{ln + 1}: edits overlap")
|
|
641
|
+
lst.append(TextEdit(ln, a, b, new_name))
|
|
642
|
+
return RenameResult(True, None, edits, versions)
|
|
643
|
+
except _Refuse as e:
|
|
644
|
+
return RenameResult(False, e.refusal)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _versions(entries: list[_Entry], paths, current: _Current) -> dict:
|
|
648
|
+
"""sha256 of the current bytes of each edited path; `_Current.verify` has already shown that
|
|
649
|
+
they are what every entry that read the path analysed."""
|
|
650
|
+
out = {}
|
|
651
|
+
for p in sorted(paths, key=str):
|
|
652
|
+
for e in entries:
|
|
653
|
+
r = e.state.result
|
|
654
|
+
if not any(Path(q) == p or _same(q, p) for q in r.dependencies.reads):
|
|
655
|
+
continue # this entry did not read the file
|
|
656
|
+
data = current.read(r, p)
|
|
657
|
+
digest = None if data is None else hashlib.sha256(data).hexdigest()
|
|
658
|
+
if digest is None or out.setdefault(p, digest) != digest:
|
|
659
|
+
raise _Refuse("stale", f"{p.name} differs between entries")
|
|
660
|
+
if p not in out:
|
|
661
|
+
raise _Refuse("stale", f"{p.name} was not read by any analysis")
|
|
662
|
+
return out
|