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
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""The jobs that run inside a spawned worker process: an analysis, and the compiler output of the
|
|
2
|
+
`opencrbasic.showCompilerOutput` command. Imports only the core compiler."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import pickle
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
CRASH_ENV = "OPENCRBASIC_LSP_TEST_CRASH"
|
|
11
|
+
SLEEP_ENV = "OPENCRBASIC_LSP_TEST_SLEEP"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class JobResult:
|
|
16
|
+
"""Plain picklable data: no AST, no dataclasses from the facade."""
|
|
17
|
+
|
|
18
|
+
ok: bool
|
|
19
|
+
# (severity, message, line, resolved path or None, compiler file name,
|
|
20
|
+
# physical (first, last) line in that path's file or None)
|
|
21
|
+
diagnostics: tuple[tuple[str, str, int, str | None, str, tuple[int, int] | None], ...]
|
|
22
|
+
failures: tuple[str, ...]
|
|
23
|
+
reads: tuple[tuple[str, str | None], ...] # path -> sha256 or None (missing)
|
|
24
|
+
listings: tuple[str, ...] # directories whose listing was read
|
|
25
|
+
program: bytes | None = None # pickled parsed program (None: none or unpicklable)
|
|
26
|
+
oversized: tuple[tuple[str, int], ...] = () # files over max_file_bytes (read as missing)
|
|
27
|
+
# (channel, code, severity, message, line, resolved path or None, compiler file name, span, link)
|
|
28
|
+
findings: tuple[tuple[str, str, str, str, int, str | None, str, tuple[int, int] | None, str], ...] = ()
|
|
29
|
+
config_error: str | None = None # invalid `opencrbasic.toml` (no checks ran)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _pickle_program(program: object) -> bytes | None:
|
|
33
|
+
if program is None:
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
return pickle.dumps(program, protocol=pickle.HIGHEST_PROTOCOL)
|
|
37
|
+
except Exception: # RecursionError, an unpicklable node: features fall back to text
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class OutputResult:
|
|
43
|
+
"""`commands.CompilerOutput` as plain picklable data."""
|
|
44
|
+
|
|
45
|
+
text: str
|
|
46
|
+
ok: bool
|
|
47
|
+
oversized: tuple[tuple[str, int], ...] = () # files over max_file_bytes (not read; no text)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _test_hooks(entry: str) -> None:
|
|
51
|
+
"""Test hooks (docs/lsp-api.md "Test hooks"): only ever act on a substring of the entry path."""
|
|
52
|
+
crash = os.environ.get(CRASH_ENV)
|
|
53
|
+
if crash and crash in entry:
|
|
54
|
+
os._exit(3)
|
|
55
|
+
sleep = os.environ.get(SLEEP_ENV)
|
|
56
|
+
if sleep:
|
|
57
|
+
needle, _, seconds = sleep.rpartition(":")
|
|
58
|
+
if needle and needle in entry:
|
|
59
|
+
time.sleep(float(seconds))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def run_compiler_output(
|
|
63
|
+
entry: str, logger: str, overlays: tuple[tuple[str, bytes], ...], max_file_bytes: int | None = None
|
|
64
|
+
) -> OutputResult:
|
|
65
|
+
_test_hooks(entry)
|
|
66
|
+
from .. import commands
|
|
67
|
+
|
|
68
|
+
out = commands.compiler_output(Path(entry), logger, {Path(p): data for p, data in overlays}, max_file_bytes)
|
|
69
|
+
return OutputResult(out.text, out.ok, tuple((str(p), size) for p, size in out.oversized.items()))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run_analysis(
|
|
73
|
+
entry: str,
|
|
74
|
+
logger: str,
|
|
75
|
+
overlays: tuple[tuple[str, bytes], ...],
|
|
76
|
+
max_file_bytes: int | None = None,
|
|
77
|
+
checks: tuple[tuple[str, str], ...] = (),
|
|
78
|
+
notices: bool = False,
|
|
79
|
+
config_root: str | None = None,
|
|
80
|
+
) -> JobResult:
|
|
81
|
+
_test_hooks(entry)
|
|
82
|
+
from .. import analysis
|
|
83
|
+
|
|
84
|
+
result = analysis.analyze(
|
|
85
|
+
analysis.AnalysisRequest(
|
|
86
|
+
Path(entry),
|
|
87
|
+
logger,
|
|
88
|
+
{Path(p): data for p, data in overlays},
|
|
89
|
+
max_file_bytes=max_file_bytes,
|
|
90
|
+
checks=dict(checks),
|
|
91
|
+
notices=notices,
|
|
92
|
+
config_root=None if config_root is None else Path(config_root),
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
return JobResult(
|
|
96
|
+
result.ok,
|
|
97
|
+
tuple(
|
|
98
|
+
(
|
|
99
|
+
d.severity,
|
|
100
|
+
d.message,
|
|
101
|
+
d.line,
|
|
102
|
+
None if d.path is None else str(d.path),
|
|
103
|
+
d.file,
|
|
104
|
+
None if d.span is None else (int(d.span[0]), int(d.span[1])),
|
|
105
|
+
)
|
|
106
|
+
for d in result.diagnostics
|
|
107
|
+
),
|
|
108
|
+
tuple(result.implementation_failures),
|
|
109
|
+
tuple((str(p), h) for p, h in result.dependencies.reads.items()),
|
|
110
|
+
tuple(str(p) for p in result.dependencies.listings),
|
|
111
|
+
_pickle_program(result.program),
|
|
112
|
+
tuple((str(p), size) for p, size in result.dependencies.oversized.items()),
|
|
113
|
+
tuple(
|
|
114
|
+
(
|
|
115
|
+
f.channel,
|
|
116
|
+
f.code,
|
|
117
|
+
f.severity,
|
|
118
|
+
f.message,
|
|
119
|
+
f.line,
|
|
120
|
+
None if f.path is None else str(f.path),
|
|
121
|
+
f.file,
|
|
122
|
+
None if f.span is None else (int(f.span[0]), int(f.span[1])),
|
|
123
|
+
f.link,
|
|
124
|
+
)
|
|
125
|
+
for f in result.findings
|
|
126
|
+
),
|
|
127
|
+
result.config_error,
|
|
128
|
+
)
|
opencrbasic/mode.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Sequential / Pipeline mode decision (CR6, CR1000X). Rules: docs/modes.md.
|
|
2
|
+
|
|
3
|
+
The trigger lists themselves are per-logger data (`data/<logger>/mode.json`, `Profile.mode_rules`
|
|
4
|
+
-- `docs/loggers.md`), not module constants: CR1000X's compiled mode-decision function
|
|
5
|
+
(`docs/loggers.md`'s Ghidra evidence) is structurally identical to CR6's but three
|
|
6
|
+
`task_instructions` names and two `nonconstant_arg` names are absent because those instructions
|
|
7
|
+
(`ExciteI`, `VibratingWire`, and -- `task_instructions` only -- `Resistance`) do not exist on
|
|
8
|
+
CR1000X at all, so the two loggers' *observable* behaviour is identical: a name missing from a
|
|
9
|
+
logger's list can never occur as a real instruction in a program that logger accepts anyway.
|
|
10
|
+
CR300/CR350 never print a mode line (`Profile.reports_mode`), so their `mode.json` is an unverified
|
|
11
|
+
copy of CR6's, kept only so every profile has one; `mode.decide`'s result for them is computed but
|
|
12
|
+
discarded by `compiler.py`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
from . import syntax as S
|
|
18
|
+
from .profiles import Profile
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _const_value(expr, value_of: Callable) -> float | None:
|
|
22
|
+
try:
|
|
23
|
+
return value_of(expr)
|
|
24
|
+
except Exception:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _numeric_constant(expr, is_constant: Callable, value_of: Callable) -> bool:
|
|
29
|
+
"""A `nonconstant_arg` argument that keeps PipelineMode: numbers and numeric `Const` names
|
|
30
|
+
joined by operators. A string value or a function call counts as variable (docs/modes.md)."""
|
|
31
|
+
if isinstance(expr, S.Number):
|
|
32
|
+
return True
|
|
33
|
+
if isinstance(expr, S.Name):
|
|
34
|
+
return is_constant(expr) and not isinstance(_const_value(expr, value_of), str)
|
|
35
|
+
if isinstance(expr, S.Unary):
|
|
36
|
+
return _numeric_constant(expr.operand, is_constant, value_of)
|
|
37
|
+
if isinstance(expr, S.Binary):
|
|
38
|
+
return _numeric_constant(expr.left, is_constant, value_of) and _numeric_constant(
|
|
39
|
+
expr.right, is_constant, value_of
|
|
40
|
+
)
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def decide(statements: list[S.Stmt], profile: Profile, is_constant: Callable, value_of: Callable) -> str:
|
|
45
|
+
"""Return "Sequential" or "Pipeline".
|
|
46
|
+
|
|
47
|
+
`is_constant(expr)` tells whether an argument is a compile-time constant; `value_of(expr)`
|
|
48
|
+
returns its numeric value (raises when it has none). Trigger lists come from
|
|
49
|
+
`profile.mode_rules` (`data/<logger>/mode.json`).
|
|
50
|
+
"""
|
|
51
|
+
rules = profile.mode_rules
|
|
52
|
+
always_sequential = set(rules["always_sequential"])
|
|
53
|
+
task_instructions = set(rules["task_instructions"])
|
|
54
|
+
nonconstant_arg = {k: tuple(v) for k, v in rules["nonconstant_arg"].items()}
|
|
55
|
+
variable_arg = dict(rules["variable_arg"])
|
|
56
|
+
|
|
57
|
+
sequential = False
|
|
58
|
+
pipeline_forced = False
|
|
59
|
+
depth = 0 # open If/Select/For/While/Do since the last Scan
|
|
60
|
+
in_scan = 0 # Scan nesting (Scan ... NextScan)
|
|
61
|
+
in_slow = 0 # SlowSequence count
|
|
62
|
+
|
|
63
|
+
def visit(stmt: S.Stmt) -> None:
|
|
64
|
+
nonlocal sequential, pipeline_forced, depth, in_scan, in_slow
|
|
65
|
+
kw = _keyword(stmt)
|
|
66
|
+
args = getattr(stmt, "args", []) or []
|
|
67
|
+
if kw == "SEQUENTIALMODE":
|
|
68
|
+
sequential = True
|
|
69
|
+
elif kw == "PIPELINEMODE":
|
|
70
|
+
pipeline_forced = True
|
|
71
|
+
if not sequential and not pipeline_forced:
|
|
72
|
+
if kw in always_sequential:
|
|
73
|
+
sequential = True
|
|
74
|
+
elif kw in task_instructions and (depth or not in_scan):
|
|
75
|
+
if kw == "SW12":
|
|
76
|
+
sequential = _const_value(_arg(args, 2), value_of) == 1
|
|
77
|
+
elif kw == "DELAY":
|
|
78
|
+
sequential = _const_value(_arg(args, 0), value_of) in (0, 2)
|
|
79
|
+
elif kw in ("SERIALOUTBLOCK", "SERIALINRECORD"):
|
|
80
|
+
sequential = not in_slow or depth > 0
|
|
81
|
+
else:
|
|
82
|
+
sequential = True
|
|
83
|
+
elif kw in nonconstant_arg:
|
|
84
|
+
sequential = any(
|
|
85
|
+
_arg(args, i) is not None and not _numeric_constant(_arg(args, i), is_constant, value_of)
|
|
86
|
+
for i in nonconstant_arg[kw]
|
|
87
|
+
)
|
|
88
|
+
elif kw in ("FOR", "IF", "SELECT", "WHILE", "DO"):
|
|
89
|
+
depth += 1
|
|
90
|
+
elif kw in ("NEXT", "ENDIF", "END", "WEND", "LOOP"):
|
|
91
|
+
target = _block_word(args)
|
|
92
|
+
if kw == "NEXT" and target == "SCAN":
|
|
93
|
+
in_scan -= 1
|
|
94
|
+
elif kw == "NEXT" and target == "SUBSCAN":
|
|
95
|
+
pass
|
|
96
|
+
elif kw == "END" and target not in ("IF", "SELECT"):
|
|
97
|
+
pass
|
|
98
|
+
else:
|
|
99
|
+
depth -= 1
|
|
100
|
+
if kw == "SLOWSEQUENCE":
|
|
101
|
+
in_slow += 1
|
|
102
|
+
elif kw == "SCAN":
|
|
103
|
+
depth = 0
|
|
104
|
+
in_scan += 1
|
|
105
|
+
elif kw == "NEXTSCAN":
|
|
106
|
+
in_scan -= 1
|
|
107
|
+
if kw in variable_arg and not pipeline_forced:
|
|
108
|
+
arg = _arg(args, variable_arg[kw])
|
|
109
|
+
if arg is None or not is_constant(arg):
|
|
110
|
+
sequential = True
|
|
111
|
+
# single-line If: the inline statements are inside a conditional
|
|
112
|
+
if isinstance(stmt, S.If) and stmt.inline is not None:
|
|
113
|
+
for inner in (stmt.inline or []) + (stmt.inline_else or []):
|
|
114
|
+
visit(inner)
|
|
115
|
+
if not sequential and not pipeline_forced:
|
|
116
|
+
depth -= 1
|
|
117
|
+
|
|
118
|
+
for stmt in statements:
|
|
119
|
+
visit(stmt)
|
|
120
|
+
return "Sequential" if sequential else "Pipeline"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _keyword(stmt: S.Stmt) -> str:
|
|
124
|
+
if isinstance(stmt, S.Instruction):
|
|
125
|
+
return stmt.keyword.upper()
|
|
126
|
+
if isinstance(stmt, S.If):
|
|
127
|
+
return "IF"
|
|
128
|
+
if isinstance(stmt, S.For):
|
|
129
|
+
return "FOR"
|
|
130
|
+
if isinstance(stmt, S.DoLoop):
|
|
131
|
+
return stmt.keyword.upper()
|
|
132
|
+
return type(stmt).__name__.upper()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _arg(args: list, i: int):
|
|
136
|
+
return args[i] if i < len(args) else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _block_word(args: list) -> str | None:
|
|
140
|
+
"""`Next Scan`, `End If`: the word after a two-word block terminator."""
|
|
141
|
+
if args and isinstance(args[0], S.Name):
|
|
142
|
+
return args[0].name.upper()
|
|
143
|
+
return None
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""Navigation logic for editor tooling: go to definition, find references, document highlights and
|
|
2
|
+
semantic tokens over an `AnalysisResult`. Protocol independent (no pygls): positions in and out are
|
|
3
|
+
physical, a 0-based line and a decoded-character column (a byte offset into the line, as in
|
|
4
|
+
`positions.py`); the server converts to the client's position encoding. See `docs/lsp-api.md`
|
|
5
|
+
"Navigation (LSP-05b-core)".
|
|
6
|
+
|
|
7
|
+
A position is mapped to a record by the records' own ranges, never by searching text for a name:
|
|
8
|
+
`bindings.BindingIndex.token_of` gives the token of each declaration/reference (a column into the
|
|
9
|
+
statement's text), the statement's `origin` (logical line and segment offset, set by the parser)
|
|
10
|
+
gives the logical column, and `positions.exact_range` maps it to the physical file. A token that
|
|
11
|
+
does not come from one contiguous physical slice, or whose token cannot be found, has no range and
|
|
12
|
+
is never navigated.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import NamedTuple
|
|
19
|
+
|
|
20
|
+
from . import bindings, positions
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Location",
|
|
24
|
+
"Highlight",
|
|
25
|
+
"SemanticToken",
|
|
26
|
+
"Site",
|
|
27
|
+
"Navigation",
|
|
28
|
+
"navigation",
|
|
29
|
+
"definition",
|
|
30
|
+
"references",
|
|
31
|
+
"references_across",
|
|
32
|
+
"declaration_sites",
|
|
33
|
+
"document_highlights",
|
|
34
|
+
"semantic_tokens",
|
|
35
|
+
"path_keys",
|
|
36
|
+
"TOKEN_TYPES",
|
|
37
|
+
"TOKEN_MODIFIERS",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
# Semantic-token legend (indices into these tuples are the LSP wire values; the server declares
|
|
41
|
+
# them in this order).
|
|
42
|
+
TOKEN_TYPES = ("variable", "parameter", "function", "property", "namespace", "struct", "type")
|
|
43
|
+
TOKEN_MODIFIERS = ("declaration", "readonly")
|
|
44
|
+
|
|
45
|
+
_KIND_TYPE = {
|
|
46
|
+
"public": "variable",
|
|
47
|
+
"dim": "variable",
|
|
48
|
+
"static": "variable",
|
|
49
|
+
"alias": "variable",
|
|
50
|
+
"const": "variable",
|
|
51
|
+
"param": "parameter",
|
|
52
|
+
"sub": "function",
|
|
53
|
+
"function": "function",
|
|
54
|
+
"structure_member": "property",
|
|
55
|
+
"datatable": "namespace",
|
|
56
|
+
"consttable": "namespace",
|
|
57
|
+
"structuretype": "struct",
|
|
58
|
+
"objecttype": "type",
|
|
59
|
+
}
|
|
60
|
+
_READ_ROLES = {
|
|
61
|
+
bindings.READ,
|
|
62
|
+
bindings.CALL,
|
|
63
|
+
bindings.MEMBER_BASE,
|
|
64
|
+
bindings.MEMBER,
|
|
65
|
+
bindings.TABLE_REF,
|
|
66
|
+
bindings.ALIAS_TARGET,
|
|
67
|
+
bindings.TYPE,
|
|
68
|
+
bindings.INSTRUCTION,
|
|
69
|
+
}
|
|
70
|
+
_KIND_RANK = {"write": 3, "read": 2, "text": 1}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class Location:
|
|
75
|
+
path: Path | None # the file (None when the occurrence has no resolved path)
|
|
76
|
+
file: str # compiler display name
|
|
77
|
+
line: int # physical, 0-based
|
|
78
|
+
start: int # decoded column, inclusive
|
|
79
|
+
end: int # decoded column, exclusive
|
|
80
|
+
occurrences: tuple[int, ...] # `Occurrence.id` of each load of the file that gives this range
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class Highlight:
|
|
85
|
+
line: int
|
|
86
|
+
start: int
|
|
87
|
+
end: int
|
|
88
|
+
kind: str # "write" | "read" | "text"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class SemanticToken(NamedTuple):
|
|
92
|
+
line: int
|
|
93
|
+
start: int
|
|
94
|
+
length: int
|
|
95
|
+
type: str # one of TOKEN_TYPES
|
|
96
|
+
modifiers: tuple[str, ...] # subset of TOKEN_MODIFIERS
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Site(NamedTuple):
|
|
100
|
+
"""The exact physical range of one declaration or reference (`rec`) of an analysis."""
|
|
101
|
+
|
|
102
|
+
rec: bindings.Declaration | bindings.Reference
|
|
103
|
+
path: Path | None
|
|
104
|
+
file: str
|
|
105
|
+
occurrence: int
|
|
106
|
+
line: int
|
|
107
|
+
start: int
|
|
108
|
+
end: int
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def path_keys(path) -> set[str]:
|
|
112
|
+
"""The normalised spellings (absolute and symlink-resolved) under which `path` is matched."""
|
|
113
|
+
p = Path(path)
|
|
114
|
+
out = {os.path.normcase(str(p.absolute()))}
|
|
115
|
+
try:
|
|
116
|
+
out.add(os.path.normcase(str(p.resolve())))
|
|
117
|
+
except OSError:
|
|
118
|
+
pass
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Navigation:
|
|
123
|
+
"""Ranges of every record of one analysis, built lazily on the first query."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, result, index: bindings.BindingIndex | None = None):
|
|
126
|
+
self.result = result
|
|
127
|
+
program = getattr(result, "program", None)
|
|
128
|
+
self.index = index
|
|
129
|
+
if self.index is None and program is not None:
|
|
130
|
+
self.index = bindings.build(program, result.request.logger)
|
|
131
|
+
self._sites: list[Site] | None = None
|
|
132
|
+
self._by_line: dict[tuple[str, int], list[Site]] = {}
|
|
133
|
+
self._by_path: dict[str, list[Site]] = {}
|
|
134
|
+
self._site_of: dict[int, Site] = {}
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------- ranges
|
|
137
|
+
|
|
138
|
+
def occurrence_paths(self) -> dict[int, Path | None]:
|
|
139
|
+
"""`Occurrence.id` -> the file of that load (`None` when unresolved); id 0 is the entry."""
|
|
140
|
+
occs = getattr(getattr(self.result, "program", None), "occurrences", None) or []
|
|
141
|
+
paths = {o.id: o.path for o in occs}
|
|
142
|
+
if paths.get(0) is None:
|
|
143
|
+
paths[0] = Path(self.result.request.entry).absolute()
|
|
144
|
+
return paths
|
|
145
|
+
|
|
146
|
+
def _site(self, rec, paths) -> Site | None:
|
|
147
|
+
unit = rec.unit
|
|
148
|
+
origin = getattr(unit, "origin", None)
|
|
149
|
+
if origin is None:
|
|
150
|
+
return None
|
|
151
|
+
if self.index is None:
|
|
152
|
+
return None
|
|
153
|
+
tok = self.index.token_of(rec)
|
|
154
|
+
if tok is None:
|
|
155
|
+
return None
|
|
156
|
+
ll, seg = origin
|
|
157
|
+
text = unit.text
|
|
158
|
+
shift = None
|
|
159
|
+
for cand in (0, len(ll.text[seg:]) - len(ll.text[seg:].lstrip())): # `Units` text is stripped
|
|
160
|
+
if ll.text.startswith(text, seg + cand):
|
|
161
|
+
shift = cand
|
|
162
|
+
break
|
|
163
|
+
if shift is None:
|
|
164
|
+
return None
|
|
165
|
+
start = seg + shift + tok.col
|
|
166
|
+
end = start + len(tok.text)
|
|
167
|
+
if ll.text[start:end] != tok.text:
|
|
168
|
+
return None
|
|
169
|
+
r = positions.exact_range(ll, start, end)
|
|
170
|
+
if r is None or r.end_col <= r.start_col:
|
|
171
|
+
return None
|
|
172
|
+
return Site(rec, paths.get(r.occurrence), r.file, r.occurrence, r.line - 1, r.start_col, r.end_col)
|
|
173
|
+
|
|
174
|
+
def _build(self) -> list[Site]:
|
|
175
|
+
if self._sites is not None:
|
|
176
|
+
return self._sites
|
|
177
|
+
sites: list[Site] = []
|
|
178
|
+
if self.index is not None:
|
|
179
|
+
paths = self.occurrence_paths()
|
|
180
|
+
for rec in (*self.index.declarations, *self.index.references):
|
|
181
|
+
try:
|
|
182
|
+
site = self._site(rec, paths)
|
|
183
|
+
except Exception: # never raise out of navigation
|
|
184
|
+
site = None
|
|
185
|
+
if site is None:
|
|
186
|
+
continue
|
|
187
|
+
sites.append(site)
|
|
188
|
+
self._site_of[id(rec)] = site
|
|
189
|
+
if site.path is not None:
|
|
190
|
+
for key in path_keys(site.path):
|
|
191
|
+
self._by_line.setdefault((key, site.line), []).append(site)
|
|
192
|
+
self._by_path.setdefault(key, []).append(site)
|
|
193
|
+
self._sites = sites
|
|
194
|
+
return sites
|
|
195
|
+
|
|
196
|
+
def _path_sites(self, path) -> list[Site]:
|
|
197
|
+
self._build()
|
|
198
|
+
seen: set[int] = set()
|
|
199
|
+
out = []
|
|
200
|
+
for key in path_keys(path):
|
|
201
|
+
for s in self._by_path.get(key, ()):
|
|
202
|
+
if id(s) not in seen:
|
|
203
|
+
seen.add(id(s))
|
|
204
|
+
out.append(s)
|
|
205
|
+
return out
|
|
206
|
+
|
|
207
|
+
def sites_at(self, path, line: int, col: int) -> list[Site]:
|
|
208
|
+
"""The sites whose name covers the position (`line` 0-based, `col` decoded). A position
|
|
209
|
+
inside a name selects that name; a position just after one selects it only when no name
|
|
210
|
+
contains the position. Empty when none."""
|
|
211
|
+
self._build()
|
|
212
|
+
seen: set[int] = set()
|
|
213
|
+
hits = []
|
|
214
|
+
for key in path_keys(path):
|
|
215
|
+
for s in self._by_line.get((key, line), ()):
|
|
216
|
+
if id(s) not in seen and s.start <= col <= s.end:
|
|
217
|
+
seen.add(id(s))
|
|
218
|
+
hits.append(s)
|
|
219
|
+
inside = [s for s in hits if col < s.end]
|
|
220
|
+
return inside or hits # a position just after a name still counts
|
|
221
|
+
|
|
222
|
+
def site_of(self, record) -> Site | None:
|
|
223
|
+
"""The site of a declaration or reference of this analysis, `None` when its range is not
|
|
224
|
+
exact (see "Range policy" in `docs/lsp-api.md`)."""
|
|
225
|
+
self._build()
|
|
226
|
+
return self._site_of.get(id(record))
|
|
227
|
+
|
|
228
|
+
def _targets(self, path, line: int, col: int) -> list[bindings.Declaration]:
|
|
229
|
+
out: list[bindings.Declaration] = []
|
|
230
|
+
for s in self.sites_at(path, line, col):
|
|
231
|
+
cands = (s.rec,) if isinstance(s.rec, bindings.Declaration) else s.rec.resolved
|
|
232
|
+
for d in cands:
|
|
233
|
+
if all(d is not o for o in out):
|
|
234
|
+
out.append(d)
|
|
235
|
+
return out
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------- queries
|
|
238
|
+
|
|
239
|
+
def definition(self, path, line: int, col: int) -> list[Location]:
|
|
240
|
+
self._build()
|
|
241
|
+
sites = [self._site_of.get(id(d)) for d in self._targets(path, line, col)]
|
|
242
|
+
return _locations(s for s in sites if s is not None)
|
|
243
|
+
|
|
244
|
+
def references(self, path, line: int, col: int, include_declaration: bool = True) -> list[Location]:
|
|
245
|
+
self._build()
|
|
246
|
+
return _locations(self._reference_sites(self._targets(path, line, col), include_declaration))
|
|
247
|
+
|
|
248
|
+
def declaration_sites(self, path, line: int, col: int) -> list[Site]:
|
|
249
|
+
"""The sites of the declaration(s) the name at the position resolves to, for those that
|
|
250
|
+
have an exact range (external and predefined declarations have none)."""
|
|
251
|
+
self._build()
|
|
252
|
+
sites = [self._site_of.get(id(d)) for d in self._targets(path, line, col)]
|
|
253
|
+
return [s for s in sites if s is not None]
|
|
254
|
+
|
|
255
|
+
def declaration_at(self, path, line: int, start: int, end: int) -> list[bindings.Declaration]:
|
|
256
|
+
"""The declarations whose own token is exactly at that range: how the same declaration is
|
|
257
|
+
found in another analysis that reads the same file (LSP-R4a)."""
|
|
258
|
+
return [
|
|
259
|
+
s.rec
|
|
260
|
+
for s in self.sites_at(path, line, start)
|
|
261
|
+
if isinstance(s.rec, bindings.Declaration) and (s.start, s.end) == (start, end)
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
def _reference_sites(self, targets, include_declaration: bool) -> list[Site]:
|
|
265
|
+
sites: list[Site] = []
|
|
266
|
+
if self.index is None:
|
|
267
|
+
return sites
|
|
268
|
+
for d in targets:
|
|
269
|
+
if include_declaration and id(d) in self._site_of:
|
|
270
|
+
sites.append(self._site_of[id(d)])
|
|
271
|
+
for r in self.index.references_to(d):
|
|
272
|
+
if id(r) in self._site_of:
|
|
273
|
+
sites.append(self._site_of[id(r)])
|
|
274
|
+
return sites
|
|
275
|
+
|
|
276
|
+
def document_highlights(self, path, line: int, col: int) -> list[Highlight]:
|
|
277
|
+
self._build()
|
|
278
|
+
targets = self._targets(path, line, col)
|
|
279
|
+
if not targets:
|
|
280
|
+
return []
|
|
281
|
+
ids = {id(d) for d in targets}
|
|
282
|
+
best: dict[tuple[int, int, int], str] = {}
|
|
283
|
+
for s in self._path_sites(path):
|
|
284
|
+
rec = s.rec
|
|
285
|
+
if isinstance(rec, bindings.Declaration):
|
|
286
|
+
if id(rec) not in ids:
|
|
287
|
+
continue
|
|
288
|
+
kind = "write"
|
|
289
|
+
else:
|
|
290
|
+
if not any(id(d) in ids for d in rec.resolved):
|
|
291
|
+
continue
|
|
292
|
+
kind = _ref_kind(rec)
|
|
293
|
+
key = (s.line, s.start, s.end)
|
|
294
|
+
if _KIND_RANK[kind] > _KIND_RANK.get(best.get(key, ""), 0):
|
|
295
|
+
best[key] = kind
|
|
296
|
+
return [Highlight(l, a, b, k) for (l, a, b), k in sorted(best.items())]
|
|
297
|
+
|
|
298
|
+
def semantic_tokens(self, path) -> list[SemanticToken]:
|
|
299
|
+
out: dict[tuple[int, int], SemanticToken] = {}
|
|
300
|
+
for s in self._path_sites(path):
|
|
301
|
+
rec = s.rec
|
|
302
|
+
if not isinstance(rec, bindings.Declaration) or rec.scope == "predefined":
|
|
303
|
+
continue
|
|
304
|
+
ttype = _KIND_TYPE.get(rec.kind)
|
|
305
|
+
if ttype is None:
|
|
306
|
+
continue
|
|
307
|
+
if rec.kind == "const" and rec.container is not None:
|
|
308
|
+
ttype = "property"
|
|
309
|
+
mods = ("declaration", "readonly") if rec.kind == "const" else ("declaration",)
|
|
310
|
+
out.setdefault((s.line, s.start), SemanticToken(s.line, s.start, s.end - s.start, ttype, mods))
|
|
311
|
+
return [out[k] for k in sorted(out)]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _ref_kind(ref: bindings.Reference) -> str:
|
|
315
|
+
if ref.is_write or ref.role == bindings.WRITE:
|
|
316
|
+
return "write"
|
|
317
|
+
return "read" if ref.role in _READ_ROLES else "text"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _locations(sites) -> list[Location]:
|
|
321
|
+
merged: dict[tuple, list] = {}
|
|
322
|
+
for s in sites:
|
|
323
|
+
key = (str(s.path) if s.path is not None else "", s.file, s.line, s.start, s.end)
|
|
324
|
+
entry = merged.setdefault(key, [s, []])
|
|
325
|
+
if s.occurrence not in entry[1]:
|
|
326
|
+
entry[1].append(s.occurrence)
|
|
327
|
+
out = [Location(s.path, s.file, s.line, s.start, s.end, tuple(sorted(occ))) for s, occ in merged.values()]
|
|
328
|
+
return sorted(out, key=lambda l: (str(l.path or ""), l.file, l.line, l.start))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# ------------------------------------------------------------------ module functions
|
|
332
|
+
|
|
333
|
+
_last: tuple | None = None # (result, index argument, Navigation): the latest analysis
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def navigation(result, index: bindings.BindingIndex | None = None) -> Navigation:
|
|
337
|
+
"""The `Navigation` of `result`, reused while the same `AnalysisResult` object is queried."""
|
|
338
|
+
global _last
|
|
339
|
+
if _last is not None and _last[0] is result and _last[1] is index:
|
|
340
|
+
return _last[2]
|
|
341
|
+
nav = Navigation(result, index)
|
|
342
|
+
_last = (result, index, nav)
|
|
343
|
+
return nav
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def definition(result, path, line: int, col: int, *, index=None) -> list[Location]:
|
|
347
|
+
"""Declaration locations of the name at (`line`, `col`) of `path`; all candidates when the name
|
|
348
|
+
is ambiguous, none when unresolved, external (catalog) or not an indexed name."""
|
|
349
|
+
return navigation(result, index).definition(path, line, col)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def references(result, path, line: int, col: int, include_declaration: bool = True, *, index=None) -> list[Location]:
|
|
353
|
+
"""Every reference to the declaration(s) of the name at the position, in every file."""
|
|
354
|
+
return navigation(result, index).references(path, line, col, include_declaration)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _decl_key(site: Site) -> tuple:
|
|
358
|
+
return (site.path, site.line, site.start, site.end)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def declaration_sites(navs, path, line: int, col: int) -> list[Site]:
|
|
362
|
+
"""The declaration sites the name at the position resolves to in any of `navs` (one
|
|
363
|
+
`Navigation` per analysis; those that do not read `path` contribute nothing), one per range."""
|
|
364
|
+
out: dict[tuple, Site] = {}
|
|
365
|
+
for nav in navs:
|
|
366
|
+
for s in nav.declaration_sites(path, line, col):
|
|
367
|
+
out.setdefault(_decl_key(s), s)
|
|
368
|
+
return list(out.values())
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def references_across(navs, path, line: int, col: int, include_declaration: bool = True) -> list[Location]:
|
|
372
|
+
"""References over several analyses (LSP-R4a): the name at the position is resolved in every
|
|
373
|
+
nav that reads `path`; each resolved declaration with an exact range is then found in every
|
|
374
|
+
nav by its token's file and position (`Navigation.declaration_at`), and the references of all
|
|
375
|
+
of them are united. A range listed by several analyses appears once, with the `occurrences`
|
|
376
|
+
of the first analysis (in `navs` order) that lists it."""
|
|
377
|
+
navs = list(navs)
|
|
378
|
+
keys = [_decl_key(s) for s in declaration_sites(navs, path, line, col)]
|
|
379
|
+
merged: dict[tuple, Location] = {}
|
|
380
|
+
for nav in navs:
|
|
381
|
+
nav._build()
|
|
382
|
+
targets: list[bindings.Declaration] = list(nav._targets(path, line, col))
|
|
383
|
+
for p, ln, a, b in keys:
|
|
384
|
+
if p is None:
|
|
385
|
+
continue
|
|
386
|
+
for d in nav.declaration_at(p, ln, a, b):
|
|
387
|
+
if all(d is not t for t in targets):
|
|
388
|
+
targets.append(d)
|
|
389
|
+
for loc in _locations(nav._reference_sites(targets, include_declaration)):
|
|
390
|
+
merged.setdefault((str(loc.path or ""), loc.file, loc.line, loc.start, loc.end), loc)
|
|
391
|
+
return sorted(merged.values(), key=lambda l: (str(l.path or ""), l.file, l.line, l.start))
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def document_highlights(result, path, line: int, col: int, *, index=None) -> list[Highlight]:
|
|
395
|
+
"""The occurrences in `path` of the declaration(s) of the name at the position."""
|
|
396
|
+
return navigation(result, index).document_highlights(path, line, col)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def semantic_tokens(result, path, *, index=None) -> list[SemanticToken]:
|
|
400
|
+
"""Declared names of `path`, sorted by position."""
|
|
401
|
+
return navigation(result, index).semantic_tokens(path)
|