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.
Files changed (32) hide show
  1. cubyte_lsp-0.1.0/PKG-INFO +6 -0
  2. cubyte_lsp-0.1.0/README.md +83 -0
  3. cubyte_lsp-0.1.0/cubyte_lsp/__init__.py +10 -0
  4. cubyte_lsp-0.1.0/cubyte_lsp/__main__.py +6 -0
  5. cubyte_lsp-0.1.0/cubyte_lsp/analyzer.py +136 -0
  6. cubyte_lsp-0.1.0/cubyte_lsp/documents.py +52 -0
  7. cubyte_lsp-0.1.0/cubyte_lsp/features/__init__.py +16 -0
  8. cubyte_lsp-0.1.0/cubyte_lsp/features/completion.py +157 -0
  9. cubyte_lsp-0.1.0/cubyte_lsp/features/definition.py +78 -0
  10. cubyte_lsp-0.1.0/cubyte_lsp/features/diagnostics.py +63 -0
  11. cubyte_lsp-0.1.0/cubyte_lsp/features/formatting.py +70 -0
  12. cubyte_lsp-0.1.0/cubyte_lsp/features/hover.py +134 -0
  13. cubyte_lsp-0.1.0/cubyte_lsp/features/symbols.py +84 -0
  14. cubyte_lsp-0.1.0/cubyte_lsp/knowledge/__init__.py +8 -0
  15. cubyte_lsp-0.1.0/cubyte_lsp/knowledge/keywords.py +44 -0
  16. cubyte_lsp-0.1.0/cubyte_lsp/knowledge/pieces.py +55 -0
  17. cubyte_lsp-0.1.0/cubyte_lsp/knowledge/types.py +45 -0
  18. cubyte_lsp-0.1.0/cubyte_lsp/protocol/__init__.py +4 -0
  19. cubyte_lsp-0.1.0/cubyte_lsp/protocol/jsonrpc.py +210 -0
  20. cubyte_lsp-0.1.0/cubyte_lsp/protocol/types.py +183 -0
  21. cubyte_lsp-0.1.0/cubyte_lsp/server.py +258 -0
  22. cubyte_lsp-0.1.0/cubyte_lsp/tests/__init__.py +0 -0
  23. cubyte_lsp-0.1.0/cubyte_lsp/tests/test_completion_scope.py +116 -0
  24. cubyte_lsp-0.1.0/cubyte_lsp/tests/test_jsonrpc.py +81 -0
  25. cubyte_lsp-0.1.0/cubyte_lsp/utils.py +62 -0
  26. cubyte_lsp-0.1.0/cubyte_lsp.egg-info/PKG-INFO +6 -0
  27. cubyte_lsp-0.1.0/cubyte_lsp.egg-info/SOURCES.txt +30 -0
  28. cubyte_lsp-0.1.0/cubyte_lsp.egg-info/dependency_links.txt +1 -0
  29. cubyte_lsp-0.1.0/cubyte_lsp.egg-info/entry_points.txt +2 -0
  30. cubyte_lsp-0.1.0/cubyte_lsp.egg-info/top_level.txt +1 -0
  31. cubyte_lsp-0.1.0/pyproject.toml +19 -0
  32. cubyte_lsp-0.1.0/setup.cfg +4 -0
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: cubyte-lsp
3
+ Version: 0.1.0
4
+ Summary: Language Server Protocol implementation for cubyte.
5
+ Author: cubyte authors
6
+ Requires-Python: >=3.10
@@ -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,6 @@
1
+ """Entry point so ``python -m cubyte_lsp`` works."""
2
+
3
+ from .server import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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
+ }]