renpy-language-server 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.

Potentially problematic release.


This version of renpy-language-server might be problematic. Click here for more details.

Files changed (44) hide show
  1. renpy_language_server/__init__.py +6 -0
  2. renpy_language_server/__main__.py +5 -0
  3. renpy_language_server/analysis.py +230 -0
  4. renpy_language_server/assets.py +104 -0
  5. renpy_language_server/builtins.py +54 -0
  6. renpy_language_server/cli.py +190 -0
  7. renpy_language_server/code_actions.py +130 -0
  8. renpy_language_server/completion.py +1191 -0
  9. renpy_language_server/completion_data.py +74 -0
  10. renpy_language_server/dap.py +824 -0
  11. renpy_language_server/data/__init__.py +1 -0
  12. renpy_language_server/data/completion.json +756 -0
  13. renpy_language_server/data/renpy_api.json +8073 -0
  14. renpy_language_server/definition.py +77 -0
  15. renpy_language_server/diagnostics.py +895 -0
  16. renpy_language_server/docs.py +21 -0
  17. renpy_language_server/edits.py +70 -0
  18. renpy_language_server/folding.py +107 -0
  19. renpy_language_server/formatter.py +288 -0
  20. renpy_language_server/hover.py +238 -0
  21. renpy_language_server/index.py +1002 -0
  22. renpy_language_server/positions.py +28 -0
  23. renpy_language_server/py.typed +0 -0
  24. renpy_language_server/python_format.py +141 -0
  25. renpy_language_server/python_index.py +371 -0
  26. renpy_language_server/quotes.py +143 -0
  27. renpy_language_server/references.py +40 -0
  28. renpy_language_server/rename.py +293 -0
  29. renpy_language_server/resolve.py +807 -0
  30. renpy_language_server/scanner.py +86 -0
  31. renpy_language_server/semantic.py +488 -0
  32. renpy_language_server/server.py +770 -0
  33. renpy_language_server/settings.py +187 -0
  34. renpy_language_server/signature.py +371 -0
  35. renpy_language_server/spacing.py +261 -0
  36. renpy_language_server/strings.py +86 -0
  37. renpy_language_server/symbols.py +163 -0
  38. renpy_language_server/targets.py +51 -0
  39. renpy_language_server-0.2.0.dist-info/METADATA +434 -0
  40. renpy_language_server-0.2.0.dist-info/RECORD +44 -0
  41. renpy_language_server-0.2.0.dist-info/WHEEL +4 -0
  42. renpy_language_server-0.2.0.dist-info/entry_points.txt +3 -0
  43. renpy_language_server-0.2.0.dist-info/licenses/LICENSE +21 -0
  44. renpy_language_server-0.2.0.dist-info/licenses/NOTICE.md +41 -0
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__: str = version("renpy-language-server")
5
+ except PackageNotFoundError: # running from a source checkout without installing
6
+ __version__ = "0.0.0.dev0"
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m renpy_language_server``."""
2
+
3
+ from renpy_language_server.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,230 @@
1
+ """Logical lines and the block tree shared by server features.
2
+
3
+ The scanner already knows where a physical line continues an earlier logical
4
+ line. This module turns that information into two small structures:
5
+
6
+ * :class:`LogicalLine` - one Ren'Py statement, possibly spanning several
7
+ physical lines;
8
+ * :class:`Block` - a colon-terminated statement with its indented body.
9
+
10
+ It is deliberately not a parser. It is accurate enough for symbols, navigation,
11
+ diagnostics and fold ranges, and it never treats the inside of a string or a
12
+ bracket continuation as a block opener.
13
+ """
14
+
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from typing import Literal
18
+
19
+ from .scanner import State, scan_line
20
+
21
+ _TAB = 4
22
+ _FIRST_TOKEN = re.compile(r"\S+")
23
+
24
+ LineKind = Literal["code", "comment", "blank", "continuation"]
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class LogicalLine:
29
+ """One Ren'Py statement, from its first to its last physical line."""
30
+
31
+ text: str
32
+ start_line: int
33
+ end_line: int
34
+ indent: int
35
+ depth: int
36
+ kind: LineKind
37
+ first_word: str
38
+ opens_block: bool
39
+
40
+
41
+ def _no_children() -> "list[Block]":
42
+ return []
43
+
44
+
45
+ @dataclass
46
+ class Block:
47
+ """A colon-terminated statement and its indented body."""
48
+
49
+ line: LogicalLine
50
+ parent: "Block | None"
51
+ children: "list[Block]" = field(default_factory=_no_children)
52
+ end_line: int = 0
53
+
54
+
55
+ @dataclass
56
+ class Analysis:
57
+ """Result of analysing one source file."""
58
+
59
+ lines: list[LogicalLine]
60
+ blocks: list[Block]
61
+ indent_unit: int
62
+
63
+ def line_at(self, line: int) -> LogicalLine | None:
64
+ """Return the logical line containing physical ``line``."""
65
+ for item in self.lines:
66
+ if item.start_line <= line <= item.end_line:
67
+ return item
68
+ return None
69
+
70
+ def block_at(self, line: int) -> Block | None:
71
+ """Return the innermost block whose range contains ``line``."""
72
+ found: Block | None = None
73
+ for block in self.blocks:
74
+ if block.line.start_line <= line <= block.end_line and (
75
+ found is None or block.line.start_line >= found.line.start_line
76
+ ):
77
+ found = block
78
+ return found
79
+
80
+ def enclosing_label(self, line: int) -> Block | None:
81
+ """Return the closest ``label`` block containing ``line``."""
82
+ block = self.block_at(line)
83
+ while block is not None:
84
+ if block.line.first_word == "label":
85
+ return block
86
+ block = block.parent
87
+ return None
88
+
89
+
90
+ @dataclass
91
+ class _Pending:
92
+ start_line: int
93
+ end_line: int
94
+ text: str
95
+ indent: int
96
+ kind: LineKind
97
+ first_word: str
98
+ last_char: str
99
+
100
+
101
+ def analyze(source: str, *, default_indent: int = 4) -> Analysis:
102
+ """Analyse ``source`` into logical lines and a block tree."""
103
+ raw_lines = _split_lines(source)
104
+ pending = _collect_logical_lines(raw_lines)
105
+ indent_unit = _infer_indent_unit(pending, default_indent)
106
+
107
+ stack: list[Block] = []
108
+ blocks: list[Block] = []
109
+ lines: list[LogicalLine] = []
110
+
111
+ for item in pending:
112
+ if item.kind != "blank":
113
+ while stack and item.indent <= stack[-1].line.indent:
114
+ _close_block(stack.pop())
115
+
116
+ line = LogicalLine(
117
+ text=item.text,
118
+ start_line=item.start_line,
119
+ end_line=item.end_line,
120
+ indent=item.indent,
121
+ depth=len(stack),
122
+ kind=item.kind,
123
+ first_word=item.first_word,
124
+ opens_block=item.kind != "comment" and item.last_char == ":",
125
+ )
126
+ lines.append(line)
127
+
128
+ if item.kind != "blank":
129
+ for block in stack:
130
+ block.end_line = item.end_line
131
+
132
+ if line.opens_block:
133
+ parent = stack[-1] if stack else None
134
+ block = Block(line=line, parent=parent, end_line=line.end_line)
135
+ blocks.append(block)
136
+ if parent is not None:
137
+ parent.children.append(block)
138
+ stack.append(block)
139
+
140
+ while stack:
141
+ _close_block(stack.pop())
142
+
143
+ return Analysis(lines=lines, blocks=blocks, indent_unit=indent_unit)
144
+
145
+
146
+ def _close_block(block: Block) -> None:
147
+ block.end_line = max(block.end_line, block.line.end_line)
148
+
149
+
150
+ def _split_lines(source: str) -> list[str]:
151
+ text = source.replace("\r\n", "\n").replace("\r", "\n")
152
+ return text.split("\n")
153
+
154
+
155
+ def _collect_logical_lines(raw_lines: list[str]) -> list[_Pending]:
156
+ state = State()
157
+ pending: list[_Pending] = []
158
+ current: _Pending | None = None
159
+
160
+ for index, raw in enumerate(raw_lines):
161
+ info = scan_line(raw, state)
162
+ stripped = raw.strip()
163
+
164
+ if state.clean:
165
+ if current is not None:
166
+ pending.append(current)
167
+ current = None
168
+
169
+ if stripped:
170
+ text = stripped
171
+ current = _Pending(
172
+ start_line=index,
173
+ end_line=index,
174
+ text=text,
175
+ indent=_leading_width(raw),
176
+ kind="comment" if text.startswith("#") else "code",
177
+ first_word=_first_word(text),
178
+ last_char=info.last_char,
179
+ )
180
+ else:
181
+ pending.append(
182
+ _Pending(
183
+ start_line=index,
184
+ end_line=index,
185
+ text="",
186
+ indent=0,
187
+ kind="blank",
188
+ first_word="",
189
+ last_char="",
190
+ )
191
+ )
192
+ elif current is not None:
193
+ current.end_line = index
194
+ if info.last_char:
195
+ current.last_char = info.last_char
196
+ else:
197
+ # A continuation without a logical-line opener should not happen
198
+ # with a balanced scanner. Keep it as a safe code line.
199
+ current = _Pending(
200
+ start_line=index,
201
+ end_line=index,
202
+ text=stripped,
203
+ indent=_leading_width(raw),
204
+ kind="continuation",
205
+ first_word=_first_word(stripped),
206
+ last_char=info.last_char,
207
+ )
208
+
209
+ state = info.end
210
+
211
+ if current is not None:
212
+ pending.append(current)
213
+ return pending
214
+
215
+
216
+ def _infer_indent_unit(lines: list[_Pending], default_indent: int) -> int:
217
+ for line in lines:
218
+ if line.kind == "code" and line.indent > 0:
219
+ return line.indent
220
+ return default_indent if default_indent > 0 else _TAB
221
+
222
+
223
+ def _first_word(text: str) -> str:
224
+ match = _FIRST_TOKEN.match(text)
225
+ return match.group(0) if match else ""
226
+
227
+
228
+ def _leading_width(line: str) -> int:
229
+ stripped = line.lstrip(" \t")
230
+ return len(line[: len(line) - len(stripped)].expandtabs(_TAB))
@@ -0,0 +1,104 @@
1
+ """Automatic image and audio assets from the project folders."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ AssetKind = Literal["image", "audio"]
8
+
9
+ IMAGE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".webp", ".avif"})
10
+ AUDIO_SUFFIXES = frozenset({".ogg", ".opus", ".mp3", ".wav", ".mp2"})
11
+ MAX_ASSETS = 20_000
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Asset:
16
+ """One file that Ren'Py treats as an image or audio resource."""
17
+
18
+ kind: AssetKind
19
+ name: str
20
+ path: Path
21
+
22
+
23
+ class AssetIndex:
24
+ """A cached map of asset names to files."""
25
+
26
+ def __init__(self) -> None:
27
+ self._images: dict[str, Asset] = {}
28
+ self._audio: dict[str, Asset] = {}
29
+
30
+ def scan(self, roots: list[Path]) -> None:
31
+ """Replace the index with assets found under ``roots``."""
32
+ images: dict[str, Asset] = {}
33
+ audio: dict[str, Asset] = {}
34
+
35
+ for root in _unique_dirs(roots):
36
+ if len(images) + len(audio) >= MAX_ASSETS:
37
+ break
38
+ for directory in (root / "game" / "images", root / "images"):
39
+ if directory.is_dir():
40
+ _scan_directory(directory, "image", IMAGE_SUFFIXES, images)
41
+ for directory in (root / "game" / "audio", root / "audio"):
42
+ if directory.is_dir():
43
+ _scan_directory(directory, "audio", AUDIO_SUFFIXES, audio)
44
+
45
+ self._images = images
46
+ self._audio = audio
47
+
48
+ def images(self) -> list[Asset]:
49
+ return list(self._images.values())
50
+
51
+ def audio(self) -> list[Asset]:
52
+ return list(self._audio.values())
53
+
54
+ def find_image(self, name: str) -> Asset | None:
55
+ return self._images.get(name)
56
+
57
+ def find_audio(self, name: str) -> Asset | None:
58
+ direct = self._audio.get(name)
59
+ if direct is not None:
60
+ return direct
61
+ if not name.startswith("audio/"):
62
+ return self._audio.get(f"audio/{name}")
63
+ return None
64
+
65
+
66
+ def _unique_dirs(roots: list[Path]) -> list[Path]:
67
+ result: list[Path] = []
68
+ seen: set[Path] = set()
69
+ for root in roots:
70
+ resolved = root.resolve()
71
+ if resolved in seen:
72
+ continue
73
+ seen.add(resolved)
74
+ result.append(resolved)
75
+ return result
76
+
77
+
78
+ def _scan_directory(
79
+ directory: Path,
80
+ kind: AssetKind,
81
+ suffixes: frozenset[str],
82
+ target: dict[str, Asset],
83
+ ) -> None:
84
+ for path in sorted(directory.rglob("*")):
85
+ if len(target) >= MAX_ASSETS:
86
+ return
87
+ if not path.is_file() or path.suffix.lower() not in suffixes:
88
+ continue
89
+ try:
90
+ relative = path.relative_to(directory)
91
+ except ValueError:
92
+ continue
93
+ if any(part.startswith(".") for part in relative.parts):
94
+ continue
95
+ if kind == "image":
96
+ name = _image_name(relative)
97
+ else:
98
+ name = f"audio/{relative.as_posix()}"
99
+ target.setdefault(name, Asset(kind=kind, name=name, path=path))
100
+
101
+
102
+ def _image_name(relative: Path) -> str:
103
+ parts = list(relative.with_suffix("").parts)
104
+ return " ".join(part.replace("_", " ") for part in parts)
@@ -0,0 +1,54 @@
1
+ """Bundled Ren'Py API documentation.
2
+
3
+ The data is generated from the MIT-licensed ``renpy.json`` shipped by
4
+ ``renpy/vscode-language-renpy``. See ``NOTICE.md`` for attribution.
5
+ """
6
+
7
+ import json
8
+ from dataclasses import dataclass
9
+ from functools import lru_cache
10
+ from importlib.resources import files
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Builtin:
15
+ """One Ren'Py function, class, action or variable."""
16
+
17
+ name: str
18
+ kind: str
19
+ args: str
20
+ type: str
21
+ category: str
22
+ documentation: str
23
+
24
+ @property
25
+ def signature(self) -> str:
26
+ return f"{self.name}{self.args}" if self.args else self.name
27
+
28
+
29
+ @lru_cache(maxsize=1)
30
+ def builtins() -> dict[str, Builtin]:
31
+ """Load and cache the bundled API table."""
32
+ path = files("renpy_language_server.data").joinpath("renpy_api.json")
33
+ raw = json.loads(path.read_text(encoding="utf-8"))
34
+ result: dict[str, Builtin] = {}
35
+ for name, value in raw.items():
36
+ result[name] = Builtin(
37
+ name=name,
38
+ kind=value["kind"],
39
+ args=value["args"],
40
+ type=value["type"],
41
+ category=value["category"],
42
+ documentation=value["doc"],
43
+ )
44
+ return result
45
+
46
+
47
+ def lookup(name: str) -> Builtin | None:
48
+ """Return the built-in with exactly ``name``, if it exists."""
49
+ return builtins().get(name)
50
+
51
+
52
+ def iter_builtins() -> tuple[Builtin, ...]:
53
+ """Return every bundled built-in in a stable order."""
54
+ return tuple(builtins().values())
@@ -0,0 +1,190 @@
1
+ """Command line entry point.
2
+
3
+ ``renpy-language-server`` start the language server on stdio
4
+ ``renpy-language-server dap`` start the launch-only debug adapter on stdio
5
+ ``renpy-language-server format FILE...`` format files in place (``--check``, ``--diff``)
6
+ """
7
+
8
+ import difflib
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Annotated
12
+
13
+ import typer
14
+
15
+ from . import __version__
16
+ from .formatter import FormatOptions, format_source
17
+
18
+ app = typer.Typer(
19
+ name="renpy-language-server",
20
+ help="Ren'Py language server.",
21
+ invoke_without_command=True,
22
+ no_args_is_help=False,
23
+ )
24
+
25
+
26
+ def _version_callback(value: bool) -> None:
27
+ if value:
28
+ print(f"renpy-language-server {__version__}")
29
+ raise typer.Exit
30
+
31
+
32
+ @app.callback()
33
+ def root(
34
+ ctx: typer.Context,
35
+ *,
36
+ version: Annotated[
37
+ bool,
38
+ typer.Option(
39
+ "--version",
40
+ callback=_version_callback,
41
+ is_eager=True,
42
+ help="Show version and exit.",
43
+ ),
44
+ ] = False,
45
+ stdio: Annotated[
46
+ bool,
47
+ typer.Option("--stdio", help="Accepted for client compatibility."),
48
+ ] = False,
49
+ ) -> None:
50
+ """Start the language server on stdio (default when no subcommand is given)."""
51
+ _ = (version, stdio)
52
+ if ctx.invoked_subcommand is None:
53
+ from .server import run # imported lazily: the CLI should start fast
54
+
55
+ run()
56
+
57
+
58
+ def _iter_files(paths: list[str]) -> list[Path]:
59
+ files: list[Path] = []
60
+ for raw in paths:
61
+ path = Path(raw)
62
+ if path.is_dir():
63
+ files.extend(sorted(path.rglob("*.rpy")))
64
+ files.extend(sorted(path.rglob("*.rpym")))
65
+ else:
66
+ files.append(path)
67
+ return files
68
+
69
+
70
+ def _format_impl(
71
+ paths: list[str],
72
+ *,
73
+ check: bool,
74
+ diff: bool,
75
+ indent: int,
76
+ spacing: bool,
77
+ quotes: bool,
78
+ python_format: bool,
79
+ ) -> int:
80
+ options = FormatOptions(
81
+ indent=indent,
82
+ normalize_spacing=spacing,
83
+ normalize_quotes=quotes,
84
+ format_python=python_format,
85
+ )
86
+
87
+ if paths == ["-"]:
88
+ _ = sys.stdout.write(format_source(sys.stdin.read(), options))
89
+ return 0
90
+
91
+ changed = 0
92
+ for path in _iter_files(paths):
93
+ with open(path, encoding="utf-8", newline="") as fh:
94
+ old = fh.read()
95
+ new = format_source(old, options)
96
+ if old == new:
97
+ continue
98
+ changed += 1
99
+ if diff:
100
+ _ = sys.stdout.writelines(
101
+ difflib.unified_diff(
102
+ old.splitlines(keepends=True),
103
+ new.splitlines(keepends=True),
104
+ fromfile=str(path),
105
+ tofile=str(path),
106
+ )
107
+ )
108
+ elif check:
109
+ print(f"would reformat {path}", file=sys.stderr)
110
+ else:
111
+ with open(path, "w", encoding="utf-8", newline="") as fh:
112
+ _ = fh.write(new)
113
+ print(f"reformatted {path}", file=sys.stderr)
114
+
115
+ return 1 if changed and (check or diff) else 0
116
+
117
+
118
+ @app.command()
119
+ def dap() -> None:
120
+ """Start the launch-only Debug Adapter Protocol server on stdio."""
121
+ from .dap import run # imported lazily: the CLI should start fast
122
+
123
+ raise typer.Exit(run())
124
+
125
+
126
+ @app.command()
127
+ def format(
128
+ paths: Annotated[
129
+ list[str],
130
+ typer.Argument(help="Files or directories; '-' reads stdin."),
131
+ ],
132
+ *,
133
+ check: Annotated[
134
+ bool,
135
+ typer.Option("--check", help="Exit 1 if any file would change."),
136
+ ] = False,
137
+ diff: Annotated[
138
+ bool,
139
+ typer.Option("--diff", help="Print a diff instead of writing."),
140
+ ] = False,
141
+ indent: Annotated[
142
+ int,
143
+ typer.Option("--indent", help="Spaces per indentation level."),
144
+ ] = 4,
145
+ spacing: Annotated[
146
+ bool,
147
+ typer.Option(
148
+ "--spacing/--no-spacing",
149
+ help="Normalize spacing around operators in Python statements.",
150
+ ),
151
+ ] = True,
152
+ python_format: Annotated[
153
+ bool,
154
+ typer.Option(
155
+ "--python/--no-python",
156
+ help="Format python: blocks with Ruff (not whitespace-only).",
157
+ ),
158
+ ] = False,
159
+ quotes: Annotated[
160
+ bool,
161
+ typer.Option(
162
+ "--quotes/--no-quotes",
163
+ help="Rewrite single-quoted strings to double quotes.",
164
+ ),
165
+ ] = True,
166
+ ) -> None:
167
+ """Format .rpy files."""
168
+ rc = _format_impl(
169
+ paths,
170
+ check=check,
171
+ diff=diff,
172
+ indent=indent,
173
+ spacing=spacing,
174
+ quotes=quotes,
175
+ python_format=python_format,
176
+ )
177
+ raise typer.Exit(rc)
178
+
179
+
180
+ def main(argv: list[str] | None = None) -> int:
181
+ """Entry point for programmatic use and tests.
182
+
183
+ Returns the process exit code (0 = success).
184
+ When no subcommand is given the language server is started.
185
+ """
186
+ try:
187
+ app(args=argv, standalone_mode=True)
188
+ except SystemExit as exc:
189
+ return int(exc.code or 0)
190
+ return 0