dotless 1.0.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.
- dotless/__init__.py +3 -0
- dotless/__main__.py +3 -0
- dotless/analysis.py +350 -0
- dotless/cli.py +173 -0
- dotless/config.py +91 -0
- dotless/formats.py +148 -0
- dotless/lexer.py +286 -0
- dotless/rules.py +127 -0
- dotless/runtime.py +132 -0
- dotless/scanner.py +418 -0
- dotless-1.0.0.dist-info/METADATA +278 -0
- dotless-1.0.0.dist-info/RECORD +16 -0
- dotless-1.0.0.dist-info/WHEEL +5 -0
- dotless-1.0.0.dist-info/entry_points.txt +2 -0
- dotless-1.0.0.dist-info/licenses/LICENSE +674 -0
- dotless-1.0.0.dist-info/top_level.txt +1 -0
dotless/__init__.py
ADDED
dotless/__main__.py
ADDED
dotless/analysis.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Decide how serious a locale-dependent case conversion is.
|
|
2
|
+
|
|
3
|
+
Only one character pair behaves differently in Turkic locales: I/i.
|
|
4
|
+
That makes equality checks against ASCII constants decidable:
|
|
5
|
+
|
|
6
|
+
* lowering, then comparing with a constant that contains 'i' -> broken
|
|
7
|
+
* lowering, then comparing with a constant without any 'i' -> unaffected
|
|
8
|
+
* upper-casing works the same way with 'I'
|
|
9
|
+
|
|
10
|
+
A site is reported as ``high`` when its result is compared with an
|
|
11
|
+
affected ASCII constant or is used as a lookup key (hash table, key file,
|
|
12
|
+
dictionary, MIME type, file extension ...). A site whose only uses are
|
|
13
|
+
comparisons with unaffected constants is not reported at all. Everything
|
|
14
|
+
else is ``low``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from .lexer import Source, literal_value
|
|
23
|
+
|
|
24
|
+
LOWER, UPPER, BOTH = "lower", "upper", "both"
|
|
25
|
+
|
|
26
|
+
_CMP_OP = re.compile(r"\s*(===|!==|==|!=)\s*(?:(?:Ok|Some)\s*\(\s*)?")
|
|
27
|
+
_CMP_OP_BEFORE = re.compile(r"(===|!==|==|!=)\s*$")
|
|
28
|
+
_PY_IN = re.compile(r"\s*(?:not\s+)?in\s*([(\[{])")
|
|
29
|
+
_METHOD_AFTER = re.compile(r"\s*\.\s*(\w+)\s*\(")
|
|
30
|
+
_IDENT_BEFORE = re.compile(r"([A-Za-z_$][\w$]*(?:\s*(?:\.|->|::)\s*[A-Za-z_$][\w$]*)*)\s*$")
|
|
31
|
+
_ASSIGN_BEFORE = re.compile(
|
|
32
|
+
r"([A-Za-z_$][\w$]*)\s*(?:\[[^\]]*\]\s*)?=\s*(?:\(\s*(?:const\s+)?\w+\s*\**\s*\)\s*)?$"
|
|
33
|
+
)
|
|
34
|
+
_CASE_LABEL = re.compile(r"\bcase\s+")
|
|
35
|
+
|
|
36
|
+
COMPARE_FUNCS = {
|
|
37
|
+
"strcmp", "strncmp", "g_strcmp0", "g_str_equal", "g_str_has_prefix",
|
|
38
|
+
"g_str_has_suffix", "strstr", "g_strrstr", "strcasecmp", "strncasecmp",
|
|
39
|
+
"g_ascii_strcasecmp", "g_ascii_strncasecmp", "g_strv_contains", "memcmp",
|
|
40
|
+
"streq", "g_strcasecmp", "g_strncasecmp", "strcasestr", "g_regex_match_simple",
|
|
41
|
+
"g_pattern_match_simple",
|
|
42
|
+
}
|
|
43
|
+
COMPARE_METHODS = {
|
|
44
|
+
"has_prefix", "has_suffix", "contains", "startsWith", "endsWith",
|
|
45
|
+
"includes", "indexOf", "startswith", "endswith", "find", "index",
|
|
46
|
+
"equals", "equal", "match", "test", "search", "replace", "split",
|
|
47
|
+
}
|
|
48
|
+
KEY_FUNC_PREFIXES = (
|
|
49
|
+
"g_hash_table_", "g_tree_", "g_key_file_", "g_settings_", "g_quark_",
|
|
50
|
+
"g_intern_", "g_datalist_", "g_dataset_", "g_content_type_", "g_mime_",
|
|
51
|
+
"g_variant_lookup", "g_variant_dict_", "g_object_get_data", "g_object_set_data",
|
|
52
|
+
"g_file_info_get_attribute", "g_file_info_set_attribute", "bsearch",
|
|
53
|
+
"g_app_info_get_default_for_type", "g_application_get_", "g_type_from_name",
|
|
54
|
+
"g_enum_get_value_by_", "g_flags_get_value_by_",
|
|
55
|
+
)
|
|
56
|
+
KEY_METHODS = {
|
|
57
|
+
"lookup", "lookup_extended", "get", "has", "has_key", "contains_key",
|
|
58
|
+
"get_string", "get_boolean", "get_int", "get_value", "get_data",
|
|
59
|
+
"setdefault", "pop",
|
|
60
|
+
}
|
|
61
|
+
# Storing a converted key is only a problem if something later looks it up
|
|
62
|
+
# with a differently converted string; that is not visible locally.
|
|
63
|
+
INSERT_FUNCS = {
|
|
64
|
+
"g_hash_table_add", "g_hash_table_insert", "g_hash_table_replace",
|
|
65
|
+
"g_tree_insert", "g_tree_replace", "insert", "set", "add", "push", "append",
|
|
66
|
+
}
|
|
67
|
+
# Calls and methods that pass a string through without changing its case.
|
|
68
|
+
TRANSPARENT_FUNCS = {
|
|
69
|
+
"from_ptr", "from", "String", "str", "CStr", "Some", "Ok", "g_strstrip",
|
|
70
|
+
"g_strchomp", "g_strchug", "strip", "trim",
|
|
71
|
+
}
|
|
72
|
+
TRANSPARENT_METHODS = {
|
|
73
|
+
"to_str", "to_bytes", "to_string_lossy", "to_string", "to_owned", "unwrap",
|
|
74
|
+
"as_str", "as_ref", "strip", "trim", "toString", "get_string",
|
|
75
|
+
}
|
|
76
|
+
IGNORED_USES = {"g_free", "free", "g_clear_pointer", "g_strfreev", "g_steal_pointer", "g_strdup", "strlen"}
|
|
77
|
+
# Variable names that mark machine-readable identifiers. "key" and
|
|
78
|
+
# "keyword" are deliberately absent: in GNOME code they usually hold
|
|
79
|
+
# keyboard keys or user search terms.
|
|
80
|
+
KEYISH_WORDS = {
|
|
81
|
+
"ext", "exts", "extension", "extensions", "suffix", "mime", "mimetype",
|
|
82
|
+
"scheme", "protocol", "proto", "charset", "encoding", "codeset", "header",
|
|
83
|
+
}
|
|
84
|
+
_DEFINE = re.compile(r'^[ ]*#[ ]*define[ ]+(\w+)[ ]+("(?:[^"\
|
|
85
|
+
]|\.)*")[ ]*$', re.M)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Verdict:
|
|
90
|
+
severity: str | None # "high", "low" or None (provably unaffected)
|
|
91
|
+
reason: str
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _affected(value: str, kind: str) -> bool:
|
|
95
|
+
if not value.isascii():
|
|
96
|
+
return False
|
|
97
|
+
if kind == LOWER:
|
|
98
|
+
return "i" in value
|
|
99
|
+
if kind == UPPER:
|
|
100
|
+
return "I" in value
|
|
101
|
+
return "i" in value or "I" in value
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _ident_words(name: str) -> set[str]:
|
|
105
|
+
spaced = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name).lower()
|
|
106
|
+
return {w for w in re.split(r"[_\W]+", spaced) if w}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def is_keyish(name: str) -> bool:
|
|
110
|
+
words = _ident_words(name)
|
|
111
|
+
if words & KEYISH_WORDS:
|
|
112
|
+
return True
|
|
113
|
+
return "content_type" in "_".join(words) or "mime_type" in "_".join(words)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_BRACKETS = {"(": ")", "[": "]", "{": "}"}
|
|
117
|
+
_match_cache: tuple[str, dict[int, int]] = ("", {})
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _bracket_map(bare: str) -> dict[int, int]:
|
|
121
|
+
"""Offset of every opening bracket -> offset just past its closer.
|
|
122
|
+
|
|
123
|
+
Computed once per file in a single pass. A mismatched closer (macro
|
|
124
|
+
tricks, broken code) closes the innermost open bracket; brackets left
|
|
125
|
+
open at the end map to the end of the text.
|
|
126
|
+
"""
|
|
127
|
+
global _match_cache
|
|
128
|
+
if _match_cache[0] is bare:
|
|
129
|
+
return _match_cache[1]
|
|
130
|
+
result: dict[int, int] = {}
|
|
131
|
+
stack: list[int] = []
|
|
132
|
+
for i, c in enumerate(bare):
|
|
133
|
+
if c in _BRACKETS:
|
|
134
|
+
stack.append(i)
|
|
135
|
+
elif c in ")]}" and stack:
|
|
136
|
+
result[stack.pop()] = i + 1
|
|
137
|
+
n = len(bare)
|
|
138
|
+
for i in stack:
|
|
139
|
+
result[i] = n
|
|
140
|
+
_match_cache = (bare, result)
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def matching_close(bare: str, open_idx: int) -> int:
|
|
145
|
+
"""Index just past the bracket matching ``bare[open_idx]``."""
|
|
146
|
+
return _bracket_map(bare).get(open_idx, len(bare))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def enclosing(bare: str, pos: int, limit: int = 2000) -> tuple[int, str, int] | None:
|
|
150
|
+
"""Innermost unclosed ( or [ before ``pos`` and the name in front of it.
|
|
151
|
+
|
|
152
|
+
Returns (offset of the bracket, name, offset where the possibly
|
|
153
|
+
qualified name starts). ``name`` is "[" for subscripts. Stops at
|
|
154
|
+
statement boundaries.
|
|
155
|
+
"""
|
|
156
|
+
depth = 0
|
|
157
|
+
i = pos - 1
|
|
158
|
+
stop = max(0, pos - limit)
|
|
159
|
+
while i >= stop:
|
|
160
|
+
c = bare[i]
|
|
161
|
+
if c in ")]":
|
|
162
|
+
depth += 1
|
|
163
|
+
elif c in "([":
|
|
164
|
+
if depth == 0:
|
|
165
|
+
if c == "[":
|
|
166
|
+
return i, "[", i
|
|
167
|
+
m = _IDENT_BEFORE.search(bare, max(0, i - 200), i)
|
|
168
|
+
if not m:
|
|
169
|
+
return i, "", i
|
|
170
|
+
return i, re.split(r"\s*(?:\.|->|::)\s*", m.group(1))[-1], m.start(1)
|
|
171
|
+
depth -= 1
|
|
172
|
+
elif c in ";{}" and depth == 0:
|
|
173
|
+
return None
|
|
174
|
+
i -= 1
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def defines(src: Source) -> dict[str, str]:
|
|
179
|
+
"""``#define NAME "literal"`` constants of a file (cached on ``src``)."""
|
|
180
|
+
cached = getattr(src, "_dotless_defines", None)
|
|
181
|
+
if cached is None:
|
|
182
|
+
cached = {m.group(1): literal_value(m.group(2)) for m in _DEFINE.finditer(src.text)} if "#" in src.text else {}
|
|
183
|
+
src._dotless_defines = cached
|
|
184
|
+
return cached
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def literals_in(src: Source, start: int, end: int) -> list[str]:
|
|
188
|
+
"""Literal values in text[start:end], including #define'd constants."""
|
|
189
|
+
out = []
|
|
190
|
+
consts = defines(src)
|
|
191
|
+
if consts:
|
|
192
|
+
for m in re.finditer(r"[A-Za-z_]\w*", src.bare[start:end]):
|
|
193
|
+
v = consts.get(m.group(0))
|
|
194
|
+
if v is not None:
|
|
195
|
+
out.append(v)
|
|
196
|
+
for s, e in src.strings:
|
|
197
|
+
if s >= end:
|
|
198
|
+
break
|
|
199
|
+
if s >= start:
|
|
200
|
+
v = literal_value(src.text[s:e])
|
|
201
|
+
if v is not None:
|
|
202
|
+
out.append(v)
|
|
203
|
+
return out
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def judge_literals(values: list[str], kind: str, what: str) -> Verdict | None:
|
|
207
|
+
if not values:
|
|
208
|
+
return None
|
|
209
|
+
hit = next((v for v in values if _affected(v, kind)), None)
|
|
210
|
+
if hit is not None:
|
|
211
|
+
return Verdict("high", f"{what} {_show(hit)}")
|
|
212
|
+
return Verdict(None, f"{what} only constants without i/I")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _show(v: str) -> str:
|
|
216
|
+
v = v if len(v) <= 40 else v[:37] + "..."
|
|
217
|
+
return '"' + v.replace("\n", "\\n") + '"'
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _literal_ending_at(src: Source, end: int) -> str | None:
|
|
221
|
+
for s, e in reversed(src.strings):
|
|
222
|
+
if e == end:
|
|
223
|
+
return literal_value(src.text[s:e])
|
|
224
|
+
if e < end:
|
|
225
|
+
return None
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def judge_expression(src: Source, start: int, end: int, kind: str, lang: str, depth: int = 0) -> Verdict | None:
|
|
230
|
+
"""Look at how the value of ``text[start:end]`` is used.
|
|
231
|
+
|
|
232
|
+
Returns a Verdict when the use is decidable, None otherwise.
|
|
233
|
+
"""
|
|
234
|
+
bare = src.bare
|
|
235
|
+
|
|
236
|
+
# Skip case-preserving method chains: expr.to_str().unwrap() == "lit"
|
|
237
|
+
while True:
|
|
238
|
+
m = _METHOD_AFTER.match(bare, end)
|
|
239
|
+
if not m or m.group(1) not in TRANSPARENT_METHODS:
|
|
240
|
+
break
|
|
241
|
+
end = matching_close(bare, m.end() - 1)
|
|
242
|
+
|
|
243
|
+
# expr == "lit"
|
|
244
|
+
m = _CMP_OP.match(bare, end)
|
|
245
|
+
if m:
|
|
246
|
+
span = src.string_at(m.end())
|
|
247
|
+
if span:
|
|
248
|
+
v = literal_value(src.text[span[0] : span[1]])
|
|
249
|
+
if v is not None:
|
|
250
|
+
return judge_literals([v], kind, "compared with")
|
|
251
|
+
# "lit" == expr
|
|
252
|
+
m = _CMP_OP_BEFORE.search(bare, max(0, start - 50), start)
|
|
253
|
+
if m:
|
|
254
|
+
k = m.start()
|
|
255
|
+
while k > 0 and bare[k - 1].isspace():
|
|
256
|
+
k -= 1
|
|
257
|
+
v = _literal_ending_at(src, k)
|
|
258
|
+
if v is not None:
|
|
259
|
+
return judge_literals([v], kind, "compared with")
|
|
260
|
+
|
|
261
|
+
# expr in ("a", "b")
|
|
262
|
+
if lang == "python":
|
|
263
|
+
m = _PY_IN.match(bare, end)
|
|
264
|
+
if m:
|
|
265
|
+
close = matching_close(bare, m.end() - 1)
|
|
266
|
+
return judge_literals(literals_in(src, m.end(), close), kind, "checked for membership in")
|
|
267
|
+
|
|
268
|
+
# expr.endsWith(".ini") / expr.has_suffix (".ini")
|
|
269
|
+
m = _METHOD_AFTER.match(bare, end)
|
|
270
|
+
if m:
|
|
271
|
+
close = matching_close(bare, m.end() - 1)
|
|
272
|
+
if m.group(1) in COMPARE_METHODS:
|
|
273
|
+
v = judge_literals(literals_in(src, m.end(), close), kind, f"passed to .{m.group(1)}() with")
|
|
274
|
+
if v:
|
|
275
|
+
return v
|
|
276
|
+
|
|
277
|
+
enc = enclosing(bare, start)
|
|
278
|
+
if enc is None:
|
|
279
|
+
return None
|
|
280
|
+
open_idx, name, name_start = enc
|
|
281
|
+
if name == "[":
|
|
282
|
+
return Verdict("high", "used as a subscript key")
|
|
283
|
+
if name == "switch":
|
|
284
|
+
close = matching_close(bare, open_idx)
|
|
285
|
+
brace = bare.find("{", close)
|
|
286
|
+
if brace >= 0 and not bare[close:brace].strip():
|
|
287
|
+
body_end = matching_close(bare, brace)
|
|
288
|
+
labels = []
|
|
289
|
+
for cm in _CASE_LABEL.finditer(bare, brace, body_end):
|
|
290
|
+
span = src.string_at(cm.end())
|
|
291
|
+
if span:
|
|
292
|
+
v = literal_value(src.text[span[0] : span[1]])
|
|
293
|
+
if v is not None:
|
|
294
|
+
labels.append(v)
|
|
295
|
+
return judge_literals(labels, kind, "switch case")
|
|
296
|
+
return None
|
|
297
|
+
close = matching_close(bare, open_idx)
|
|
298
|
+
if name in TRANSPARENT_FUNCS and depth < 3:
|
|
299
|
+
return judge_expression(src, name_start, close, kind, lang, depth + 1)
|
|
300
|
+
if name in COMPARE_FUNCS or name in COMPARE_METHODS:
|
|
301
|
+
return judge_literals(literals_in(src, open_idx, close), kind, f"passed to {name}() with")
|
|
302
|
+
if name in INSERT_FUNCS:
|
|
303
|
+
return Verdict("low", f"stored as a key with {name}()")
|
|
304
|
+
if name.startswith(KEY_FUNC_PREFIXES) or name in KEY_METHODS:
|
|
305
|
+
return Verdict("high", f"used as a lookup key in {name}()")
|
|
306
|
+
if name in IGNORED_USES:
|
|
307
|
+
return Verdict(None, f"only passed to {name}()")
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def judge_call(src: Source, start: int, end: int, kind: str, lang: str) -> Verdict:
|
|
312
|
+
"""Severity for a locale-dependent call spanning text[start:end]."""
|
|
313
|
+
direct = judge_expression(src, start, end, kind, lang)
|
|
314
|
+
if direct is not None:
|
|
315
|
+
return direct
|
|
316
|
+
|
|
317
|
+
# Result stored in a variable: follow it for a while.
|
|
318
|
+
bare = src.bare
|
|
319
|
+
# Assignments sit right before the call; a bounded window keeps very
|
|
320
|
+
# long lines (minified or generated code) linear.
|
|
321
|
+
line_start = max(bare.rfind("\n", 0, start) + 1, start - 300)
|
|
322
|
+
m = _ASSIGN_BEFORE.search(bare, line_start, start)
|
|
323
|
+
if m:
|
|
324
|
+
var = m.group(1)
|
|
325
|
+
if is_keyish(var):
|
|
326
|
+
return Verdict("high", f"stored in `{var}` (looks like a key, extension, MIME type or protocol name)")
|
|
327
|
+
return _follow_variable(src, var, end, kind, lang)
|
|
328
|
+
return Verdict("low", "result not compared with a constant nearby")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _follow_variable(src: Source, var: str, from_off: int, kind: str, lang: str) -> Verdict:
|
|
332
|
+
bare = src.bare
|
|
333
|
+
end_line = src.line_of(from_off) + 60
|
|
334
|
+
limit = src.line_offset(end_line)
|
|
335
|
+
use = re.compile(r"(?<![\w$.>])" + re.escape(var) + r"(?![\w$])")
|
|
336
|
+
verdicts: list[Verdict] = []
|
|
337
|
+
undecided = False
|
|
338
|
+
for m in use.finditer(bare, from_off, limit):
|
|
339
|
+
if re.match(r"\s*=(?!=)", bare[m.end() : m.end() + 3]):
|
|
340
|
+
break # reassigned
|
|
341
|
+
v = judge_expression(src, m.start(), m.end(), kind, lang)
|
|
342
|
+
if v is None:
|
|
343
|
+
undecided = True
|
|
344
|
+
continue
|
|
345
|
+
if v.severity == "high":
|
|
346
|
+
return Verdict("high", f"stored in `{var}`, which is {v.reason} (line {src.line_of(m.start())})")
|
|
347
|
+
verdicts.append(v)
|
|
348
|
+
if verdicts and not undecided:
|
|
349
|
+
return Verdict(None, f"`{var}` is {verdicts[0].reason}")
|
|
350
|
+
return Verdict("low", f"stored in `{var}`; no comparison with an affected constant found")
|
dotless/cli.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
Exit status: 0 nothing found (or ``--exit-zero``), 1 findings reported,
|
|
4
|
+
2 usage or configuration error. ``dotless run`` uses 0 for identical
|
|
5
|
+
output, 1 for differences and 2 when it cannot run the comparison.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .rules import RULES, SEVERITIES
|
|
16
|
+
|
|
17
|
+
EXIT_OK, EXIT_FINDINGS, EXIT_ERROR = 0, 1, 2
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _err(msg: str) -> None:
|
|
21
|
+
print(f"dotless: {msg}", file=sys.stderr)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def use_color(mode: str, stream) -> bool:
|
|
25
|
+
"""Colour for --color=auto follows NO_COLOR and whether ``stream`` is a terminal."""
|
|
26
|
+
if mode == "always":
|
|
27
|
+
return True
|
|
28
|
+
if mode == "never":
|
|
29
|
+
return False
|
|
30
|
+
return "NO_COLOR" not in os.environ and hasattr(stream, "isatty") and stream.isatty()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _write(text: str) -> None:
|
|
34
|
+
try:
|
|
35
|
+
sys.stdout.write(text)
|
|
36
|
+
sys.stdout.flush()
|
|
37
|
+
except BrokenPipeError:
|
|
38
|
+
# The reader went away (e.g. `dotless scan . | head`); stop quietly.
|
|
39
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
40
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _scan(args: argparse.Namespace) -> int:
|
|
44
|
+
from . import config, formats
|
|
45
|
+
from .scanner import scan_paths
|
|
46
|
+
|
|
47
|
+
for p in args.paths:
|
|
48
|
+
if not os.path.exists(p):
|
|
49
|
+
_err(f"{p}: no such file or directory")
|
|
50
|
+
return EXIT_ERROR
|
|
51
|
+
bad = [r for r in args.disable if r not in RULES]
|
|
52
|
+
if bad:
|
|
53
|
+
_err(f"unknown rule id(s): {', '.join(bad)} (see `dotless rules`)")
|
|
54
|
+
return EXIT_ERROR
|
|
55
|
+
|
|
56
|
+
cfg = config.Config()
|
|
57
|
+
path = args.config if args.config else (None if args.no_config else config.find_pyproject(os.getcwd()))
|
|
58
|
+
if args.config and not os.path.isfile(args.config):
|
|
59
|
+
_err(f"{args.config}: no such file")
|
|
60
|
+
return EXIT_ERROR
|
|
61
|
+
if path:
|
|
62
|
+
try:
|
|
63
|
+
cfg, warning = config.load(path)
|
|
64
|
+
except (config.ConfigError, OSError) as e:
|
|
65
|
+
_err(str(e))
|
|
66
|
+
return EXIT_ERROR
|
|
67
|
+
if warning:
|
|
68
|
+
_err(f"warning: {warning}")
|
|
69
|
+
|
|
70
|
+
min_severity = args.min_severity or cfg.min_severity or "low"
|
|
71
|
+
errors: list[str] = []
|
|
72
|
+
findings, files = scan_paths(
|
|
73
|
+
args.paths,
|
|
74
|
+
min_severity,
|
|
75
|
+
excludes=cfg.exclude + args.exclude,
|
|
76
|
+
disable=cfg.disable + args.disable,
|
|
77
|
+
force_exclude=args.force_exclude,
|
|
78
|
+
errors=errors,
|
|
79
|
+
)
|
|
80
|
+
for e in errors:
|
|
81
|
+
_err(f"warning: cannot read {e}")
|
|
82
|
+
|
|
83
|
+
if args.format == "text":
|
|
84
|
+
stream = sys.stdout if args.output is None else None
|
|
85
|
+
out = formats.text(findings, files, color=stream is not None and use_color(args.color, stream))
|
|
86
|
+
else:
|
|
87
|
+
out = formats.FORMATS[args.format](findings, files)
|
|
88
|
+
print(formats.summary(findings, files), file=sys.stderr)
|
|
89
|
+
if args.output:
|
|
90
|
+
try:
|
|
91
|
+
with open(args.output, "w", encoding="utf-8", newline="\n") as fh:
|
|
92
|
+
fh.write(out)
|
|
93
|
+
except OSError as e:
|
|
94
|
+
_err(f"{args.output}: {e.strerror or e}")
|
|
95
|
+
return EXIT_ERROR
|
|
96
|
+
else:
|
|
97
|
+
_write(out)
|
|
98
|
+
return EXIT_FINDINGS if findings and not args.exit_zero else EXIT_OK
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _rules(args: argparse.Namespace) -> int:
|
|
102
|
+
lines = []
|
|
103
|
+
for r in RULES.values():
|
|
104
|
+
lines.append(f"{r.id} {r.name} [{', '.join(r.languages)}]")
|
|
105
|
+
lines.append(f" {r.summary}")
|
|
106
|
+
lines.append(f" fix: {r.fix}")
|
|
107
|
+
_write("\n".join(lines) + "\n")
|
|
108
|
+
return EXIT_OK
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _run(args: argparse.Namespace) -> int:
|
|
112
|
+
from .runtime import run_compare
|
|
113
|
+
|
|
114
|
+
cmd = args.command
|
|
115
|
+
if cmd and cmd[0] == "--":
|
|
116
|
+
cmd = cmd[1:]
|
|
117
|
+
if not cmd:
|
|
118
|
+
_err("run: missing command (usage: dotless run -- COMMAND [ARGS...])")
|
|
119
|
+
return EXIT_ERROR
|
|
120
|
+
return run_compare(cmd, args.locales.split(","), args.baseline, args.timeout, args.stdin)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
124
|
+
p = argparse.ArgumentParser(
|
|
125
|
+
prog="dotless",
|
|
126
|
+
description="Find dotted/dotless I bugs caused by locale-dependent case mapping.",
|
|
127
|
+
epilog="Exit status: 0 nothing found, 1 findings reported, 2 error.",
|
|
128
|
+
)
|
|
129
|
+
p.add_argument("--version", action="version", version=f"dotless {__version__}")
|
|
130
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
131
|
+
|
|
132
|
+
s = sub.add_parser(
|
|
133
|
+
"scan",
|
|
134
|
+
help="scan source files",
|
|
135
|
+
description="Scan C, Vala, Python, JavaScript and Rust sources. Settings in "
|
|
136
|
+
"[tool.dotless] of the nearest pyproject.toml are used unless --no-config is given.",
|
|
137
|
+
)
|
|
138
|
+
s.add_argument("paths", nargs="+", metavar="PATH")
|
|
139
|
+
s.add_argument("-f", "--format", choices=["text", "json", "sarif", "gitlab"], default="text")
|
|
140
|
+
s.add_argument("--min-severity", choices=SEVERITIES, default=None, help="default: low")
|
|
141
|
+
s.add_argument("-o", "--output", metavar="FILE", help="write the report to FILE")
|
|
142
|
+
s.add_argument("--exclude", action="append", default=[], metavar="GLOB", help="skip matching paths (repeatable)")
|
|
143
|
+
s.add_argument("--force-exclude", action="store_true", help="apply excludes to files named on the command line too")
|
|
144
|
+
s.add_argument("--disable", action="append", default=[], metavar="RULE", help="turn off a rule, e.g. DL005 (repeatable)")
|
|
145
|
+
s.add_argument("--color", choices=["auto", "always", "never"], default="auto", help="colour text output (default: auto)")
|
|
146
|
+
s.add_argument("--config", metavar="FILE", help="read [tool.dotless] from FILE")
|
|
147
|
+
s.add_argument("--no-config", action="store_true", help="ignore pyproject.toml")
|
|
148
|
+
s.add_argument("--exit-zero", action="store_true", help="exit with 0 even if there are findings")
|
|
149
|
+
s.set_defaults(func=_scan)
|
|
150
|
+
|
|
151
|
+
r = sub.add_parser("rules", help="list rules")
|
|
152
|
+
r.set_defaults(func=_rules)
|
|
153
|
+
|
|
154
|
+
run = sub.add_parser(
|
|
155
|
+
"run",
|
|
156
|
+
help="run a command under Turkic locales and compare its output",
|
|
157
|
+
usage="dotless run [--locales L1,L2] [--baseline LOCALE] -- COMMAND [ARGS...]",
|
|
158
|
+
)
|
|
159
|
+
run.add_argument("--locales", default="tr_TR.UTF-8,az_AZ.UTF-8")
|
|
160
|
+
run.add_argument("--baseline", default="C.UTF-8")
|
|
161
|
+
run.add_argument("--timeout", type=float, default=60.0)
|
|
162
|
+
run.add_argument("--stdin", metavar="FILE", help="feed FILE to the command's standard input")
|
|
163
|
+
run.add_argument("command", nargs=argparse.REMAINDER)
|
|
164
|
+
run.set_defaults(func=_run)
|
|
165
|
+
return p
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def main(argv: list[str] | None = None) -> int:
|
|
169
|
+
for stream in (sys.stdout, sys.stderr):
|
|
170
|
+
if hasattr(stream, "reconfigure"):
|
|
171
|
+
stream.reconfigure(errors="backslashreplace")
|
|
172
|
+
args = build_parser().parse_args(argv)
|
|
173
|
+
return args.func(args)
|
dotless/config.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""``[tool.dotless]`` settings from pyproject.toml.
|
|
2
|
+
|
|
3
|
+
Reading TOML needs ``tomllib`` (Python 3.11+). On older Pythons a
|
|
4
|
+
pyproject.toml that has a ``[tool.dotless]`` table is reported and
|
|
5
|
+
ignored; command line options keep working.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from .rules import RULES, SEVERITIES
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import tomllib
|
|
18
|
+
except ImportError: # Python < 3.11
|
|
19
|
+
tomllib = None
|
|
20
|
+
|
|
21
|
+
KEYS = {"exclude", "min-severity", "disable"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConfigError(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Config:
|
|
30
|
+
path: str | None = None
|
|
31
|
+
exclude: list[str] = field(default_factory=list)
|
|
32
|
+
min_severity: str | None = None
|
|
33
|
+
disable: list[str] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def find_pyproject(start: str) -> str | None:
|
|
37
|
+
"""The nearest pyproject.toml in ``start`` or one of its parents."""
|
|
38
|
+
d = os.path.abspath(start)
|
|
39
|
+
while True:
|
|
40
|
+
candidate = os.path.join(d, "pyproject.toml")
|
|
41
|
+
if os.path.isfile(candidate):
|
|
42
|
+
return candidate
|
|
43
|
+
parent = os.path.dirname(d)
|
|
44
|
+
if parent == d:
|
|
45
|
+
return None
|
|
46
|
+
d = parent
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load(path: str) -> tuple[Config, str | None]:
|
|
50
|
+
"""Read ``[tool.dotless]`` from ``path``.
|
|
51
|
+
|
|
52
|
+
Returns (config, warning). Raises ConfigError for invalid settings.
|
|
53
|
+
"""
|
|
54
|
+
with open(path, "rb") as fh:
|
|
55
|
+
raw = fh.read()
|
|
56
|
+
if tomllib is None:
|
|
57
|
+
if re.search(rb"^\s*\[tool\.dotless\]", raw, re.M):
|
|
58
|
+
return Config(), (f"{path}: [tool.dotless] ignored, reading it needs Python 3.11 or newer")
|
|
59
|
+
return Config(), None
|
|
60
|
+
try:
|
|
61
|
+
data = tomllib.loads(raw.decode("utf-8"))
|
|
62
|
+
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as e:
|
|
63
|
+
raise ConfigError(f"{path}: {e}") from None
|
|
64
|
+
table = data.get("tool", {}).get("dotless")
|
|
65
|
+
if table is None:
|
|
66
|
+
return Config(), None
|
|
67
|
+
if not isinstance(table, dict):
|
|
68
|
+
raise ConfigError(f"{path}: [tool.dotless] must be a table")
|
|
69
|
+
unknown = set(table) - KEYS
|
|
70
|
+
if unknown:
|
|
71
|
+
raise ConfigError(f"{path}: unknown key(s) in [tool.dotless]: {', '.join(sorted(unknown))}")
|
|
72
|
+
|
|
73
|
+
cfg = Config(path=path)
|
|
74
|
+
exclude = table.get("exclude", [])
|
|
75
|
+
if not (isinstance(exclude, list) and all(isinstance(x, str) for x in exclude)):
|
|
76
|
+
raise ConfigError(f"{path}: 'exclude' must be a list of strings")
|
|
77
|
+
cfg.exclude = exclude
|
|
78
|
+
|
|
79
|
+
sev = table.get("min-severity")
|
|
80
|
+
if sev is not None and sev not in SEVERITIES:
|
|
81
|
+
raise ConfigError(f"{path}: 'min-severity' must be one of {', '.join(SEVERITIES)}")
|
|
82
|
+
cfg.min_severity = sev
|
|
83
|
+
|
|
84
|
+
disable = table.get("disable", [])
|
|
85
|
+
if not (isinstance(disable, list) and all(isinstance(x, str) for x in disable)):
|
|
86
|
+
raise ConfigError(f"{path}: 'disable' must be a list of rule ids")
|
|
87
|
+
bad = [r for r in disable if r not in RULES]
|
|
88
|
+
if bad:
|
|
89
|
+
raise ConfigError(f"{path}: unknown rule id(s) in 'disable': {', '.join(bad)}")
|
|
90
|
+
cfg.disable = disable
|
|
91
|
+
return cfg, None
|