Cnass commited on
Commit
8e992fc
·
verified ·
1 Parent(s): 5faffaf

Update app/renpy/script.py

Browse files
Files changed (1) hide show
  1. app/renpy/script.py +110 -30
app/renpy/script.py CHANGED
@@ -11,10 +11,19 @@ this parser is built to be *certain* about what it touches:
11
  offset.
12
  * Statements are classified from a "code view" of each line - the same line
13
  with the inside of every string blanked out - so a keyword mentioned inside
14
- dialogue is never mistaken for the statement's own keyword.
15
- * Anything not positively recognised as dialogue, a menu choice or screen text
16
- is left alone. Over-skipping costs a line of translation; under-skipping
17
- corrupts a script.
 
 
 
 
 
 
 
 
 
18
 
19
  The offsets are what makes writing back safe: a translation replaces exactly
20
  the characters between one pair of quotes, so no amount of re-flowing by the
@@ -122,6 +131,7 @@ class Literal:
122
  triple: bool
123
  line: int # 0-based index of the line the literal opens on
124
  depth: int # bracket nesting at the opening quote
 
125
 
126
 
127
  @dataclass
@@ -136,6 +146,23 @@ class Found:
136
  line: int
137
 
138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  def scan_literals(source: str) -> tuple[list[Literal], list[str]]:
140
  """Locate every string literal, and build the blanked-out code view.
141
 
@@ -155,6 +182,13 @@ def scan_literals(source: str) -> tuple[list[Literal], list[str]]:
155
  depth = 0
156
  index = 0
157
  length = len(source)
 
 
 
 
 
 
 
158
 
159
  while index < length:
160
  char = source[index]
@@ -171,11 +205,22 @@ def scan_literals(source: str) -> tuple[list[Literal], list[str]]:
171
  index += 1
172
  continue
173
 
174
- if char in "([{":
 
 
 
 
 
 
 
 
 
 
 
175
  depth += 1
176
  index += 1
177
  continue
178
- if char in ")]}":
179
  depth = max(0, depth - 1)
180
  index += 1
181
  continue
@@ -234,6 +279,7 @@ def scan_literals(source: str) -> tuple[list[Literal], list[str]]:
234
  triple=triple,
235
  line=open_line,
236
  depth=depth,
 
237
  )
238
  )
239
  index = inner_end + len(marker)
@@ -281,9 +327,14 @@ def find_translatable(source: str) -> list[Found]:
281
 
282
  found: list[Found] = []
283
  skip_indent: int | None = None
 
284
  menu_indent: int | None = None
285
  continuation = False
286
  depth = 0
 
 
 
 
287
 
288
  for index, code_line in enumerate(code_lines):
289
  stripped = code_line.strip()
@@ -310,8 +361,11 @@ def find_translatable(source: str) -> list[Found]:
310
 
311
  if skip_indent is not None:
312
  if indent > skip_indent:
 
 
313
  continue
314
  skip_indent = None
 
315
 
316
  if menu_indent is not None and indent <= menu_indent:
317
  menu_indent = None
@@ -324,6 +378,9 @@ def find_translatable(source: str) -> list[Found]:
324
 
325
  if first in _BLOCK_SKIP:
326
  skip_indent = indent
 
 
 
327
  continue
328
 
329
  if first == "menu":
@@ -366,31 +423,54 @@ def find_translatable(source: str) -> list[Found]:
366
  chosen = candidates[-1]
367
  kind = "menu" if menu_indent is not None and not prefix else "dialogue"
368
 
369
- raw = source[chosen.start : chosen.end]
370
- try:
371
- value = unescape(raw)
372
- except EscapeError:
373
- # An escape this tool will not round-trip. Leaving the line in the
374
- # source untranslated is always recoverable; writing back a guess
375
- # is not.
376
- continue
377
- if not value.strip() or not is_translatable(value):
 
 
 
 
 
 
 
 
378
  continue
379
- if chosen.triple:
380
- # Triple-quoted bodies are found and skipped rather than
381
- # translated: their content is re-indented by Ren'Py and a
382
- # rewritten body would change the layout it depends on.
383
  continue
384
-
385
- found.append(
386
- Found(
387
- text=value,
388
- start=chosen.start,
389
- end=chosen.end,
390
- quote=chosen.quote,
391
- kind=kind,
392
- line=index + 1,
393
- )
394
- )
395
 
396
  return found
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  offset.
12
  * Statements are classified from a "code view" of each line - the same line
13
  with the inside of every string blanked out - so a keyword mentioned inside
14
+ dialogue is never mistaken for the statement's own keyword. This only looks
15
+ at bracket-depth-0 text, on purpose: a string sitting inside some other
16
+ call (`action=ShowMenu("save")`) is never mistaken for a label.
17
+ * A second, independent pass then finds every string wrapped in `_(...)` or
18
+ `__(...)`, at *any* depth, wherever it sits - inside an `Achievement(...)`
19
+ constructor, a dict three levels deep in a list inside `init python:`.
20
+ `_()` is Ren'Py's own "translate this" marker, so unlike everything else
21
+ here it needs no statement classification: the developer already answered
22
+ the question by writing it. Only a `translate` block's own body is held
23
+ back from this pass, even one that happens to contain a `_()` call.
24
+ * Anything not positively recognised as dialogue, a menu choice, screen text
25
+ or `_()`-marked is left alone. Over-skipping costs a line of translation;
26
+ under-skipping corrupts a script.
27
 
28
  The offsets are what makes writing back safe: a translation replaces exactly
29
  the characters between one pair of quotes, so no amount of re-flowing by the
 
131
  triple: bool
132
  line: int # 0-based index of the line the literal opens on
133
  depth: int # bracket nesting at the opening quote
134
+ marked: bool # sits directly inside a `_(...)` / `__(...)` call
135
 
136
 
137
  @dataclass
 
146
  line: int
147
 
148
 
149
+ def _is_underscore_call(source: str, paren_index: int) -> bool:
150
+ """True when the '(' at `paren_index` is `_(` or `__(`.
151
+
152
+ Walks back over whitespace, then over one contiguous identifier, and
153
+ compares that whole token - not just the character before the paren - so
154
+ a real name that merely ends in an underscore (`get_text_ (x)`) is never
155
+ mistaken for the marker.
156
+ """
157
+ cursor = paren_index
158
+ while cursor > 0 and source[cursor - 1] in " \t":
159
+ cursor -= 1
160
+ end = cursor
161
+ while cursor > 0 and (source[cursor - 1].isalnum() or source[cursor - 1] == "_"):
162
+ cursor -= 1
163
+ return source[cursor:end] in ("_", "__")
164
+
165
+
166
  def scan_literals(source: str) -> tuple[list[Literal], list[str]]:
167
  """Locate every string literal, and build the blanked-out code view.
168
 
 
182
  depth = 0
183
  index = 0
184
  length = len(source)
185
+ # One entry per currently-open '(' ')' pair, tracking whether it was
186
+ # opened by a bare `_` or `__` - Ren'Py's own "translate this" marker.
187
+ # `_("Continue")`, `Achievement(description=_("..."))` and
188
+ # `flavor=_("..."))` inside a dict two calls deep are all reached this way,
189
+ # regardless of what statement or block they sit inside; see `marked` on
190
+ # Literal.
191
+ paren_marks: list[bool] = []
192
 
193
  while index < length:
194
  char = source[index]
 
205
  index += 1
206
  continue
207
 
208
+ if char == "(":
209
+ paren_marks.append(_is_underscore_call(source, index))
210
+ depth += 1
211
+ index += 1
212
+ continue
213
+ if char == ")":
214
+ if paren_marks:
215
+ paren_marks.pop()
216
+ depth = max(0, depth - 1)
217
+ index += 1
218
+ continue
219
+ if char in "[{":
220
  depth += 1
221
  index += 1
222
  continue
223
+ if char in "]}":
224
  depth = max(0, depth - 1)
225
  index += 1
226
  continue
 
279
  triple=triple,
280
  line=open_line,
281
  depth=depth,
282
+ marked=bool(paren_marks) and paren_marks[-1],
283
  )
284
  )
285
  index = inner_end + len(marker)
 
327
 
328
  found: list[Found] = []
329
  skip_indent: int | None = None
330
+ skip_kind: str | None = None
331
  menu_indent: int | None = None
332
  continuation = False
333
  depth = 0
334
+ # Lines that belong to a `translate` block - somebody else's finished
335
+ # translation - so the marked-literal pass below can steer clear of it
336
+ # the same way the main classification does.
337
+ translate_lines: set[int] = set()
338
 
339
  for index, code_line in enumerate(code_lines):
340
  stripped = code_line.strip()
 
361
 
362
  if skip_indent is not None:
363
  if indent > skip_indent:
364
+ if skip_kind == "translate":
365
+ translate_lines.add(index)
366
  continue
367
  skip_indent = None
368
+ skip_kind = None
369
 
370
  if menu_indent is not None and indent <= menu_indent:
371
  menu_indent = None
 
378
 
379
  if first in _BLOCK_SKIP:
380
  skip_indent = indent
381
+ skip_kind = first
382
+ if first == "translate":
383
+ translate_lines.add(index)
384
  continue
385
 
386
  if first == "menu":
 
423
  chosen = candidates[-1]
424
  kind = "menu" if menu_indent is not None and not prefix else "dialogue"
425
 
426
+ item = _literal_to_found(source, chosen, kind, index + 1)
427
+ if item is not None:
428
+ found.append(item)
429
+
430
+ # `_()` / `__()` is Ren'Py's own "translate this" marker, and it means
431
+ # exactly that wherever it appears - inside a `python:`/`init python:`
432
+ # block, an `Achievement(...)` constructor, a dict buried in a list two
433
+ # levels deep. The scan above only classifies statements at bracket-depth
434
+ # 0 (so a style name or an action sitting next to a label is never
435
+ # mistaken for one), which is also why it cannot see into any of those
436
+ # places. This second pass doesn't classify anything; the developer
437
+ # already did, explicitly, by writing `_(...)`. Depth-0 literals can never
438
+ # be marked (marking requires an open paren), so this cannot re-find
439
+ # anything the loop above already collected.
440
+ seen = {(f.start, f.end) for f in found}
441
+ for literal in literals:
442
+ if not literal.marked or literal.line in translate_lines:
443
  continue
444
+ if (literal.start, literal.end) in seen:
 
 
 
445
  continue
446
+ item = _literal_to_found(source, literal, "ui", literal.line + 1)
447
+ if item is not None:
448
+ found.append(item)
 
 
 
 
 
 
 
 
449
 
450
  return found
451
+
452
+
453
+ def _literal_to_found(source: str, literal: Literal, kind: str, line: int) -> Found | None:
454
+ raw = source[literal.start : literal.end]
455
+ try:
456
+ value = unescape(raw)
457
+ except EscapeError:
458
+ # An escape this tool will not round-trip. Leaving the line in the
459
+ # source untranslated is always recoverable; writing back a guess is
460
+ # not.
461
+ return None
462
+ if not value.strip() or not is_translatable(value):
463
+ return None
464
+ if literal.triple:
465
+ # Triple-quoted bodies are found and skipped rather than translated:
466
+ # their content is re-indented by Ren'Py and a rewritten body would
467
+ # change the layout it depends on.
468
+ return None
469
+ return Found(
470
+ text=value,
471
+ start=literal.start,
472
+ end=literal.end,
473
+ quote=literal.quote,
474
+ kind=kind,
475
+ line=line,
476
+ )