cubyte-lsp 0.1.0__tar.gz
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.
- cubyte_lsp-0.1.0/PKG-INFO +6 -0
- cubyte_lsp-0.1.0/README.md +83 -0
- cubyte_lsp-0.1.0/cubyte_lsp/__init__.py +10 -0
- cubyte_lsp-0.1.0/cubyte_lsp/__main__.py +6 -0
- cubyte_lsp-0.1.0/cubyte_lsp/analyzer.py +136 -0
- cubyte_lsp-0.1.0/cubyte_lsp/documents.py +52 -0
- cubyte_lsp-0.1.0/cubyte_lsp/features/__init__.py +16 -0
- cubyte_lsp-0.1.0/cubyte_lsp/features/completion.py +157 -0
- cubyte_lsp-0.1.0/cubyte_lsp/features/definition.py +78 -0
- cubyte_lsp-0.1.0/cubyte_lsp/features/diagnostics.py +63 -0
- cubyte_lsp-0.1.0/cubyte_lsp/features/formatting.py +70 -0
- cubyte_lsp-0.1.0/cubyte_lsp/features/hover.py +134 -0
- cubyte_lsp-0.1.0/cubyte_lsp/features/symbols.py +84 -0
- cubyte_lsp-0.1.0/cubyte_lsp/knowledge/__init__.py +8 -0
- cubyte_lsp-0.1.0/cubyte_lsp/knowledge/keywords.py +44 -0
- cubyte_lsp-0.1.0/cubyte_lsp/knowledge/pieces.py +55 -0
- cubyte_lsp-0.1.0/cubyte_lsp/knowledge/types.py +45 -0
- cubyte_lsp-0.1.0/cubyte_lsp/protocol/__init__.py +4 -0
- cubyte_lsp-0.1.0/cubyte_lsp/protocol/jsonrpc.py +210 -0
- cubyte_lsp-0.1.0/cubyte_lsp/protocol/types.py +183 -0
- cubyte_lsp-0.1.0/cubyte_lsp/server.py +258 -0
- cubyte_lsp-0.1.0/cubyte_lsp/tests/__init__.py +0 -0
- cubyte_lsp-0.1.0/cubyte_lsp/tests/test_completion_scope.py +116 -0
- cubyte_lsp-0.1.0/cubyte_lsp/tests/test_jsonrpc.py +81 -0
- cubyte_lsp-0.1.0/cubyte_lsp/utils.py +62 -0
- cubyte_lsp-0.1.0/cubyte_lsp.egg-info/PKG-INFO +6 -0
- cubyte_lsp-0.1.0/cubyte_lsp.egg-info/SOURCES.txt +30 -0
- cubyte_lsp-0.1.0/cubyte_lsp.egg-info/dependency_links.txt +1 -0
- cubyte_lsp-0.1.0/cubyte_lsp.egg-info/entry_points.txt +2 -0
- cubyte_lsp-0.1.0/cubyte_lsp.egg-info/top_level.txt +1 -0
- cubyte_lsp-0.1.0/pyproject.toml +19 -0
- cubyte_lsp-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# cubyte-lsp
|
|
2
|
+
|
|
3
|
+
A Language Server Protocol implementation for [cubyte](../README.md), the
|
|
4
|
+
high-level language whose runtime is a Rubik's cube. The server speaks LSP
|
|
5
|
+
3.17 over stdin/stdout and provides:
|
|
6
|
+
|
|
7
|
+
- **Diagnostics** — the server shells out to the `cubyte` compiler and
|
|
8
|
+
converts its `[stage] line N: message` errors into LSP diagnostics
|
|
9
|
+
published on `textDocument/publishDiagnostics`.
|
|
10
|
+
- **Hover** — shows the type, required register order, and (when available)
|
|
11
|
+
the physical register the variable is bound to, plus a short description
|
|
12
|
+
of built-in pieces, keywords, and the I/O register.
|
|
13
|
+
- **Completion** — completes keywords, type names, piece labels
|
|
14
|
+
(`UF, UFL, DBR, …`), and identifiers visible in the current document.
|
|
15
|
+
- **Go-to-definition / document symbols** — jumps to the declaration of a
|
|
16
|
+
variable, function-free label, or algorithm literal.
|
|
17
|
+
- **Formatting** — a minimal, opinionated formatter that re-indents blocks
|
|
18
|
+
and aligns `:=` in a column. It is safe to call on unsaved buffers
|
|
19
|
+
because it operates on a string in/out.
|
|
20
|
+
|
|
21
|
+
## Layout
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
lsp/
|
|
25
|
+
├── server.py # Entry point: starts the LSP, dispatches requests.
|
|
26
|
+
├── analyzer.py # Re-runs the cubyte compiler, parses diagnostics.
|
|
27
|
+
├── features/
|
|
28
|
+
│ ├── __init__.py
|
|
29
|
+
│ ├── completion.py # textDocument/completion
|
|
30
|
+
│ ├── definition.py # textDocument/definition
|
|
31
|
+
│ ├── diagnostics.py # Background publishDiagnostics loop
|
|
32
|
+
│ ├── formatting.py # textDocument/formatting
|
|
33
|
+
│ ├── hover.py # textDocument/hover
|
|
34
|
+
│ └── symbols.py # textDocument/documentSymbol
|
|
35
|
+
├── knowledge/
|
|
36
|
+
│ ├── __init__.py
|
|
37
|
+
│ ├── keywords.py # Built-in keyword metadata
|
|
38
|
+
│ ├── pieces.py # Piece-label metadata
|
|
39
|
+
│ └── types.py # Type + register-order metadata
|
|
40
|
+
├── protocol/
|
|
41
|
+
│ ├── __init__.py
|
|
42
|
+
│ ├── jsonrpc.py # Minimal stdlib LSP/JSON-RPC framing
|
|
43
|
+
│ └── types.py # Request/response dataclasses
|
|
44
|
+
├── utils.py # URI <-> path, range helpers
|
|
45
|
+
├── pyproject.toml # `python -m cubyte_lsp` entry point
|
|
46
|
+
└── tests/
|
|
47
|
+
└── test_jsonrpc.py # Round-trip framing test (placeholder)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Running
|
|
51
|
+
|
|
52
|
+
The server expects the `cubyte` binary on `PATH` (or set `CUBYTE_BIN`).
|
|
53
|
+
It is meant to be launched by an editor client, not by hand:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python -m cubyte_lsp # default: read PATH for cubyte
|
|
57
|
+
CUBYTE_BIN=/path/to/cubyte \
|
|
58
|
+
python -m cubyte_lsp # explicit path
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Editor configuration
|
|
62
|
+
|
|
63
|
+
`neovim` (with `nvim-lspconfig`):
|
|
64
|
+
|
|
65
|
+
```lua
|
|
66
|
+
require('lspconfig').cubyte_lsp.setup{
|
|
67
|
+
cmd = { 'python', '-m', 'cubyte_lsp' },
|
|
68
|
+
filetypes = { 'cubyte' },
|
|
69
|
+
root_dir = function(fname) return vim.fs.dirname(fname) end,
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`vscode`: see `editors/vscode-cubyte/README.md` (out of scope for this
|
|
74
|
+
skeleton — left to whoever wires the extension together).
|
|
75
|
+
|
|
76
|
+
## Status
|
|
77
|
+
|
|
78
|
+
This is a skeleton: the JSON-RPC framing, document-state plumbing, and
|
|
79
|
+
feature modules are in place, but only diagnostics and hover are wired
|
|
80
|
+
through to real behaviour. Completion, definition, symbols, and
|
|
81
|
+
formatting ship with sensible stub responses that the editor will
|
|
82
|
+
display; flesh them out by filling in the marked `TODO(stub)` blocks in
|
|
83
|
+
`features/*.py`.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""cubyte_lsp — Language Server Protocol for cubyte.
|
|
2
|
+
|
|
3
|
+
Entry point: ``python -m cubyte_lsp`` (see ``server.py``).
|
|
4
|
+
|
|
5
|
+
The package is organised so that each LSP feature lives in its own module
|
|
6
|
+
under :mod:`cubyte_lsp.features`. The package itself only re-exports
|
|
7
|
+
public names; importing submodules directly is supported.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__all__ = ["server", "analyzer"]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""cubyte_lsp.analyzer — runs the cubyte compiler and parses its output.
|
|
2
|
+
|
|
3
|
+
The compiler is the source of truth for diagnostics. We invoke it on a
|
|
4
|
+
temporary copy of the in-memory buffer (the editor's text might not yet
|
|
5
|
+
be saved to disk) and translate its ``[stage] line N: message`` stderr
|
|
6
|
+
output into LSP :class:`Diagnostic` objects.
|
|
7
|
+
|
|
8
|
+
The diagnostic mapping table is intentionally explicit: the cubyte exit
|
|
9
|
+
code determines the LSP ``severity`` (``Error`` for everything) and the
|
|
10
|
+
stage name becomes the diagnostic ``code`` so the editor can group them.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
import tempfile
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from .protocol.types import Diagnostic, Position, Range
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger("cubyte_lsp.analyzer")
|
|
27
|
+
|
|
28
|
+
# Stages reported by cubyte on stderr. The first capture group is the
|
|
29
|
+
# stage name; the second is the line number (1-based per the compiler,
|
|
30
|
+
# which we convert to 0-based for LSP). Many compiler errors come out as
|
|
31
|
+
# ``<Stage> error at line N, column M: message``; we accept that form too.
|
|
32
|
+
_STAGE_RE = re.compile(
|
|
33
|
+
r"^(?:"
|
|
34
|
+
r"\[(?P<stage_bracketed>\w+)\]\s+line\s+(?P<line1>\d+)\s*:\s*(?P<msg1>.*)$"
|
|
35
|
+
r"|"
|
|
36
|
+
r"(?P<stage_plain>\w+)\s+error\s+at\s+line\s+(?P<line2>\d+)\s*,"
|
|
37
|
+
r"\s*column\s+(?P<col>\d+)\s*:\s*(?P<msg2>.*)$"
|
|
38
|
+
r")"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class AnalysisResult:
|
|
44
|
+
diagnostics: tuple[Diagnostic, ...]
|
|
45
|
+
raw_stderr: str
|
|
46
|
+
raw_stdout: str
|
|
47
|
+
exit_code: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CubyteAnalyzer:
|
|
51
|
+
"""Invokes the ``cubyte`` binary on a buffer and reports diagnostics.
|
|
52
|
+
|
|
53
|
+
The analyzer writes the buffer to a temporary ``.cbyte`` file because
|
|
54
|
+
the compiler expects a real path (it derives intermediate filenames
|
|
55
|
+
from it). The file is cleaned up after the run; the analyzer never
|
|
56
|
+
touches the editor's on-disk file.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, binary: Optional[str] = None) -> None:
|
|
60
|
+
self._binary = binary or os.environ.get("CUBYTE_BIN") or shutil.which("cubyte")
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def available(self) -> bool:
|
|
64
|
+
return self._binary is not None
|
|
65
|
+
|
|
66
|
+
async def analyze(self, uri: str, text: str) -> AnalysisResult:
|
|
67
|
+
"""Run the compiler on ``text`` and return its diagnostics."""
|
|
68
|
+
if not self.available:
|
|
69
|
+
log.warning("cubyte binary not found on PATH and CUBYTE_BIN not set")
|
|
70
|
+
return AnalysisResult((), "", "", -1)
|
|
71
|
+
|
|
72
|
+
# The compiler derives ``<stem>-pp.cbyte`` and ``<stem>.cubin`` from
|
|
73
|
+
# the input path; passing the full ``.cbyte`` filename lets those
|
|
74
|
+
# siblings land in the same tmp directory we own.
|
|
75
|
+
with tempfile.TemporaryDirectory(prefix="cubyte-lsp-") as tmp:
|
|
76
|
+
src_path = os.path.join(tmp, "buf.cbyte")
|
|
77
|
+
with open(src_path, "w", encoding="utf-8") as f:
|
|
78
|
+
f.write(text)
|
|
79
|
+
|
|
80
|
+
cmd = [self._binary, src_path, os.path.join(tmp, "buf.cubin")]
|
|
81
|
+
try:
|
|
82
|
+
proc = await asyncio.create_subprocess_exec(
|
|
83
|
+
*cmd,
|
|
84
|
+
stdout=asyncio.subprocess.PIPE,
|
|
85
|
+
stderr=asyncio.subprocess.PIPE,
|
|
86
|
+
)
|
|
87
|
+
stdout, stderr = await proc.communicate()
|
|
88
|
+
except FileNotFoundError:
|
|
89
|
+
log.error("cubyte binary %s could not be executed", self._binary)
|
|
90
|
+
return AnalysisResult((), "", "", -1)
|
|
91
|
+
|
|
92
|
+
stderr_text = stderr.decode("utf-8", errors="replace")
|
|
93
|
+
stdout_text = stdout.decode("utf-8", errors="replace")
|
|
94
|
+
diagnostics = _parse_stderr(stderr_text)
|
|
95
|
+
return AnalysisResult(
|
|
96
|
+
diagnostics=tuple(diagnostics),
|
|
97
|
+
raw_stderr=stderr_text,
|
|
98
|
+
raw_stdout=stdout_text,
|
|
99
|
+
exit_code=proc.returncode if proc.returncode is not None else -1,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _parse_stderr(stderr: str) -> list[Diagnostic]:
|
|
104
|
+
diagnostics: list[Diagnostic] = []
|
|
105
|
+
for line in stderr.splitlines():
|
|
106
|
+
m = _STAGE_RE.match(line)
|
|
107
|
+
if not m:
|
|
108
|
+
continue
|
|
109
|
+
if m.group("stage_bracketed") is not None:
|
|
110
|
+
stage = m.group("stage_bracketed")
|
|
111
|
+
line_no = int(m.group("line1"))
|
|
112
|
+
col = 0
|
|
113
|
+
message = m.group("msg1").strip()
|
|
114
|
+
else:
|
|
115
|
+
stage = m.group("stage_plain")
|
|
116
|
+
line_no = int(m.group("line2"))
|
|
117
|
+
col = max(0, int(m.group("col")) - 1)
|
|
118
|
+
message = m.group("msg2").strip()
|
|
119
|
+
|
|
120
|
+
if line_no <= 0:
|
|
121
|
+
range_ = Range(start=Position(0, 0), end=Position(0, 0))
|
|
122
|
+
else:
|
|
123
|
+
# 0-based for LSP.
|
|
124
|
+
end_char = max(col + 1, len(message))
|
|
125
|
+
range_ = Range(
|
|
126
|
+
start=Position(line=line_no - 1, character=col),
|
|
127
|
+
end=Position(line=line_no - 1, character=end_char),
|
|
128
|
+
)
|
|
129
|
+
diagnostics.append(Diagnostic(
|
|
130
|
+
range=range_,
|
|
131
|
+
message=message,
|
|
132
|
+
severity=1, # DiagnosticSeverity.Error
|
|
133
|
+
source="cubyte",
|
|
134
|
+
code=stage.lower(),
|
|
135
|
+
))
|
|
136
|
+
return diagnostics
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""In-memory store of open cubyte documents.
|
|
2
|
+
|
|
3
|
+
The LSP server is single-process and shared across all open files, so a
|
|
4
|
+
plain :class:`dict` keyed on URI is sufficient. We also keep a simple
|
|
5
|
+
generation counter so an in-flight analysis for an older version of the
|
|
6
|
+
buffer can be discarded when a newer one arrives.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Document:
|
|
17
|
+
uri: str
|
|
18
|
+
version: int = 0
|
|
19
|
+
text: str = ""
|
|
20
|
+
language_id: str = "cubyte"
|
|
21
|
+
generation: int = 0 # bumped on every didChange
|
|
22
|
+
# Last diagnostics produced by the analyzer, kept so the client can
|
|
23
|
+
# be re-served them after a server restart of the editor session.
|
|
24
|
+
last_diagnostics: tuple = field(default_factory=tuple)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DocumentStore:
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
self._docs: dict[str, Document] = {}
|
|
30
|
+
|
|
31
|
+
def open(self, uri: str, version: int, text: str, language_id: str) -> Document:
|
|
32
|
+
doc = Document(uri=uri, version=version, text=text, language_id=language_id)
|
|
33
|
+
self._docs[uri] = doc
|
|
34
|
+
return doc
|
|
35
|
+
|
|
36
|
+
def update(self, uri: str, version: int, text: str) -> Optional[Document]:
|
|
37
|
+
doc = self._docs.get(uri)
|
|
38
|
+
if doc is None:
|
|
39
|
+
return None
|
|
40
|
+
doc.version = version
|
|
41
|
+
doc.text = text
|
|
42
|
+
doc.generation += 1
|
|
43
|
+
return doc
|
|
44
|
+
|
|
45
|
+
def close(self, uri: str) -> None:
|
|
46
|
+
self._docs.pop(uri, None)
|
|
47
|
+
|
|
48
|
+
def get(self, uri: str) -> Optional[Document]:
|
|
49
|
+
return self._docs.get(uri)
|
|
50
|
+
|
|
51
|
+
def all(self) -> list[Document]:
|
|
52
|
+
return list(self._docs.values())
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""cubyte_lsp.features — per-LSP-method implementations.
|
|
2
|
+
|
|
3
|
+
Each module exports a single coroutine that takes the
|
|
4
|
+
:class:`cubyte_lsp.protocol.jsonrpc.JsonRpcHandler` params dict and the
|
|
5
|
+
open :class:`~cubyte_lsp.documents.Document` (or documents) and returns
|
|
6
|
+
the JSON-serialisable LSP response body.
|
|
7
|
+
|
|
8
|
+
The convention is:
|
|
9
|
+
|
|
10
|
+
async def feature_<name>(params: dict, doc: Document) -> dict | list | None
|
|
11
|
+
|
|
12
|
+
``server.py`` wires these into JSON-RPC handlers. The feature modules
|
|
13
|
+
never touch the wire directly.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from . import completion, definition, diagnostics, formatting, hover, symbols # noqa: F401
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Completion provider.
|
|
2
|
+
|
|
3
|
+
Returns three kinds of items:
|
|
4
|
+
|
|
5
|
+
* Keywords, with a snippet form for the common shapes
|
|
6
|
+
(``let int : 4 $1 := $2;`` etc.).
|
|
7
|
+
* Type names (``int``, ``alg``).
|
|
8
|
+
* Piece labels (the 20 corner/edge names).
|
|
9
|
+
* Identifiers visible in the current document — declared variables and
|
|
10
|
+
labels. We use a regex pass for now; a proper parser would let us
|
|
11
|
+
scope these to the right block. cubyte's scoping rule is purely
|
|
12
|
+
textual (a name is in scope only after the line that declares it), so
|
|
13
|
+
we filter the document-symbols pass by the cursor's character offset
|
|
14
|
+
and drop any declaration that comes at or after the cursor.
|
|
15
|
+
|
|
16
|
+
The order is: keywords, types, then user identifiers, then pieces. The
|
|
17
|
+
detail field shows the kind so editors can render it in a column.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from typing import Iterable, Optional
|
|
24
|
+
|
|
25
|
+
from ..knowledge import keywords as kw
|
|
26
|
+
from ..knowledge import pieces as pc
|
|
27
|
+
from ..knowledge import types as ty
|
|
28
|
+
from ..protocol.types import (
|
|
29
|
+
COMPLETION_CONSTANT,
|
|
30
|
+
COMPLETION_KEYWORD,
|
|
31
|
+
COMPLETION_TEXT,
|
|
32
|
+
COMPLETION_VARIABLE,
|
|
33
|
+
CompletionItem,
|
|
34
|
+
Position,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# Snippets for keywords. ``$1``, ``$2`` are tab stops the editor fills in.
|
|
39
|
+
_SNIPPETS: dict[str, str] = {
|
|
40
|
+
"let": "let int : 4 $1 := $2;",
|
|
41
|
+
"if": "if not ($1 = 0) {\n\t$2\n}",
|
|
42
|
+
"while": "while not ($1 = 0) {\n\t$2\n}",
|
|
43
|
+
"goto": "goto $1;",
|
|
44
|
+
"input": "input \"$1\";",
|
|
45
|
+
"output":"output;",
|
|
46
|
+
"apply": "apply $1;",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def completion(params: dict, doc) -> list[dict]:
|
|
51
|
+
items: list[CompletionItem] = []
|
|
52
|
+
|
|
53
|
+
for k in kw.KEYWORDS:
|
|
54
|
+
items.append(CompletionItem(
|
|
55
|
+
label=k.name,
|
|
56
|
+
kind=COMPLETION_KEYWORD,
|
|
57
|
+
detail="keyword",
|
|
58
|
+
documentation=k.doc,
|
|
59
|
+
insert_text=_SNIPPETS.get(k.name, k.name),
|
|
60
|
+
))
|
|
61
|
+
|
|
62
|
+
for t in ty.TYPES:
|
|
63
|
+
items.append(CompletionItem(
|
|
64
|
+
label=t.name,
|
|
65
|
+
kind=COMPLETION_KEYWORD,
|
|
66
|
+
detail="type",
|
|
67
|
+
documentation=t.doc,
|
|
68
|
+
))
|
|
69
|
+
|
|
70
|
+
for piece in pc.PIECES:
|
|
71
|
+
items.append(CompletionItem(
|
|
72
|
+
label=piece.name,
|
|
73
|
+
kind=COMPLETION_CONSTANT,
|
|
74
|
+
detail="piece",
|
|
75
|
+
documentation=piece.doc,
|
|
76
|
+
))
|
|
77
|
+
|
|
78
|
+
# Document-level symbols are scoped to the cursor: cubyte's scoping
|
|
79
|
+
# rule is purely textual, so a declaration at or after the cursor
|
|
80
|
+
# is not yet in scope and must not be suggested. If the client omits
|
|
81
|
+
# the position we fall back to offering everything.
|
|
82
|
+
cursor_offset = _cursor_offset(params, doc.text)
|
|
83
|
+
items.extend(_document_symbols(doc.text, cursor_offset))
|
|
84
|
+
|
|
85
|
+
return [it.to_dict() for it in items]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cursor_offset(params: dict, text: str) -> Optional[int]:
|
|
89
|
+
"""Translate the completion request's ``position`` to a char offset.
|
|
90
|
+
|
|
91
|
+
Returns ``None`` if the position is missing or invalid, in which case
|
|
92
|
+
the caller should treat the buffer as having no scoping filter
|
|
93
|
+
(offer every declared symbol).
|
|
94
|
+
"""
|
|
95
|
+
raw = params.get("position")
|
|
96
|
+
if not isinstance(raw, dict):
|
|
97
|
+
return None
|
|
98
|
+
try:
|
|
99
|
+
position = Position.from_dict(raw)
|
|
100
|
+
except (KeyError, TypeError, ValueError):
|
|
101
|
+
return None
|
|
102
|
+
if position.line < 0 or position.character < 0:
|
|
103
|
+
return None
|
|
104
|
+
return _offset_for_line_col(text, position)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _offset_for_line_col(text: str, position: Position) -> int:
|
|
108
|
+
"""Convert an LSP ``Position`` to a 0-based char offset.
|
|
109
|
+
|
|
110
|
+
Clamps a column past the end of a line to that line's end (editors
|
|
111
|
+
may report the column one past the last glyph when triggering
|
|
112
|
+
completion on a trailing newline). Returns ``len(text)`` for
|
|
113
|
+
positions past the end of the buffer.
|
|
114
|
+
"""
|
|
115
|
+
line = 0
|
|
116
|
+
pos = 0
|
|
117
|
+
while pos <= len(text):
|
|
118
|
+
nl = text.find("\n", pos)
|
|
119
|
+
line_end = nl if nl != -1 else len(text)
|
|
120
|
+
if line == position.line:
|
|
121
|
+
col = min(position.character, line_end - pos)
|
|
122
|
+
return pos + col
|
|
123
|
+
if nl == -1:
|
|
124
|
+
return len(text)
|
|
125
|
+
line += 1
|
|
126
|
+
pos = nl + 1
|
|
127
|
+
return len(text)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _document_symbols(text: str, cursor_offset: Optional[int] = None) -> Iterable[CompletionItem]:
|
|
131
|
+
seen: set[str] = set()
|
|
132
|
+
# Variable declarations: `let (int|alg) [ : <order> ] name := ...`.
|
|
133
|
+
# cubyte scoping is textual — a `let` is in scope only from the line
|
|
134
|
+
# *after* its declaration. So when the cursor is on or before the
|
|
135
|
+
# declaration, the name must not be suggested (using it would be a
|
|
136
|
+
# typecheck error).
|
|
137
|
+
for m in re.finditer(r"let\s+(int|alg)\s*(?::\s*\d+\s+)?([A-Za-z_][A-Za-z_0-9]*)\s*:=", text):
|
|
138
|
+
if cursor_offset is not None and m.start() >= cursor_offset:
|
|
139
|
+
continue
|
|
140
|
+
name = m.group(2)
|
|
141
|
+
if name in seen:
|
|
142
|
+
continue
|
|
143
|
+
seen.add(name)
|
|
144
|
+
kind = "variable"
|
|
145
|
+
if m.group(1) == "alg":
|
|
146
|
+
kind = "algorithm"
|
|
147
|
+
yield CompletionItem(label=name, kind=COMPLETION_VARIABLE, detail=kind)
|
|
148
|
+
# Labels: `name:` at the start of a line (no `=` after). Labels in
|
|
149
|
+
# cubyte are valid ``goto`` targets from anywhere in the program
|
|
150
|
+
# (the typechecker collects all of them in a pre-pass), so we
|
|
151
|
+
# always offer them, regardless of cursor position.
|
|
152
|
+
for m in re.finditer(r"^([A-Za-z_][A-Za-z_0-9]*)\s*:\s*(?!=)", text, flags=re.MULTILINE):
|
|
153
|
+
name = m.group(1)
|
|
154
|
+
if name in seen:
|
|
155
|
+
continue
|
|
156
|
+
seen.add(name)
|
|
157
|
+
yield CompletionItem(label=name, kind=COMPLETION_TEXT, detail="label")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Go-to-definition.
|
|
2
|
+
|
|
3
|
+
Stub implementation: walks the document for the identifier under the
|
|
4
|
+
cursor and jumps to its declaration site (variable ``let`` or label).
|
|
5
|
+
The position reported is the *start* of the declaration so the editor
|
|
6
|
+
centres on the keyword.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from ..protocol.types import Position
|
|
14
|
+
from ..utils import position_at
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_VAR_RE = re.compile(r"let\s+(int|alg)\s*(?::\s*\d+\s+)?([A-Za-z_][A-Za-z_0-9]*)\s*:=")
|
|
18
|
+
_LABEL_RE = re.compile(r"^([A-Za-z_][A-Za-z_0-9]*)\s*:\s*(?!=)", re.MULTILINE)
|
|
19
|
+
_IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z_0-9]*")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def definition(params: dict, doc) -> list[dict] | None:
|
|
23
|
+
position = Position.from_dict(params["position"])
|
|
24
|
+
word = _word_at(doc.text, position)
|
|
25
|
+
if word is None:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
# Prefer a variable declaration, fall back to a label, fall back to None.
|
|
29
|
+
for pattern in (_VAR_RE, _LABEL_RE):
|
|
30
|
+
for m in pattern.finditer(doc.text):
|
|
31
|
+
name = m.group(2) if pattern is _VAR_RE else m.group(1)
|
|
32
|
+
if name != word:
|
|
33
|
+
continue
|
|
34
|
+
line = doc.text.count("\n", 0, m.start())
|
|
35
|
+
return [{
|
|
36
|
+
"uri": doc.uri,
|
|
37
|
+
"range": {
|
|
38
|
+
"start": {"line": line, "character": 0},
|
|
39
|
+
"end": {"line": line, "character": m.end() - m.start()},
|
|
40
|
+
},
|
|
41
|
+
}]
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _word_at(text: str, position: Position) -> str | None:
|
|
46
|
+
# Approximate the same algorithm as in hover.py. We do not import
|
|
47
|
+
# it to keep this module independent; the spec only requires we
|
|
48
|
+
# return the right identifier, not be clever about column edges.
|
|
49
|
+
offset = _offset_for(text, position)
|
|
50
|
+
if offset is None:
|
|
51
|
+
return None
|
|
52
|
+
start = offset
|
|
53
|
+
while start > 0 and _IDENT_RE.match(text[start - 1]):
|
|
54
|
+
start -= 1
|
|
55
|
+
end = offset
|
|
56
|
+
while end < len(text) and _IDENT_RE.match(text[end]):
|
|
57
|
+
end += 1
|
|
58
|
+
if start == end:
|
|
59
|
+
return None
|
|
60
|
+
return text[start:end]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _offset_for(text: str, position: Position) -> int | None:
|
|
64
|
+
line = 0
|
|
65
|
+
offset = 0
|
|
66
|
+
while offset <= len(text):
|
|
67
|
+
line_start = text.rfind("\n", 0, offset) + 1
|
|
68
|
+
line_end = text.find("\n", offset)
|
|
69
|
+
if line_end == -1:
|
|
70
|
+
line_end = len(text)
|
|
71
|
+
if line == position.line:
|
|
72
|
+
col = position.character - (offset - line_start)
|
|
73
|
+
if 0 <= col <= line_end - line_start:
|
|
74
|
+
return line_start + col
|
|
75
|
+
return None
|
|
76
|
+
offset = line_end + 1
|
|
77
|
+
line += 1
|
|
78
|
+
return None
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Diagnostics.
|
|
2
|
+
|
|
3
|
+
Runs the analyzer in the background whenever a document changes and
|
|
4
|
+
publishes the results via ``textDocument/publishDiagnostics``.
|
|
5
|
+
|
|
6
|
+
We use ``asyncio.create_task`` so the editor stays responsive while the
|
|
7
|
+
compiler runs. The :class:`~cubyte_lsp.documents.DocumentStore`
|
|
8
|
+
generation counter is consulted before pushing — a late result for an
|
|
9
|
+
older buffer is dropped on the floor.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
import uuid
|
|
17
|
+
|
|
18
|
+
from ..analyzer import CubyteAnalyzer
|
|
19
|
+
from ..protocol.jsonrpc import JsonRpcHandler
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger("cubyte_lsp.diagnostics")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DiagnosticPublisher:
|
|
25
|
+
"""Schedule analysis runs and publish the results."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, rpc: JsonRpcHandler, analyzer: CubyteAnalyzer) -> None:
|
|
28
|
+
self._rpc = rpc
|
|
29
|
+
self._analyzer = analyzer
|
|
30
|
+
# Generation counter so old runs can be discarded.
|
|
31
|
+
self._counters: dict[str, int] = {}
|
|
32
|
+
|
|
33
|
+
def schedule(self, doc) -> None:
|
|
34
|
+
"""Schedule an analysis for ``doc``.
|
|
35
|
+
|
|
36
|
+
Multiple calls collapse: if a run is already in flight we let it
|
|
37
|
+
finish, then re-schedule. This keeps the editor responsive
|
|
38
|
+
during fast typing bursts.
|
|
39
|
+
"""
|
|
40
|
+
gen = doc.generation
|
|
41
|
+
self._counters[doc.uri] = gen
|
|
42
|
+
asyncio.create_task(self._run(doc, gen))
|
|
43
|
+
|
|
44
|
+
async def _run(self, doc, generation: int) -> None:
|
|
45
|
+
try:
|
|
46
|
+
result = await self._analyzer.analyze(doc.uri, doc.text)
|
|
47
|
+
except Exception: # noqa: BLE001
|
|
48
|
+
log.exception("analyzer crashed for %s", doc.uri)
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
# Bail if the document changed while we were running.
|
|
52
|
+
if self._counters.get(doc.uri) != generation:
|
|
53
|
+
log.debug("discarding stale diagnostics for %s", doc.uri)
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
doc.last_diagnostics = result.diagnostics
|
|
57
|
+
await self._rpc.send_notification(
|
|
58
|
+
"textDocument/publishDiagnostics",
|
|
59
|
+
{
|
|
60
|
+
"uri": doc.uri,
|
|
61
|
+
"diagnostics": [d.to_dict() for d in result.diagnostics],
|
|
62
|
+
},
|
|
63
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Formatting.
|
|
2
|
+
|
|
3
|
+
A minimal, safe formatter:
|
|
4
|
+
|
|
5
|
+
* Strips trailing whitespace.
|
|
6
|
+
* Re-indents ``{ ... }`` blocks using the editor's tab size (default 4).
|
|
7
|
+
* Leaves all other content alone — the LSP spec encourages providers
|
|
8
|
+
that only do a *subset* of formatting, and we don't want to fight
|
|
9
|
+
users' existing conventions on ``:=`` alignment or comment style.
|
|
10
|
+
|
|
11
|
+
The function is total: it never raises, so the editor can call it on
|
|
12
|
+
any buffer without a try/except.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def formatting(params: dict, doc) -> list[dict] | None:
|
|
19
|
+
tab_size = int(params.get("options", {}).get("tabSize", 4))
|
|
20
|
+
text = doc.text
|
|
21
|
+
|
|
22
|
+
out_lines: list[str] = []
|
|
23
|
+
# The stack of indent levels, top of stack = current block's indent.
|
|
24
|
+
indent_stack: list[int] = [0]
|
|
25
|
+
for raw in text.splitlines():
|
|
26
|
+
line = raw.rstrip()
|
|
27
|
+
stripped = line.lstrip()
|
|
28
|
+
|
|
29
|
+
if not stripped:
|
|
30
|
+
out_lines.append("")
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
# Pop any indent levels that are *strictly greater* than this
|
|
34
|
+
# line's indent. We compare on the line's *raw* indent so a
|
|
35
|
+
# correctly-indented block closer still pops cleanly.
|
|
36
|
+
leading = len(line) - len(stripped)
|
|
37
|
+
# If the line is a block closer (starts with ``}``) we will pop
|
|
38
|
+
# below; otherwise we pop anything whose indent is now too deep.
|
|
39
|
+
if stripped.startswith("}"):
|
|
40
|
+
while len(indent_stack) > 1 and indent_stack[-1] >= leading:
|
|
41
|
+
indent_stack.pop()
|
|
42
|
+
if len(indent_stack) > 1:
|
|
43
|
+
indent_stack.pop()
|
|
44
|
+
target = indent_stack[-1] if indent_stack else 0
|
|
45
|
+
else:
|
|
46
|
+
while len(indent_stack) > 1 and indent_stack[-1] > leading:
|
|
47
|
+
indent_stack.pop()
|
|
48
|
+
target = indent_stack[-1] if indent_stack else 0
|
|
49
|
+
|
|
50
|
+
out_lines.append(" " * target + stripped)
|
|
51
|
+
|
|
52
|
+
# After this line, any new block opener advances the stack.
|
|
53
|
+
if stripped.endswith("{") and not stripped.startswith("}"):
|
|
54
|
+
indent_stack.append(target + tab_size)
|
|
55
|
+
|
|
56
|
+
new_text = "\n".join(out_lines)
|
|
57
|
+
if text.endswith("\n"):
|
|
58
|
+
new_text += "\n"
|
|
59
|
+
|
|
60
|
+
# Whole-document edit, as required by LSP.
|
|
61
|
+
last_line = text.count("\n")
|
|
62
|
+
last_nl = text.rfind("\n")
|
|
63
|
+
end_col = len(text) - (last_nl + 1) if last_nl != -1 else len(text)
|
|
64
|
+
return [{
|
|
65
|
+
"range": {
|
|
66
|
+
"start": {"line": 0, "character": 0},
|
|
67
|
+
"end": {"line": last_line, "character": end_col},
|
|
68
|
+
},
|
|
69
|
+
"newText": new_text,
|
|
70
|
+
}]
|