repostyle 0.28.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.
- repostyle/__init__.py +5 -0
- repostyle/_comments.py +529 -0
- repostyle/_shared.py +558 -0
- repostyle/changed_lines.py +59 -0
- repostyle/cli.py +175 -0
- repostyle/explain.py +111 -0
- repostyle/rules/__init__.py +360 -0
- repostyle/rules/_catalog.py +1430 -0
- repostyle/rules/_registry.py +358 -0
- repostyle/rules/_violation.py +91 -0
- repostyle/rules/annotations.py +116 -0
- repostyle/rules/comments.py +638 -0
- repostyle/rules/complexity.py +84 -0
- repostyle/rules/doc_fill.py +669 -0
- repostyle/rules/doc_value.py +565 -0
- repostyle/rules/docstrings.py +2119 -0
- repostyle/rules/duration.py +63 -0
- repostyle/rules/encapsulation.py +125 -0
- repostyle/rules/equality.py +126 -0
- repostyle/rules/error_handling.py +168 -0
- repostyle/rules/filenames.py +152 -0
- repostyle/rules/idioms.py +130 -0
- repostyle/rules/imperative_verbs.py +337 -0
- repostyle/rules/import_layering.py +82 -0
- repostyle/rules/layout.py +381 -0
- repostyle/rules/logging_phi.py +73 -0
- repostyle/rules/naming.py +832 -0
- repostyle/rules/ports.py +74 -0
- repostyle/rules/signatures.py +96 -0
- repostyle/rules/testing.py +740 -0
- repostyle/rules/visibility.py +260 -0
- repostyle/runner.py +411 -0
- repostyle/suppressions.py +89 -0
- repostyle-0.28.0.dist-info/METADATA +434 -0
- repostyle-0.28.0.dist-info/RECORD +39 -0
- repostyle-0.28.0.dist-info/WHEEL +5 -0
- repostyle-0.28.0.dist-info/entry_points.txt +2 -0
- repostyle-0.28.0.dist-info/licenses/LICENSE +202 -0
- repostyle-0.28.0.dist-info/top_level.txt +1 -0
repostyle/__init__.py
ADDED
repostyle/_comments.py
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""The cross-language `#`-comment extractor shared by the comment rules.
|
|
2
|
+
|
|
3
|
+
RS009 (paragraph wrapping), RS022 (tag format), RS030 (terminal punctuation),
|
|
4
|
+
and the suppression parser each read `#` comments from Python, TOML, YAML, and
|
|
5
|
+
shell. This module holds the one extractor they share: Python through the
|
|
6
|
+
tokenizer; TOML, YAML, and shell through a string- and block-aware line scan.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
import re
|
|
13
|
+
import tokenize
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from functools import lru_cache
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import NamedTuple
|
|
18
|
+
|
|
19
|
+
# A YAML block scalar introducer: a value that, after a `:` or `-` lead or
|
|
20
|
+
# standing at the line start, is a `|` or `>` carrying only chomping and indent
|
|
21
|
+
# indicators, so the indented lines that follow are literal.
|
|
22
|
+
_BLOCK_SCALAR_PATTERN = re.compile(r"(?:[:-]\s+|^\s*)[|>][0-9+-]*\s*$")
|
|
23
|
+
|
|
24
|
+
# The file types whose `#` comments the comment rules read. Python is
|
|
25
|
+
# tokenized; TOML, YAML, and shell are scanned line by line. A type absent here
|
|
26
|
+
# is never comment-checked.
|
|
27
|
+
COMMENT_SUFFIXES = frozenset({".py", ".toml", ".yaml", ".yml", ".sh"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _CommentToken(NamedTuple):
|
|
31
|
+
lineno: int
|
|
32
|
+
"""1-based line the comment starts on."""
|
|
33
|
+
column: int
|
|
34
|
+
"""0-based column of the leading hash."""
|
|
35
|
+
string: str
|
|
36
|
+
"""The comment from its leading hash to the end of the line."""
|
|
37
|
+
is_trailing: bool
|
|
38
|
+
"""Whether code or data precedes the comment on its line."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Cache on (path, source) so a file is scanned once and the result shared
|
|
42
|
+
# across the rules that read it -- RS009, RS022, RS030, and the suppression
|
|
43
|
+
# parser -- the way `_parse_python` caches the AST. A tuple is returned so the
|
|
44
|
+
# cached value is safe to iterate repeatedly.
|
|
45
|
+
@lru_cache(maxsize=128)
|
|
46
|
+
def extract_comments(path: Path, source: str) -> tuple[_CommentToken, ...]:
|
|
47
|
+
"""Returns each `#` comment in `source`, dispatched by file type.
|
|
48
|
+
|
|
49
|
+
A Python file is tokenized. A TOML, YAML, or shell file is scanned line by
|
|
50
|
+
line under that language's string and block rules, so a `#` inside a
|
|
51
|
+
string, a TOML multi-line string, a YAML block scalar, or a shell heredoc
|
|
52
|
+
is not mistaken for a comment. A file of any other type yields nothing. The
|
|
53
|
+
scan is conservative: an unrecognised construct keeps its `#` out of the
|
|
54
|
+
results rather than risk flagging string content.
|
|
55
|
+
"""
|
|
56
|
+
suffix = path.suffix
|
|
57
|
+
if suffix == ".py":
|
|
58
|
+
return tuple(_python_comments(source))
|
|
59
|
+
if suffix == ".toml":
|
|
60
|
+
return tuple(_toml_comments(source))
|
|
61
|
+
if suffix in {".yaml", ".yml"}:
|
|
62
|
+
return tuple(_yaml_comments(source))
|
|
63
|
+
if suffix == ".sh":
|
|
64
|
+
return tuple(_shell_comments(source))
|
|
65
|
+
return ()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _python_comments(source: str) -> Iterator[_CommentToken]:
|
|
69
|
+
"""Yields each comment token in Python `source` via the tokenizer.
|
|
70
|
+
|
|
71
|
+
Tokens are yielded as the tokenizer produces them, so a fault in the tail
|
|
72
|
+
still surfaces the comments before it.
|
|
73
|
+
"""
|
|
74
|
+
source_lines = source.splitlines()
|
|
75
|
+
try:
|
|
76
|
+
for token in tokenize.generate_tokens(io.StringIO(source).readline):
|
|
77
|
+
if token.type != tokenize.COMMENT:
|
|
78
|
+
continue
|
|
79
|
+
lineno, column = token.start
|
|
80
|
+
is_trailing = bool(source_lines[lineno - 1][:column].strip())
|
|
81
|
+
yield _CommentToken(lineno, column, token.string, is_trailing)
|
|
82
|
+
except (tokenize.TokenError, SyntaxError):
|
|
83
|
+
# An unterminated tail raises TokenError; inconsistent indentation
|
|
84
|
+
# raises IndentationError/TabError (SyntaxError subclasses). Stop at
|
|
85
|
+
# the fault and keep the comments already surfaced.
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _toml_comments(source: str) -> Iterator[_CommentToken]:
|
|
90
|
+
"""Yields each `#` comment in TOML `source`, line by line.
|
|
91
|
+
|
|
92
|
+
A multi-line string spanning lines carries its closing delimiter forward in
|
|
93
|
+
`open_delimiter`, so a `#` inside it is never a comment.
|
|
94
|
+
"""
|
|
95
|
+
open_delimiter: str | None = None
|
|
96
|
+
for lineno, line in enumerate(source.splitlines(), start=1):
|
|
97
|
+
column, open_delimiter = _toml_scan_line(line, open_delimiter)
|
|
98
|
+
if column is not None:
|
|
99
|
+
yield _token(lineno, line, column)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _toml_scan_line(
|
|
103
|
+
line: str, open_delimiter: str | None
|
|
104
|
+
) -> tuple[int | None, str | None]:
|
|
105
|
+
"""Finds a `#` comment in one TOML line, tracking multi-line strings.
|
|
106
|
+
|
|
107
|
+
`open_delimiter`, when set, is the triple-quote delimiter closing an open
|
|
108
|
+
multi-line string; the scan resumes after it closes on this line. Returns
|
|
109
|
+
the comment column (or `None`) and the delimiter still open at the line's
|
|
110
|
+
end (or `None`).
|
|
111
|
+
"""
|
|
112
|
+
index = 0
|
|
113
|
+
if open_delimiter is not None:
|
|
114
|
+
close = line.find(open_delimiter)
|
|
115
|
+
if close == -1:
|
|
116
|
+
return None, open_delimiter
|
|
117
|
+
index = close + len(open_delimiter)
|
|
118
|
+
while index < len(line):
|
|
119
|
+
char = line[index]
|
|
120
|
+
if char == "#":
|
|
121
|
+
return index, None
|
|
122
|
+
if char in "\"'":
|
|
123
|
+
triple = line[index : index + 3]
|
|
124
|
+
if triple in ('"""', "'''"):
|
|
125
|
+
close = line.find(triple, index + 3)
|
|
126
|
+
if close == -1:
|
|
127
|
+
return None, triple
|
|
128
|
+
index = close + 3
|
|
129
|
+
continue
|
|
130
|
+
index = _skip_toml_string(line, index)
|
|
131
|
+
continue
|
|
132
|
+
index += 1
|
|
133
|
+
return None, None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _skip_toml_string(line: str, index: int) -> int:
|
|
137
|
+
"""Returns the index past the single-line string opening at `index`.
|
|
138
|
+
|
|
139
|
+
A basic (`"`) string honours backslash escapes; a literal (`'`) string does
|
|
140
|
+
not. An unterminated string consumes the rest of the line, so its content
|
|
141
|
+
is never read as a comment.
|
|
142
|
+
"""
|
|
143
|
+
quote = line[index]
|
|
144
|
+
index += 1
|
|
145
|
+
while index < len(line):
|
|
146
|
+
if quote == '"' and line[index] == "\\":
|
|
147
|
+
index += 2
|
|
148
|
+
continue
|
|
149
|
+
if line[index] == quote:
|
|
150
|
+
return index + 1
|
|
151
|
+
index += 1
|
|
152
|
+
return len(line)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _yaml_comments(source: str) -> Iterator[_CommentToken]:
|
|
156
|
+
"""Yields each `#` comment in YAML `source`, line by line.
|
|
157
|
+
|
|
158
|
+
A `|` or `>` block scalar records its introducer indent in `block_indent`;
|
|
159
|
+
the deeper-indented lines that follow are literal, so a `#` among them is
|
|
160
|
+
never read as a comment.
|
|
161
|
+
"""
|
|
162
|
+
block_indent: int | None = None
|
|
163
|
+
for lineno, line in enumerate(source.splitlines(), start=1):
|
|
164
|
+
if block_indent is not None and _inside_block(line, block_indent):
|
|
165
|
+
continue
|
|
166
|
+
block_indent = None
|
|
167
|
+
column = _yaml_comment_column(line)
|
|
168
|
+
content = line if column is None else line[:column]
|
|
169
|
+
if _opens_block_scalar(content):
|
|
170
|
+
block_indent = _indent_of(line)
|
|
171
|
+
if column is not None:
|
|
172
|
+
yield _token(lineno, line, column)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _inside_block(line: str, block_indent: int) -> bool:
|
|
176
|
+
"""Reports whether `line` sits in a block scalar at `block_indent`."""
|
|
177
|
+
return not line.strip() or _indent_of(line) > block_indent
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _indent_of(line: str) -> int:
|
|
181
|
+
"""Returns the count of leading spaces on `line`."""
|
|
182
|
+
return len(line) - len(line.lstrip(" "))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _opens_block_scalar(content: str) -> bool:
|
|
186
|
+
"""Reports whether `content` is a YAML line opening a block scalar."""
|
|
187
|
+
return _BLOCK_SCALAR_PATTERN.search(content) is not None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _yaml_comment_column(line: str) -> int | None:
|
|
191
|
+
"""Returns the column of a `#` comment in one YAML line, or `None`.
|
|
192
|
+
|
|
193
|
+
A `#` opens a comment only at the line start or after whitespace, and never
|
|
194
|
+
inside a quoted scalar. A double-quoted scalar honours backslash escapes; a
|
|
195
|
+
single-quoted one escapes a quote by doubling it.
|
|
196
|
+
"""
|
|
197
|
+
index = 0
|
|
198
|
+
while index < len(line):
|
|
199
|
+
char = line[index]
|
|
200
|
+
if char == "#" and (index == 0 or line[index - 1] in " \t"):
|
|
201
|
+
return index
|
|
202
|
+
if char == '"':
|
|
203
|
+
index = _skip_yaml_double(line, index)
|
|
204
|
+
continue
|
|
205
|
+
if char == "'":
|
|
206
|
+
index = _skip_yaml_single(line, index)
|
|
207
|
+
continue
|
|
208
|
+
index += 1
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _skip_yaml_double(line: str, index: int) -> int:
|
|
213
|
+
"""Returns the index past the double-quoted scalar opening at `index`."""
|
|
214
|
+
index += 1
|
|
215
|
+
while index < len(line):
|
|
216
|
+
if line[index] == "\\":
|
|
217
|
+
index += 2
|
|
218
|
+
continue
|
|
219
|
+
if line[index] == '"':
|
|
220
|
+
return index + 1
|
|
221
|
+
index += 1
|
|
222
|
+
return len(line)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _skip_yaml_single(line: str, index: int) -> int:
|
|
226
|
+
"""Returns the index past the single-quoted scalar opening at `index`."""
|
|
227
|
+
index += 1
|
|
228
|
+
while index < len(line):
|
|
229
|
+
if line[index] == "'":
|
|
230
|
+
if line[index + 1 : index + 2] == "'":
|
|
231
|
+
index += 2
|
|
232
|
+
continue
|
|
233
|
+
return index + 1
|
|
234
|
+
index += 1
|
|
235
|
+
return len(line)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# A bare heredoc delimiter word, optionally backslash-quoted (`<<\EOF`). A
|
|
239
|
+
# leading letter or underscore keeps a `<< 2` arithmetic shift from reading as
|
|
240
|
+
# a redirection, since a delimiter starting with a digit is not used in
|
|
241
|
+
# practice.
|
|
242
|
+
_HEREDOC_WORD_PATTERN = re.compile(r"\\?[A-Za-z_][A-Za-z0-9_]*")
|
|
243
|
+
|
|
244
|
+
# The shell contexts the scan nests, innermost last in `_ShellState.contexts`.
|
|
245
|
+
# A substitution marker also stands for a `(` group opened inside one, so the
|
|
246
|
+
# group's `)` does not close the substitution early.
|
|
247
|
+
_DOUBLE_QUOTED = '"'
|
|
248
|
+
_SINGLE_QUOTED = "'"
|
|
249
|
+
_SUBSTITUTION = "("
|
|
250
|
+
_BACKTICK = "`"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _shell_comments(source: str) -> Iterator[_CommentToken]:
|
|
254
|
+
"""Yields each `#` comment in shell `source`, line by line.
|
|
255
|
+
|
|
256
|
+
A `#` opens a comment only at the line start or after whitespace, so a
|
|
257
|
+
`${var#pat}` expansion, a `$#`, and a `#` glued inside a word stay code. A
|
|
258
|
+
quoted string, an ANSI-C `$'...'` string, and an arithmetic `$(( ))`
|
|
259
|
+
expansion are skipped, so a `#` inside them is never a comment; the open
|
|
260
|
+
contexts and any heredoc body carry forward in `_ShellState`. A `#!`
|
|
261
|
+
shebang is yielded like any comment.
|
|
262
|
+
"""
|
|
263
|
+
state = _ShellState((), None)
|
|
264
|
+
for lineno, line in enumerate(source.splitlines(), start=1):
|
|
265
|
+
column, state = _shell_scan_line(line, state)
|
|
266
|
+
if column is not None:
|
|
267
|
+
yield _token(lineno, line, column)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _shell_scan_line(line: str, state: _ShellState) -> tuple[int | None, _ShellState]:
|
|
271
|
+
"""Finds a `#` comment in one shell line, resuming any cross-line state.
|
|
272
|
+
|
|
273
|
+
Contexts open from a prior line resume where they left off; a line inside a
|
|
274
|
+
heredoc body yields no comment until the terminator line closes it. Returns
|
|
275
|
+
the comment column (or `None`) and the state still open at the line's end.
|
|
276
|
+
"""
|
|
277
|
+
if state.contexts:
|
|
278
|
+
return _shell_scan(line, state.contexts)
|
|
279
|
+
if state.heredoc is not None:
|
|
280
|
+
if _heredoc_terminated(line, state.heredoc):
|
|
281
|
+
return None, _ShellState((), None)
|
|
282
|
+
return None, state
|
|
283
|
+
return _shell_scan(line, ())
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _shell_scan(line: str, contexts: tuple[str, ...]) -> tuple[int | None, _ShellState]:
|
|
287
|
+
"""Scans `line` for a `#` comment under the contexts open around it.
|
|
288
|
+
|
|
289
|
+
Quoting nests, so which characters matter depends on the innermost open
|
|
290
|
+
context rather than on the line alone: `$(...)` and a backtick substitution
|
|
291
|
+
start a fresh code context inside a double-quoted string, and a quote of
|
|
292
|
+
the other style inside any string is literal. Tracking that is what keeps a
|
|
293
|
+
`sed 's/"/X/'` inside `"$( )"` from closing the outer string and stranding
|
|
294
|
+
every later `#` inside a phantom one.
|
|
295
|
+
|
|
296
|
+
Outside a string, a backslash-escaped character, an ANSI-C `$'...'` string,
|
|
297
|
+
an arithmetic `$(( ))` expansion, and a `<<<` here-string are skipped
|
|
298
|
+
whole, so neither a `#` inside one nor a here-string's own `<<` is misread.
|
|
299
|
+
A heredoc redirection is recorded but the scan continues, so a trailing
|
|
300
|
+
comment on the redirection line is still found.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
The `#` column (or `None`) and the state open at the line's end.
|
|
304
|
+
"""
|
|
305
|
+
stack = list(contexts)
|
|
306
|
+
pending: _Heredoc | None = None
|
|
307
|
+
index = 0
|
|
308
|
+
while index < len(line):
|
|
309
|
+
innermost = stack[-1] if stack else None
|
|
310
|
+
if innermost == _SINGLE_QUOTED:
|
|
311
|
+
index = _scan_single_quoted(line, index, stack)
|
|
312
|
+
continue
|
|
313
|
+
if innermost == _DOUBLE_QUOTED:
|
|
314
|
+
index = _scan_double_quoted(line, index, stack)
|
|
315
|
+
continue
|
|
316
|
+
if line[index] == "#" and _opens_comment(line, index):
|
|
317
|
+
return index, _ShellState(tuple(stack), pending)
|
|
318
|
+
step = _shell_skip(line, index)
|
|
319
|
+
if step is not None:
|
|
320
|
+
index = step
|
|
321
|
+
continue
|
|
322
|
+
opener = _heredoc_opener(line, index)
|
|
323
|
+
if opener is not None:
|
|
324
|
+
heredoc, index = opener
|
|
325
|
+
pending = pending or heredoc
|
|
326
|
+
continue
|
|
327
|
+
index = _scan_code(line, index, stack)
|
|
328
|
+
return None, _ShellState(tuple(stack), pending)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _heredoc_opener(line: str, index: int) -> tuple[_Heredoc, int] | None:
|
|
332
|
+
"""Parses a `<<WORD` heredoc redirection at `index`, or returns `None`.
|
|
333
|
+
|
|
334
|
+
Handles `<<`, the tab-stripping `<<-`, and a quoted (`<<'EOF'`) or bare
|
|
335
|
+
delimiter. Returns the heredoc and the index past the delimiter, so the
|
|
336
|
+
rest of the line still scans for a trailing comment.
|
|
337
|
+
"""
|
|
338
|
+
if not line.startswith("<<", index):
|
|
339
|
+
return None
|
|
340
|
+
cursor = index + 2
|
|
341
|
+
has_tab_stripping = line[cursor : cursor + 1] == "-"
|
|
342
|
+
if has_tab_stripping:
|
|
343
|
+
cursor += 1
|
|
344
|
+
while cursor < len(line) and line[cursor] in " \t":
|
|
345
|
+
cursor += 1
|
|
346
|
+
terminator, cursor = _heredoc_delimiter(line, cursor)
|
|
347
|
+
if terminator is None:
|
|
348
|
+
return None
|
|
349
|
+
return _Heredoc(terminator, has_tab_stripping), cursor
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _heredoc_delimiter(line: str, index: int) -> tuple[str | None, int]:
|
|
353
|
+
"""Reads a heredoc delimiter word at `index`, quoted or bare.
|
|
354
|
+
|
|
355
|
+
Returns the unquoted terminator and the index past it, or `(None, index)`
|
|
356
|
+
when no delimiter word follows, so the `<<` reads as a shift operator
|
|
357
|
+
rather than a redirection.
|
|
358
|
+
"""
|
|
359
|
+
if index < len(line) and line[index] in "\"'":
|
|
360
|
+
quote = line[index]
|
|
361
|
+
close = line.find(quote, index + 1)
|
|
362
|
+
if close == -1:
|
|
363
|
+
return None, index
|
|
364
|
+
return line[index + 1 : close], close + 1
|
|
365
|
+
match = _HEREDOC_WORD_PATTERN.match(line, index)
|
|
366
|
+
if match is None:
|
|
367
|
+
return None, index
|
|
368
|
+
return match.group().lstrip("\\"), match.end()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _heredoc_terminated(line: str, heredoc: _Heredoc) -> bool:
|
|
372
|
+
"""Reports whether `line` is the delimiter ending a heredoc body.
|
|
373
|
+
|
|
374
|
+
A `<<-` heredoc lets the delimiter line carry leading tabs, so those are
|
|
375
|
+
stripped before the comparison; otherwise the line must equal the delimiter
|
|
376
|
+
exactly.
|
|
377
|
+
"""
|
|
378
|
+
candidate = line.lstrip("\t") if heredoc.has_tab_stripping else line
|
|
379
|
+
return candidate == heredoc.terminator
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _opens_comment(line: str, index: int) -> bool:
|
|
383
|
+
"""Reports whether the `#` at `index` begins a shell comment.
|
|
384
|
+
|
|
385
|
+
A `#` begins a comment only at the line start or after whitespace, so a `#`
|
|
386
|
+
glued to a word (`$#`, `${v#p}`, `a#b`) stays code.
|
|
387
|
+
"""
|
|
388
|
+
return index == 0 or line[index - 1] in " \t"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _scan_code(line: str, index: int, stack: list[str]) -> int:
|
|
392
|
+
"""Advances one character through a code context, updating `stack`.
|
|
393
|
+
|
|
394
|
+
A quote opens the string it delimits. A backtick opens a substitution, or
|
|
395
|
+
closes the one it already opened. A `$(` opens a command substitution, and
|
|
396
|
+
a `(` nested inside one is pushed too so its `)` does not close the
|
|
397
|
+
substitution early. A `(` at the top level is ignored, since a `case`
|
|
398
|
+
pattern and a function header carry unpaired parentheses.
|
|
399
|
+
"""
|
|
400
|
+
char = line[index]
|
|
401
|
+
innermost = stack[-1] if stack else None
|
|
402
|
+
if char == _BACKTICK:
|
|
403
|
+
if innermost == _BACKTICK:
|
|
404
|
+
stack.pop()
|
|
405
|
+
else:
|
|
406
|
+
stack.append(_BACKTICK)
|
|
407
|
+
return index + 1
|
|
408
|
+
if line.startswith("$(", index):
|
|
409
|
+
stack.append(_SUBSTITUTION)
|
|
410
|
+
return index + 2
|
|
411
|
+
if char in (_DOUBLE_QUOTED, _SINGLE_QUOTED):
|
|
412
|
+
stack.append(char)
|
|
413
|
+
return index + 1
|
|
414
|
+
if char == "(" and _SUBSTITUTION in stack:
|
|
415
|
+
stack.append(_SUBSTITUTION)
|
|
416
|
+
elif char == ")" and innermost == _SUBSTITUTION:
|
|
417
|
+
stack.pop()
|
|
418
|
+
return index + 1
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _scan_double_quoted(line: str, index: int, stack: list[str]) -> int:
|
|
422
|
+
r"""Advances one character through a double-quoted string.
|
|
423
|
+
|
|
424
|
+
A backslash escapes the next character, so `\"` does not end the string,
|
|
425
|
+
and a `'` inside is literal. A `$(` or a backtick opens a nested code
|
|
426
|
+
context where quoting starts afresh, so a quote inside it belongs to that
|
|
427
|
+
context rather than closing this string. `stack` is pushed or popped
|
|
428
|
+
accordingly.
|
|
429
|
+
"""
|
|
430
|
+
char = line[index]
|
|
431
|
+
if char == "\\":
|
|
432
|
+
return index + 2
|
|
433
|
+
if char == _DOUBLE_QUOTED:
|
|
434
|
+
stack.pop()
|
|
435
|
+
return index + 1
|
|
436
|
+
if line.startswith("$(", index):
|
|
437
|
+
stack.append(_SUBSTITUTION)
|
|
438
|
+
return index + 2
|
|
439
|
+
if char == _BACKTICK:
|
|
440
|
+
stack.append(_BACKTICK)
|
|
441
|
+
return index + 1
|
|
442
|
+
return index + 1
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _scan_single_quoted(line: str, index: int, stack: list[str]) -> int:
|
|
446
|
+
r"""Advances one character through a single-quoted string.
|
|
447
|
+
|
|
448
|
+
A single-quoted string has no escapes at all, so a backslash is literal
|
|
449
|
+
there and only a `'` ends it, popping `stack`. That is why `'it'\''s'` is
|
|
450
|
+
three adjacent strings rather than one holding an escaped quote, and why a
|
|
451
|
+
`"` inside is just a character.
|
|
452
|
+
"""
|
|
453
|
+
if line[index] == _SINGLE_QUOTED:
|
|
454
|
+
stack.pop()
|
|
455
|
+
return index + 1
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _shell_skip(line: str, index: int) -> int | None:
|
|
459
|
+
r"""Returns the index past a construct skipped whole, or `None`.
|
|
460
|
+
|
|
461
|
+
A backslash escapes the next character, including a line-continuation `\`
|
|
462
|
+
at the line's end; `$'...'` is an ANSI-C string honouring backslash
|
|
463
|
+
escapes; `$(( ))` is an arithmetic expansion whose `#` base marker and `<<`
|
|
464
|
+
shift must not read as a comment or a heredoc; `<<<` is a here-string
|
|
465
|
+
operator, whose three characters skip together so the trailing `<<` cannot
|
|
466
|
+
open a spurious heredoc.
|
|
467
|
+
"""
|
|
468
|
+
if line[index] == "\\":
|
|
469
|
+
return index + 2
|
|
470
|
+
if line.startswith("$'", index):
|
|
471
|
+
return _shell_ansi_c_end(line, index + 2)
|
|
472
|
+
if line.startswith("$((", index):
|
|
473
|
+
return _shell_arithmetic_end(line, index + 3)
|
|
474
|
+
if line.startswith("<<<", index):
|
|
475
|
+
return index + 3
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _shell_ansi_c_end(line: str, index: int) -> int:
|
|
480
|
+
r"""Returns the index past an ANSI-C `$'...'` string opened before `index`.
|
|
481
|
+
|
|
482
|
+
A backslash escapes the next character, so `\'` does not close the string.
|
|
483
|
+
An unterminated string consumes the rest of the line.
|
|
484
|
+
"""
|
|
485
|
+
while index < len(line):
|
|
486
|
+
if line[index] == "\\":
|
|
487
|
+
index += 2
|
|
488
|
+
continue
|
|
489
|
+
if line[index] == "'":
|
|
490
|
+
return index + 1
|
|
491
|
+
index += 1
|
|
492
|
+
return len(line)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _shell_arithmetic_end(line: str, index: int) -> int:
|
|
496
|
+
"""Returns the index past a `$(( ))` expansion opened before `index`.
|
|
497
|
+
|
|
498
|
+
Tracks parenthesis depth so a nested `(` pairs before the closing `))`. An
|
|
499
|
+
unterminated expansion consumes the rest of the line.
|
|
500
|
+
"""
|
|
501
|
+
depth = 2
|
|
502
|
+
while index < len(line):
|
|
503
|
+
if line[index] == "(":
|
|
504
|
+
depth += 1
|
|
505
|
+
elif line[index] == ")":
|
|
506
|
+
depth -= 1
|
|
507
|
+
if depth == 0:
|
|
508
|
+
return index + 1
|
|
509
|
+
index += 1
|
|
510
|
+
return len(line)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _token(lineno: int, line: str, column: int) -> _CommentToken:
|
|
514
|
+
"""Builds a comment token for the `#` at `column` on `line`."""
|
|
515
|
+
return _CommentToken(lineno, column, line[column:], bool(line[:column].strip()))
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
class _Heredoc(NamedTuple):
|
|
519
|
+
terminator: str
|
|
520
|
+
"""The word whose own line ends the heredoc body."""
|
|
521
|
+
has_tab_stripping: bool
|
|
522
|
+
"""Whether a `<<-` heredoc lets the terminator line carry leading tabs."""
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
class _ShellState(NamedTuple):
|
|
526
|
+
contexts: tuple[str, ...]
|
|
527
|
+
"""Strings and substitutions open at a line's end, innermost last."""
|
|
528
|
+
heredoc: _Heredoc | None
|
|
529
|
+
"""An open heredoc whose body suppresses comments, else `None`."""
|