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.
@@ -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.