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.
- renpy_language_server/__init__.py +6 -0
- renpy_language_server/__main__.py +5 -0
- renpy_language_server/analysis.py +230 -0
- renpy_language_server/assets.py +104 -0
- renpy_language_server/builtins.py +54 -0
- renpy_language_server/cli.py +190 -0
- renpy_language_server/code_actions.py +130 -0
- renpy_language_server/completion.py +1191 -0
- renpy_language_server/completion_data.py +74 -0
- renpy_language_server/dap.py +824 -0
- renpy_language_server/data/__init__.py +1 -0
- renpy_language_server/data/completion.json +756 -0
- renpy_language_server/data/renpy_api.json +8073 -0
- renpy_language_server/definition.py +77 -0
- renpy_language_server/diagnostics.py +895 -0
- renpy_language_server/docs.py +21 -0
- renpy_language_server/edits.py +70 -0
- renpy_language_server/folding.py +107 -0
- renpy_language_server/formatter.py +288 -0
- renpy_language_server/hover.py +238 -0
- renpy_language_server/index.py +1002 -0
- renpy_language_server/positions.py +28 -0
- renpy_language_server/py.typed +0 -0
- renpy_language_server/python_format.py +141 -0
- renpy_language_server/python_index.py +371 -0
- renpy_language_server/quotes.py +143 -0
- renpy_language_server/references.py +40 -0
- renpy_language_server/rename.py +293 -0
- renpy_language_server/resolve.py +807 -0
- renpy_language_server/scanner.py +86 -0
- renpy_language_server/semantic.py +488 -0
- renpy_language_server/server.py +770 -0
- renpy_language_server/settings.py +187 -0
- renpy_language_server/signature.py +371 -0
- renpy_language_server/spacing.py +261 -0
- renpy_language_server/strings.py +86 -0
- renpy_language_server/symbols.py +163 -0
- renpy_language_server/targets.py +51 -0
- renpy_language_server-0.2.0.dist-info/METADATA +434 -0
- renpy_language_server-0.2.0.dist-info/RECORD +44 -0
- renpy_language_server-0.2.0.dist-info/WHEEL +4 -0
- renpy_language_server-0.2.0.dist-info/entry_points.txt +3 -0
- renpy_language_server-0.2.0.dist-info/licenses/LICENSE +21 -0
- renpy_language_server-0.2.0.dist-info/licenses/NOTICE.md +41 -0
|
@@ -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
|