pyxle-langkit 0.2.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.
- pyxle_langkit/__init__.py +18 -0
- pyxle_langkit/cli.py +206 -0
- pyxle_langkit/completions.py +397 -0
- pyxle_langkit/definitions.py +273 -0
- pyxle_langkit/diagnostics.py +142 -0
- pyxle_langkit/document.py +187 -0
- pyxle_langkit/formatting.py +292 -0
- pyxle_langkit/hover.py +407 -0
- pyxle_langkit/js/jsx_component_extractor.mjs +193 -0
- pyxle_langkit/js/react_parser_runner.mjs +116 -0
- pyxle_langkit/js/ts_service.mjs +338 -0
- pyxle_langkit/linter.py +850 -0
- pyxle_langkit/parser_adapter.py +123 -0
- pyxle_langkit/react_checker.py +248 -0
- pyxle_langkit/semantic_tokens.py +363 -0
- pyxle_langkit/server.py +639 -0
- pyxle_langkit/symbols.py +253 -0
- pyxle_langkit/ts_bridge.py +319 -0
- pyxle_langkit/workspace.py +193 -0
- pyxle_langkit-0.2.0.dist-info/METADATA +69 -0
- pyxle_langkit-0.2.0.dist-info/RECORD +24 -0
- pyxle_langkit-0.2.0.dist-info/WHEEL +4 -0
- pyxle_langkit-0.2.0.dist-info/entry_points.txt +3 -0
- pyxle_langkit-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Pyxle language toolkit: LSP server, linter, and editor integrations.
|
|
2
|
+
|
|
3
|
+
Public API for consumers that need to parse, analyze, or index ``.pyx``
|
|
4
|
+
files programmatically.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .document import PyxDocument
|
|
10
|
+
from .parser_adapter import TolerantParser
|
|
11
|
+
from .workspace import WorkspaceIndex, WorkspaceSymbol
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"PyxDocument",
|
|
15
|
+
"TolerantParser",
|
|
16
|
+
"WorkspaceIndex",
|
|
17
|
+
"WorkspaceSymbol",
|
|
18
|
+
]
|
pyxle_langkit/cli.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Command-line interface for Pyxle language tools.
|
|
2
|
+
|
|
3
|
+
Provides ``parse``, ``lint``, ``outline``, and ``format`` subcommands
|
|
4
|
+
for use outside of an editor.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from .linter import PyxLinter
|
|
16
|
+
from .parser_adapter import TolerantParser
|
|
17
|
+
from .symbols import extract_document_symbols
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
name="pyxle-langkit",
|
|
21
|
+
help="Language tools for Pyxle .pyx files.",
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ------------------------------------------------------------------
|
|
27
|
+
# parse
|
|
28
|
+
# ------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command()
|
|
32
|
+
def parse(
|
|
33
|
+
file: Path = typer.Argument(..., exists=True, help="Path to a .pyx file."),
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Parse a .pyx file and output its structure as JSON."""
|
|
36
|
+
parser = TolerantParser()
|
|
37
|
+
document = parser.parse(file)
|
|
38
|
+
|
|
39
|
+
output = {
|
|
40
|
+
"path": str(document.path),
|
|
41
|
+
"has_python": document.has_python,
|
|
42
|
+
"has_jsx": document.has_jsx,
|
|
43
|
+
"python_lines": len(document.python_code.splitlines()),
|
|
44
|
+
"jsx_lines": len(document.jsx_code.splitlines()),
|
|
45
|
+
"loader": (
|
|
46
|
+
{
|
|
47
|
+
"name": document.loader.name,
|
|
48
|
+
"line": document.loader.line_number,
|
|
49
|
+
"is_async": document.loader.is_async,
|
|
50
|
+
"parameters": list(document.loader.parameters),
|
|
51
|
+
}
|
|
52
|
+
if document.loader
|
|
53
|
+
else None
|
|
54
|
+
),
|
|
55
|
+
"actions": [
|
|
56
|
+
{
|
|
57
|
+
"name": a.name,
|
|
58
|
+
"line": a.line_number,
|
|
59
|
+
"is_async": a.is_async,
|
|
60
|
+
"parameters": list(a.parameters),
|
|
61
|
+
}
|
|
62
|
+
for a in document.actions
|
|
63
|
+
],
|
|
64
|
+
"head_elements": list(document.head_elements),
|
|
65
|
+
"head_is_dynamic": document.head_is_dynamic,
|
|
66
|
+
"diagnostics": [
|
|
67
|
+
{
|
|
68
|
+
"section": d.section,
|
|
69
|
+
"severity": d.severity,
|
|
70
|
+
"message": d.message,
|
|
71
|
+
"line": d.line,
|
|
72
|
+
}
|
|
73
|
+
for d in document.diagnostics
|
|
74
|
+
],
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
typer.echo(json.dumps(output, indent=2))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
# lint
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command()
|
|
86
|
+
def lint(
|
|
87
|
+
file: Path = typer.Argument(..., exists=True, help="Path to a .pyx file."),
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Lint a .pyx file and print diagnostics."""
|
|
90
|
+
parser = TolerantParser()
|
|
91
|
+
document = parser.parse(file)
|
|
92
|
+
linter = PyxLinter()
|
|
93
|
+
issues = linter.lint(document)
|
|
94
|
+
|
|
95
|
+
# Also include parser diagnostics.
|
|
96
|
+
has_errors = False
|
|
97
|
+
|
|
98
|
+
for diag in document.diagnostics:
|
|
99
|
+
severity = diag.severity.upper()
|
|
100
|
+
line = diag.line or "?"
|
|
101
|
+
color = typer.colors.RED if diag.severity == "error" else typer.colors.YELLOW
|
|
102
|
+
typer.secho(
|
|
103
|
+
f" {line:>5} {severity:<8} [{diag.section}] {diag.message}",
|
|
104
|
+
fg=color,
|
|
105
|
+
)
|
|
106
|
+
if diag.severity == "error":
|
|
107
|
+
has_errors = True
|
|
108
|
+
|
|
109
|
+
for issue in issues:
|
|
110
|
+
severity = issue.severity.upper()
|
|
111
|
+
color = (
|
|
112
|
+
typer.colors.RED
|
|
113
|
+
if issue.severity == "error"
|
|
114
|
+
else typer.colors.YELLOW
|
|
115
|
+
if issue.severity == "warning"
|
|
116
|
+
else typer.colors.CYAN
|
|
117
|
+
)
|
|
118
|
+
typer.secho(
|
|
119
|
+
f" {issue.line:>5} {severity:<8} [{issue.rule}] {issue.message}",
|
|
120
|
+
fg=color,
|
|
121
|
+
)
|
|
122
|
+
if issue.severity == "error":
|
|
123
|
+
has_errors = True
|
|
124
|
+
|
|
125
|
+
total = len(issues) + len(document.diagnostics)
|
|
126
|
+
if total == 0:
|
|
127
|
+
typer.secho(" No issues found.", fg=typer.colors.GREEN)
|
|
128
|
+
else:
|
|
129
|
+
typer.echo(f"\n {total} issue(s) found.")
|
|
130
|
+
|
|
131
|
+
if has_errors:
|
|
132
|
+
raise typer.Exit(code=1)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
# outline
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.command()
|
|
141
|
+
def outline(
|
|
142
|
+
file: Path = typer.Argument(..., exists=True, help="Path to a .pyx file."),
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Show the symbol outline of a .pyx file."""
|
|
145
|
+
parser = TolerantParser()
|
|
146
|
+
document = parser.parse(file)
|
|
147
|
+
symbols = extract_document_symbols(document)
|
|
148
|
+
|
|
149
|
+
if not symbols:
|
|
150
|
+
typer.echo(" (no symbols)")
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
for sym in symbols:
|
|
154
|
+
detail = f" ({sym.detail})" if sym.detail else ""
|
|
155
|
+
typer.echo(f" {sym.line:>5} {sym.kind:<16} {sym.name}{detail}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ------------------------------------------------------------------
|
|
159
|
+
# format
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.command(name="format")
|
|
164
|
+
def format_cmd(
|
|
165
|
+
file: Path = typer.Argument(..., exists=True, help="Path to a .pyx file."),
|
|
166
|
+
python_formatter: str = typer.Option("ruff", help="Python formatter: ruff, black, none."),
|
|
167
|
+
jsx_formatter: str = typer.Option("prettier", help="JSX formatter: prettier, none."),
|
|
168
|
+
check: bool = typer.Option(False, "--check", help="Check if file would be changed."),
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Format a .pyx file."""
|
|
171
|
+
from .formatting import format_document
|
|
172
|
+
|
|
173
|
+
text = file.read_text(encoding="utf-8")
|
|
174
|
+
edits = asyncio.run(
|
|
175
|
+
format_document(
|
|
176
|
+
text,
|
|
177
|
+
path=file,
|
|
178
|
+
python_formatter=python_formatter,
|
|
179
|
+
jsx_formatter=jsx_formatter,
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
if not edits:
|
|
184
|
+
typer.echo(f" {file}: already formatted")
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
if check:
|
|
188
|
+
typer.secho(f" {file}: would be reformatted", fg=typer.colors.YELLOW)
|
|
189
|
+
raise typer.Exit(code=1)
|
|
190
|
+
|
|
191
|
+
# Apply edits to the original text.
|
|
192
|
+
lines = text.splitlines(keepends=True)
|
|
193
|
+
# Apply edits in reverse order so line numbers stay valid.
|
|
194
|
+
for edit in sorted(edits, key=lambda e: e.start_line, reverse=True):
|
|
195
|
+
start_idx = edit.start_line - 1
|
|
196
|
+
end_idx = edit.end_line - 1
|
|
197
|
+
new_lines = (edit.new_text + "\n").splitlines(keepends=True)
|
|
198
|
+
lines[start_idx:end_idx] = new_lines
|
|
199
|
+
|
|
200
|
+
formatted = "".join(lines)
|
|
201
|
+
file.write_text(formatted, encoding="utf-8")
|
|
202
|
+
typer.secho(f" {file}: reformatted", fg=typer.colors.GREEN)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if __name__ == "__main__": # pragma: no cover
|
|
206
|
+
app()
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""Completion provider for ``.pyx`` files.
|
|
2
|
+
|
|
3
|
+
Combines Jedi-based Python completions with Pyxle-specific component
|
|
4
|
+
and import completions for JSX sections.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Sequence
|
|
14
|
+
|
|
15
|
+
from lsprotocol.types import (
|
|
16
|
+
CompletionItem,
|
|
17
|
+
CompletionItemKind,
|
|
18
|
+
InsertTextFormat,
|
|
19
|
+
MarkupContent,
|
|
20
|
+
MarkupKind,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from pyxle_langkit.document import PyxDocument
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# ------------------------------------------------------------------
|
|
28
|
+
# Safe jedi import
|
|
29
|
+
# ------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
import jedi
|
|
33
|
+
except ImportError:
|
|
34
|
+
jedi = None # type: ignore[assignment]
|
|
35
|
+
|
|
36
|
+
# ------------------------------------------------------------------
|
|
37
|
+
# Pyxle component definitions
|
|
38
|
+
# ------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class _ComponentDef:
|
|
43
|
+
"""Definition of a built-in Pyxle component for completion."""
|
|
44
|
+
|
|
45
|
+
name: str
|
|
46
|
+
props: tuple[str, ...]
|
|
47
|
+
doc: str
|
|
48
|
+
is_container: bool = False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_PYXLE_COMPONENTS: tuple[_ComponentDef, ...] = (
|
|
52
|
+
_ComponentDef(
|
|
53
|
+
name="Link",
|
|
54
|
+
props=("href", "prefetch", "replace", "scroll"),
|
|
55
|
+
doc="Client-side navigation link. Prefetches on hover by default.",
|
|
56
|
+
),
|
|
57
|
+
_ComponentDef(
|
|
58
|
+
name="Script",
|
|
59
|
+
props=("src", "strategy", "async", "defer", "module", "noModule"),
|
|
60
|
+
doc="Managed script tag with loading strategy control.",
|
|
61
|
+
),
|
|
62
|
+
_ComponentDef(
|
|
63
|
+
name="Image",
|
|
64
|
+
props=("src", "width", "height", "alt", "priority", "lazy"),
|
|
65
|
+
doc="Optimised image component with automatic sizing.",
|
|
66
|
+
),
|
|
67
|
+
_ComponentDef(
|
|
68
|
+
name="Head",
|
|
69
|
+
props=(),
|
|
70
|
+
doc="Container for ``<head>`` meta elements.",
|
|
71
|
+
is_container=True,
|
|
72
|
+
),
|
|
73
|
+
_ComponentDef(
|
|
74
|
+
name="Slot",
|
|
75
|
+
props=("name",),
|
|
76
|
+
doc="Named slot for layout composition.",
|
|
77
|
+
),
|
|
78
|
+
_ComponentDef(
|
|
79
|
+
name="ClientOnly",
|
|
80
|
+
props=(),
|
|
81
|
+
doc="Renders children only on the client (skipped during SSR).",
|
|
82
|
+
is_container=True,
|
|
83
|
+
),
|
|
84
|
+
_ComponentDef(
|
|
85
|
+
name="Form",
|
|
86
|
+
props=("action", "method"),
|
|
87
|
+
doc="Enhanced form with server action integration.",
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
_PYXLE_COMPONENT_NAMES = frozenset(c.name for c in _PYXLE_COMPONENTS)
|
|
92
|
+
_PYXLE_COMPONENT_MAP = {c.name: c for c in _PYXLE_COMPONENTS}
|
|
93
|
+
|
|
94
|
+
_IMPORT_SOURCE = "pyxle/client"
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
# Tag context detection
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
_TAG_OPEN_RE = re.compile(r"<\s*([A-Z]\w*)?$")
|
|
101
|
+
_PROP_CONTEXT_RE = re.compile(r"<\s*([A-Z]\w+)\b[^>]*\s+(\w*)$")
|
|
102
|
+
_DATA_DOT_RE = re.compile(r"\bdata\.(\w*)$")
|
|
103
|
+
_IMPORT_RE = re.compile(
|
|
104
|
+
r"""import\s*\{[^}]*$|from\s+['"]pyxle/client['"]\s*$""",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
# Jedi completion kind mapping
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
_JEDI_KIND_MAP: dict[str, CompletionItemKind] = {
|
|
112
|
+
"module": CompletionItemKind.Module,
|
|
113
|
+
"class": CompletionItemKind.Class,
|
|
114
|
+
"instance": CompletionItemKind.Variable,
|
|
115
|
+
"function": CompletionItemKind.Function,
|
|
116
|
+
"param": CompletionItemKind.Variable,
|
|
117
|
+
"path": CompletionItemKind.File,
|
|
118
|
+
"keyword": CompletionItemKind.Keyword,
|
|
119
|
+
"property": CompletionItemKind.Property,
|
|
120
|
+
"statement": CompletionItemKind.Variable,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
# Completion provider
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class CompletionProvider:
|
|
130
|
+
"""Provides completions for Python and JSX sections of ``.pyx`` files."""
|
|
131
|
+
|
|
132
|
+
def complete(
|
|
133
|
+
self,
|
|
134
|
+
document: PyxDocument,
|
|
135
|
+
line: int,
|
|
136
|
+
column: int,
|
|
137
|
+
) -> Sequence[CompletionItem]:
|
|
138
|
+
"""Provide completions at the given position in a ``.pyx`` file.
|
|
139
|
+
|
|
140
|
+
*line* is 1-indexed (matches LSP convention after +1 adjustment).
|
|
141
|
+
*column* is 0-indexed.
|
|
142
|
+
"""
|
|
143
|
+
section = document.section_at_line(line)
|
|
144
|
+
|
|
145
|
+
if section == "python":
|
|
146
|
+
return self._complete_python(document, line, column)
|
|
147
|
+
if section == "jsx":
|
|
148
|
+
return self._complete_jsx(document, line, column)
|
|
149
|
+
|
|
150
|
+
return ()
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
# Python completions (via jedi)
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def _complete_python(
|
|
157
|
+
self,
|
|
158
|
+
document: PyxDocument,
|
|
159
|
+
line: int,
|
|
160
|
+
column: int,
|
|
161
|
+
) -> Sequence[CompletionItem]:
|
|
162
|
+
"""Provide Python completions using Jedi."""
|
|
163
|
+
if jedi is None:
|
|
164
|
+
logger.debug("Jedi not available; skipping Python completions")
|
|
165
|
+
return ()
|
|
166
|
+
|
|
167
|
+
virtual_code, line_numbers = document.virtual_python_for_jedi()
|
|
168
|
+
if not virtual_code.strip():
|
|
169
|
+
return ()
|
|
170
|
+
|
|
171
|
+
virtual_line = _map_to_virtual_line(line, line_numbers)
|
|
172
|
+
if virtual_line is None:
|
|
173
|
+
return ()
|
|
174
|
+
|
|
175
|
+
path = str(document.path) if document.path else None
|
|
176
|
+
try:
|
|
177
|
+
script = jedi.Script(virtual_code, path=path)
|
|
178
|
+
completions = script.complete(virtual_line, column)
|
|
179
|
+
except Exception:
|
|
180
|
+
logger.debug("Jedi completion failed", exc_info=True)
|
|
181
|
+
return ()
|
|
182
|
+
|
|
183
|
+
return tuple(
|
|
184
|
+
_jedi_completion_to_lsp(c) for c in completions
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
# JSX completions (Pyxle-specific)
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def _complete_jsx(
|
|
192
|
+
self,
|
|
193
|
+
document: PyxDocument,
|
|
194
|
+
line: int,
|
|
195
|
+
column: int,
|
|
196
|
+
) -> Sequence[CompletionItem]:
|
|
197
|
+
"""Provide JSX completions for Pyxle components, props, and data."""
|
|
198
|
+
# Get the current line text from JSX source.
|
|
199
|
+
line_text = _get_jsx_line_text(document, line)
|
|
200
|
+
if line_text is None:
|
|
201
|
+
return ()
|
|
202
|
+
|
|
203
|
+
prefix = line_text[:column] if column <= len(line_text) else line_text
|
|
204
|
+
items: list[CompletionItem] = []
|
|
205
|
+
|
|
206
|
+
# 1. Component tag completions (after '<').
|
|
207
|
+
tag_match = _TAG_OPEN_RE.search(prefix)
|
|
208
|
+
if tag_match is not None:
|
|
209
|
+
items.extend(self._complete_components(tag_match.group(1) or ""))
|
|
210
|
+
return tuple(items)
|
|
211
|
+
|
|
212
|
+
# 2. Prop completions (inside an open tag).
|
|
213
|
+
prop_match = _PROP_CONTEXT_RE.search(prefix)
|
|
214
|
+
if prop_match is not None:
|
|
215
|
+
component_name = prop_match.group(1)
|
|
216
|
+
prop_prefix = prop_match.group(2)
|
|
217
|
+
items.extend(self._complete_props(component_name, prop_prefix))
|
|
218
|
+
return tuple(items)
|
|
219
|
+
|
|
220
|
+
# 3. data.{key} completions.
|
|
221
|
+
data_match = _DATA_DOT_RE.search(prefix)
|
|
222
|
+
if data_match is not None:
|
|
223
|
+
items.extend(self._complete_data_keys(document, data_match.group(1)))
|
|
224
|
+
return tuple(items)
|
|
225
|
+
|
|
226
|
+
# 4. Import completions.
|
|
227
|
+
if _IMPORT_RE.search(prefix):
|
|
228
|
+
items.extend(self._complete_imports(""))
|
|
229
|
+
return tuple(items)
|
|
230
|
+
|
|
231
|
+
return tuple(items)
|
|
232
|
+
|
|
233
|
+
def _complete_components(self, prefix: str) -> Sequence[CompletionItem]:
|
|
234
|
+
"""Provide Pyxle component name completions."""
|
|
235
|
+
items: list[CompletionItem] = []
|
|
236
|
+
for comp in _PYXLE_COMPONENTS:
|
|
237
|
+
if comp.name.startswith(prefix):
|
|
238
|
+
snippet = (
|
|
239
|
+
f"{comp.name}>${{0}}</{comp.name}>"
|
|
240
|
+
if comp.is_container
|
|
241
|
+
else f"{comp.name} ${{0}}/>"
|
|
242
|
+
)
|
|
243
|
+
items.append(
|
|
244
|
+
CompletionItem(
|
|
245
|
+
label=comp.name,
|
|
246
|
+
kind=CompletionItemKind.Class,
|
|
247
|
+
detail=f"pyxle/client — {comp.doc}",
|
|
248
|
+
insert_text=snippet,
|
|
249
|
+
insert_text_format=InsertTextFormat.Snippet,
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
return items
|
|
253
|
+
|
|
254
|
+
def _complete_props(
|
|
255
|
+
self,
|
|
256
|
+
component_name: str,
|
|
257
|
+
prop_prefix: str,
|
|
258
|
+
) -> Sequence[CompletionItem]:
|
|
259
|
+
"""Provide prop completions for a Pyxle component."""
|
|
260
|
+
comp = _PYXLE_COMPONENT_MAP.get(component_name)
|
|
261
|
+
if comp is None:
|
|
262
|
+
return ()
|
|
263
|
+
|
|
264
|
+
items: list[CompletionItem] = []
|
|
265
|
+
for prop in comp.props:
|
|
266
|
+
if prop.startswith(prop_prefix):
|
|
267
|
+
items.append(
|
|
268
|
+
CompletionItem(
|
|
269
|
+
label=prop,
|
|
270
|
+
kind=CompletionItemKind.Property,
|
|
271
|
+
detail=f"{component_name} prop",
|
|
272
|
+
insert_text=f'{prop}={{${{0}}}}',
|
|
273
|
+
insert_text_format=InsertTextFormat.Snippet,
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
return items
|
|
277
|
+
|
|
278
|
+
def _complete_data_keys(
|
|
279
|
+
self,
|
|
280
|
+
document: PyxDocument,
|
|
281
|
+
prefix: str,
|
|
282
|
+
) -> Sequence[CompletionItem]:
|
|
283
|
+
"""Provide ``data.{key}`` completions from the loader return dict."""
|
|
284
|
+
keys = _infer_loader_return_keys(document)
|
|
285
|
+
items: list[CompletionItem] = []
|
|
286
|
+
for key in keys:
|
|
287
|
+
if key.startswith(prefix):
|
|
288
|
+
items.append(
|
|
289
|
+
CompletionItem(
|
|
290
|
+
label=key,
|
|
291
|
+
kind=CompletionItemKind.Property,
|
|
292
|
+
detail="loader data",
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
return items
|
|
296
|
+
|
|
297
|
+
def _complete_imports(self, prefix: str) -> Sequence[CompletionItem]:
|
|
298
|
+
"""Provide import completions for ``pyxle/client``."""
|
|
299
|
+
items: list[CompletionItem] = []
|
|
300
|
+
for name in sorted(_PYXLE_COMPONENT_NAMES):
|
|
301
|
+
if name.startswith(prefix):
|
|
302
|
+
items.append(
|
|
303
|
+
CompletionItem(
|
|
304
|
+
label=name,
|
|
305
|
+
kind=CompletionItemKind.Class,
|
|
306
|
+
detail=f"import from {_IMPORT_SOURCE}",
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
return items
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ------------------------------------------------------------------
|
|
313
|
+
# Helpers
|
|
314
|
+
# ------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _map_to_virtual_line(
|
|
318
|
+
pyx_line: int,
|
|
319
|
+
virtual_line_numbers: tuple[int, ...],
|
|
320
|
+
) -> int | None:
|
|
321
|
+
"""Map a 1-indexed .pyx line to a 1-indexed virtual Python line.
|
|
322
|
+
|
|
323
|
+
Searches the line map for the first entry matching *pyx_line*.
|
|
324
|
+
Returns ``None`` if no mapping exists.
|
|
325
|
+
"""
|
|
326
|
+
for virtual_idx, orig_line in enumerate(virtual_line_numbers):
|
|
327
|
+
if orig_line == pyx_line:
|
|
328
|
+
return virtual_idx + 1
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _get_jsx_line_text(document: PyxDocument, pyx_line: int) -> str | None:
|
|
333
|
+
"""Get the text of a JSX line corresponding to a .pyx line number.
|
|
334
|
+
|
|
335
|
+
Returns ``None`` if the line is not in the JSX section.
|
|
336
|
+
"""
|
|
337
|
+
jsx_lines = document.jsx_code.splitlines()
|
|
338
|
+
for jsx_idx, orig_line in enumerate(document.jsx_line_numbers):
|
|
339
|
+
if orig_line == pyx_line and jsx_idx < len(jsx_lines):
|
|
340
|
+
return jsx_lines[jsx_idx]
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _jedi_completion_to_lsp(completion: object) -> CompletionItem:
|
|
345
|
+
"""Convert a Jedi completion to an LSP ``CompletionItem``."""
|
|
346
|
+
name = getattr(completion, "name", "")
|
|
347
|
+
type_str = getattr(completion, "type", "")
|
|
348
|
+
description = getattr(completion, "description", "")
|
|
349
|
+
|
|
350
|
+
kind = _JEDI_KIND_MAP.get(type_str, CompletionItemKind.Text)
|
|
351
|
+
|
|
352
|
+
doc: MarkupContent | None = None
|
|
353
|
+
if description:
|
|
354
|
+
doc = MarkupContent(kind=MarkupKind.PlainText, value=description)
|
|
355
|
+
|
|
356
|
+
return CompletionItem(
|
|
357
|
+
label=name,
|
|
358
|
+
kind=kind,
|
|
359
|
+
detail=type_str,
|
|
360
|
+
documentation=doc,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _infer_loader_return_keys(document: PyxDocument) -> tuple[str, ...]:
|
|
365
|
+
"""Infer the dict keys returned by the ``@server`` loader function.
|
|
366
|
+
|
|
367
|
+
Parses the Python AST looking for the loader function and inspects
|
|
368
|
+
its return statement(s). If the return value is a dict literal,
|
|
369
|
+
extracts the string keys.
|
|
370
|
+
"""
|
|
371
|
+
if document.loader is None or not document.has_python:
|
|
372
|
+
return ()
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
tree = ast.parse(document.python_code)
|
|
376
|
+
except SyntaxError:
|
|
377
|
+
return ()
|
|
378
|
+
|
|
379
|
+
loader_name = document.loader.name
|
|
380
|
+
for node in ast.iter_child_nodes(tree):
|
|
381
|
+
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
382
|
+
if node.name == loader_name:
|
|
383
|
+
return _extract_return_dict_keys(node)
|
|
384
|
+
|
|
385
|
+
return ()
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _extract_return_dict_keys(func_node: ast.FunctionDef | ast.AsyncFunctionDef) -> tuple[str, ...]:
|
|
389
|
+
"""Extract string keys from dict return statements in a function."""
|
|
390
|
+
keys: list[str] = []
|
|
391
|
+
for node in ast.walk(func_node):
|
|
392
|
+
if isinstance(node, ast.Return) and node.value is not None:
|
|
393
|
+
if isinstance(node.value, ast.Dict):
|
|
394
|
+
for key in node.value.keys:
|
|
395
|
+
if isinstance(key, ast.Constant) and isinstance(key.value, str):
|
|
396
|
+
keys.append(key.value)
|
|
397
|
+
return tuple(dict.fromkeys(keys)) # Deduplicate, preserve order.
|