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/formats.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Output formats: text, JSON, SARIF 2.1.0 and GitLab Code Quality."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from collections import Counter
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .rules import RULES
|
|
12
|
+
from .scanner import Finding
|
|
13
|
+
|
|
14
|
+
INFO_URI = "https://gitlab.com/rehimovv/dotless"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _rel(path: str) -> str:
|
|
18
|
+
try:
|
|
19
|
+
rel = os.path.relpath(path)
|
|
20
|
+
except ValueError: # different drive on Windows
|
|
21
|
+
return path.replace(os.sep, "/")
|
|
22
|
+
return path.replace(os.sep, "/") if rel.startswith("..") else rel.replace(os.sep, "/")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _fingerprints(findings: list[Finding]) -> list[str]:
|
|
26
|
+
"""Stable per-finding ids that survive unrelated line shifts."""
|
|
27
|
+
seen: Counter = Counter()
|
|
28
|
+
out = []
|
|
29
|
+
for f in findings:
|
|
30
|
+
base = f"{f.rule}\0{_rel(f.path)}\0{f.snippet}"
|
|
31
|
+
seen[base] += 1
|
|
32
|
+
out.append(hashlib.sha256(f"{base}\0{seen[base]}".encode()).hexdigest()[:32])
|
|
33
|
+
return out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def summary(findings: list[Finding], files: int) -> str:
|
|
37
|
+
sev = Counter(f.severity for f in findings)
|
|
38
|
+
return (
|
|
39
|
+
f"{len(findings)} finding{'s' if len(findings) != 1 else ''} "
|
|
40
|
+
f"({sev['high']} high, {sev['low']} low) in {files} file{'s' if files != 1 else ''} scanned"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def text(findings: list[Finding], files: int, color: bool = False) -> str:
|
|
45
|
+
def c(code: str, s: str) -> str:
|
|
46
|
+
return f"\033[{code}m{s}\033[0m" if color else s
|
|
47
|
+
|
|
48
|
+
lines = []
|
|
49
|
+
for f in findings:
|
|
50
|
+
sev = c("1;31", "high") if f.severity == "high" else c("33", "low")
|
|
51
|
+
title = RULES[f.rule].title or f.message
|
|
52
|
+
lines.append(f"{c('1', f'{_rel(f.path)}:{f.line}:{f.column}')}: [{sev}] {f.rule} {title}")
|
|
53
|
+
lines.append(f" {f.snippet}")
|
|
54
|
+
lines.append(f" {c('36', 'why:')} {f.reason}")
|
|
55
|
+
lines.append(f" {c('36', 'fix:')} {f.fix}")
|
|
56
|
+
lines.append(summary(findings, files))
|
|
57
|
+
return "\n".join(lines) + "\n"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def as_json(findings: list[Finding], files: int) -> str:
|
|
61
|
+
items = []
|
|
62
|
+
for f, fp in zip(findings, _fingerprints(findings)):
|
|
63
|
+
d = f.to_dict()
|
|
64
|
+
d["path"] = _rel(f.path)
|
|
65
|
+
d["fingerprint"] = fp
|
|
66
|
+
items.append(d)
|
|
67
|
+
return json.dumps({"tool": "dotless", "version": __version__, "files_scanned": files, "findings": items}, indent=2) + "\n"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def sarif(findings: list[Finding], files: int) -> str:
|
|
71
|
+
rule_ids = sorted(RULES)
|
|
72
|
+
rules = [
|
|
73
|
+
{
|
|
74
|
+
"id": r.id,
|
|
75
|
+
"name": r.name,
|
|
76
|
+
"shortDescription": {"text": r.summary.split(":")[0]},
|
|
77
|
+
"fullDescription": {"text": r.summary},
|
|
78
|
+
"help": {"text": f"{r.summary}\n\nFix: {r.fix}", "markdown": f"{r.summary}\n\n**Fix:** {r.fix}"},
|
|
79
|
+
"helpUri": r.help_uri,
|
|
80
|
+
"defaultConfiguration": {"level": "warning"},
|
|
81
|
+
"properties": {"tags": ["i18n", "locale", "turkish", "azerbaijani"]},
|
|
82
|
+
}
|
|
83
|
+
for r in (RULES[i] for i in rule_ids)
|
|
84
|
+
]
|
|
85
|
+
results = []
|
|
86
|
+
for f, fp in zip(findings, _fingerprints(findings)):
|
|
87
|
+
results.append(
|
|
88
|
+
{
|
|
89
|
+
"ruleId": f.rule,
|
|
90
|
+
"ruleIndex": rule_ids.index(f.rule),
|
|
91
|
+
"level": "error" if f.severity == "high" else "warning",
|
|
92
|
+
"message": {"text": f"{f.message} ({f.reason}). Fix: {f.fix}"},
|
|
93
|
+
"locations": [
|
|
94
|
+
{
|
|
95
|
+
"physicalLocation": {
|
|
96
|
+
"artifactLocation": {"uri": _rel(f.path), "uriBaseId": "%SRCROOT%"},
|
|
97
|
+
"region": {
|
|
98
|
+
"startLine": f.line,
|
|
99
|
+
"startColumn": f.column,
|
|
100
|
+
"endColumn": max(f.end_column, f.column + 1),
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
],
|
|
105
|
+
"partialFingerprints": {"dotless/v1": fp},
|
|
106
|
+
"properties": {"severity": f.severity, "reason": f.reason},
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
doc = {
|
|
110
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
111
|
+
"version": "2.1.0",
|
|
112
|
+
"runs": [
|
|
113
|
+
{
|
|
114
|
+
"tool": {
|
|
115
|
+
"driver": {
|
|
116
|
+
"name": "dotless",
|
|
117
|
+
"version": __version__,
|
|
118
|
+
"semanticVersion": __version__,
|
|
119
|
+
"informationUri": INFO_URI,
|
|
120
|
+
"rules": rules,
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
"results": results,
|
|
124
|
+
"properties": {"filesScanned": files},
|
|
125
|
+
}
|
|
126
|
+
],
|
|
127
|
+
}
|
|
128
|
+
return json.dumps(doc, indent=2) + "\n"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def gitlab(findings: list[Finding], files: int) -> str:
|
|
132
|
+
"""GitLab Code Quality report (CodeClimate subset)."""
|
|
133
|
+
items = [
|
|
134
|
+
{
|
|
135
|
+
"type": "issue",
|
|
136
|
+
"check_name": f"{f.rule} {RULES[f.rule].name}",
|
|
137
|
+
"description": f"{f.message} ({f.reason})",
|
|
138
|
+
"categories": ["Bug Risk"],
|
|
139
|
+
"severity": "major" if f.severity == "high" else "minor",
|
|
140
|
+
"fingerprint": fp,
|
|
141
|
+
"location": {"path": _rel(f.path), "lines": {"begin": f.line}},
|
|
142
|
+
}
|
|
143
|
+
for f, fp in zip(findings, _fingerprints(findings))
|
|
144
|
+
]
|
|
145
|
+
return json.dumps(items, indent=2) + "\n"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
FORMATS = {"text": text, "json": as_json, "sarif": sarif, "gitlab": gitlab}
|
dotless/lexer.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""A small lexer that separates code from comments and string literals.
|
|
2
|
+
|
|
3
|
+
It is not a parser. It only needs to answer three questions reliably:
|
|
4
|
+
is an offset inside a comment, inside a string literal, or in code; and
|
|
5
|
+
what literal text sits at a given offset. To keep offsets stable, every
|
|
6
|
+
view has exactly the same length as the source.
|
|
7
|
+
|
|
8
|
+
``Source.code`` comments blanked, string literals kept
|
|
9
|
+
``Source.bare`` comments and literal contents blanked, quote chars kept
|
|
10
|
+
``Source.comments`` list of (line, text) for every comment
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import bisect
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_PY_PREFIX = re.compile(r"(?:[rR][bBfF]?|[bBfF][rR]?|[uU])(?=['\"])")
|
|
21
|
+
_C_PREFIX = re.compile(r"(?:u8|[LuU])(?=['\"])")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Syntax:
|
|
26
|
+
line_comments: tuple[str, ...] = ("//",)
|
|
27
|
+
block_comment: tuple[str, str] | None = ("/*", "*/")
|
|
28
|
+
nested_blocks: bool = False
|
|
29
|
+
quotes: tuple[str, ...] = ('"', "'")
|
|
30
|
+
triple_quotes: tuple[str, ...] = () # Python ''' and """, Vala """
|
|
31
|
+
string_prefix: re.Pattern | None = None # b"", L"", u8"" ...
|
|
32
|
+
template_quote: bool = False # JavaScript `...`
|
|
33
|
+
regex_literals: bool = False # JavaScript /.../
|
|
34
|
+
rust_chars: bool = False # 'a' vs lifetime 'a
|
|
35
|
+
rust_raw: bool = False # r"..." and r#"..."#
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
C = Syntax(string_prefix=_C_PREFIX)
|
|
39
|
+
VALA = Syntax(triple_quotes=('"""',), string_prefix=_C_PREFIX)
|
|
40
|
+
PYTHON = Syntax(
|
|
41
|
+
line_comments=("#",),
|
|
42
|
+
block_comment=None,
|
|
43
|
+
triple_quotes=('"""', "'''"),
|
|
44
|
+
string_prefix=_PY_PREFIX,
|
|
45
|
+
)
|
|
46
|
+
JS = Syntax(template_quote=True, regex_literals=True)
|
|
47
|
+
RUST = Syntax(
|
|
48
|
+
quotes=('"',), nested_blocks=True, rust_chars=True, rust_raw=True, string_prefix=re.compile(r'[bc](?=")')
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Characters after which a "/" in JavaScript starts a regex, not a division.
|
|
52
|
+
_JS_REGEX_PREV = set("(,=:[!&|?{};+-*%<>~^")
|
|
53
|
+
_JS_REGEX_KEYWORDS = ("return", "typeof", "case", "do", "else", "in", "of", "void", "yield")
|
|
54
|
+
_RUST_CHAR = re.compile(r"'(?:\\u\{[0-9a-fA-F]{1,6}\}|\\x[0-9a-fA-F]{2}|\\.|[^\\'\n])'")
|
|
55
|
+
_RUST_RAW = re.compile(r'b?r(#*)"')
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Source:
|
|
60
|
+
text: str
|
|
61
|
+
code: str
|
|
62
|
+
bare: str
|
|
63
|
+
comments: list[tuple[int, str]]
|
|
64
|
+
strings: list[tuple[int, int]] # [start, end) of each literal incl. quotes
|
|
65
|
+
_line_starts: list[int] = field(default_factory=list, repr=False)
|
|
66
|
+
|
|
67
|
+
def __post_init__(self) -> None:
|
|
68
|
+
self._line_starts = [0] + [m.end() for m in re.finditer("\n", self.text)]
|
|
69
|
+
self._string_starts = [s for s, _ in self.strings]
|
|
70
|
+
|
|
71
|
+
def line_of(self, offset: int) -> int:
|
|
72
|
+
"""1-based line number of an offset."""
|
|
73
|
+
return bisect.bisect_right(self._line_starts, offset)
|
|
74
|
+
|
|
75
|
+
def col_of(self, offset: int) -> int:
|
|
76
|
+
"""1-based column of an offset."""
|
|
77
|
+
return offset - self._line_starts[self.line_of(offset) - 1] + 1
|
|
78
|
+
|
|
79
|
+
def line_offset(self, line: int) -> int:
|
|
80
|
+
if line - 1 >= len(self._line_starts):
|
|
81
|
+
return len(self.text)
|
|
82
|
+
return self._line_starts[line - 1]
|
|
83
|
+
|
|
84
|
+
def line_text(self, line: int) -> str:
|
|
85
|
+
start = self.line_offset(line)
|
|
86
|
+
end = self.text.find("\n", start)
|
|
87
|
+
return self.text[start : end if end >= 0 else len(self.text)]
|
|
88
|
+
|
|
89
|
+
def string_at(self, offset: int) -> tuple[int, int] | None:
|
|
90
|
+
"""The literal span that starts exactly at ``offset``, if any."""
|
|
91
|
+
i = bisect.bisect_left(self._string_starts, offset)
|
|
92
|
+
if i < len(self.strings) and self.strings[i][0] == offset:
|
|
93
|
+
return self.strings[i]
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
def in_string(self, offset: int) -> bool:
|
|
97
|
+
i = bisect.bisect_right(self._string_starts, offset) - 1
|
|
98
|
+
return i >= 0 and self.strings[i][0] <= offset < self.strings[i][1]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _blank(s: str) -> str:
|
|
102
|
+
return "".join("\n" if c == "\n" else " " for c in s)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def lex(text: str, syntax: Syntax) -> Source:
|
|
106
|
+
n = len(text)
|
|
107
|
+
code: list[str] = []
|
|
108
|
+
bare: list[str] = []
|
|
109
|
+
comments: list[tuple[int, str]] = []
|
|
110
|
+
strings: list[tuple[int, int]] = []
|
|
111
|
+
line = 1
|
|
112
|
+
i = 0
|
|
113
|
+
last_sig = "" # last significant code character, for JS regex detection
|
|
114
|
+
last_word = ""
|
|
115
|
+
|
|
116
|
+
def emit_comment(start: int, end: int) -> None:
|
|
117
|
+
nonlocal line
|
|
118
|
+
seg = text[start:end]
|
|
119
|
+
comments.append((line, seg))
|
|
120
|
+
blank = _blank(seg)
|
|
121
|
+
code.append(blank)
|
|
122
|
+
bare.append(blank)
|
|
123
|
+
line += seg.count("\n")
|
|
124
|
+
|
|
125
|
+
def emit_string(start: int, end: int, open_len: int, close_len: int) -> None:
|
|
126
|
+
nonlocal line
|
|
127
|
+
seg = text[start:end]
|
|
128
|
+
strings.append((start, end))
|
|
129
|
+
code.append(seg)
|
|
130
|
+
close = len(seg) - close_len
|
|
131
|
+
bare.append(seg[:open_len] + _blank(seg[open_len:close]) + seg[close:])
|
|
132
|
+
line += seg.count("\n")
|
|
133
|
+
|
|
134
|
+
def scan_quoted(start: int, quote: str, multiline: bool) -> int:
|
|
135
|
+
j = start + len(quote)
|
|
136
|
+
while j < n:
|
|
137
|
+
c = text[j]
|
|
138
|
+
if c == "\\":
|
|
139
|
+
j += 2
|
|
140
|
+
continue
|
|
141
|
+
if text.startswith(quote, j):
|
|
142
|
+
return j + len(quote)
|
|
143
|
+
if c == "\n" and not multiline:
|
|
144
|
+
return j
|
|
145
|
+
j += 1
|
|
146
|
+
return n
|
|
147
|
+
|
|
148
|
+
while i < n:
|
|
149
|
+
c = text[i]
|
|
150
|
+
|
|
151
|
+
# Comments.
|
|
152
|
+
lc = next((t for t in syntax.line_comments if text.startswith(t, i)), None)
|
|
153
|
+
if lc is not None:
|
|
154
|
+
end = text.find("\n", i)
|
|
155
|
+
end = n if end < 0 else end
|
|
156
|
+
emit_comment(i, end)
|
|
157
|
+
i = end
|
|
158
|
+
continue
|
|
159
|
+
if syntax.block_comment and text.startswith(syntax.block_comment[0], i):
|
|
160
|
+
op, cl = syntax.block_comment
|
|
161
|
+
depth, j = 1, i + len(op)
|
|
162
|
+
while j < n and depth:
|
|
163
|
+
if syntax.nested_blocks and text.startswith(op, j):
|
|
164
|
+
depth += 1
|
|
165
|
+
j += len(op)
|
|
166
|
+
elif text.startswith(cl, j):
|
|
167
|
+
depth -= 1
|
|
168
|
+
j += len(cl)
|
|
169
|
+
else:
|
|
170
|
+
j += 1
|
|
171
|
+
emit_comment(i, j)
|
|
172
|
+
i = j
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
# Rust raw strings and char literals / lifetimes.
|
|
176
|
+
if syntax.rust_raw and c in "rb" and (i == 0 or not (text[i - 1].isalnum() or text[i - 1] == "_")):
|
|
177
|
+
m = _RUST_RAW.match(text, i)
|
|
178
|
+
if m:
|
|
179
|
+
close = '"' + m.group(1)
|
|
180
|
+
end = text.find(close, m.end())
|
|
181
|
+
end = n if end < 0 else end + len(close)
|
|
182
|
+
emit_string(i, end, m.end() - i, len(close))
|
|
183
|
+
i = end
|
|
184
|
+
last_sig = '"'
|
|
185
|
+
continue
|
|
186
|
+
if syntax.rust_chars and c == "'":
|
|
187
|
+
m = _RUST_CHAR.match(text, i)
|
|
188
|
+
if m:
|
|
189
|
+
emit_string(i, m.end(), 1, 1)
|
|
190
|
+
i = m.end()
|
|
191
|
+
last_sig = "'"
|
|
192
|
+
continue
|
|
193
|
+
code.append(c)
|
|
194
|
+
bare.append(c)
|
|
195
|
+
i += 1
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
# Strings.
|
|
199
|
+
start = i
|
|
200
|
+
if syntax.string_prefix and c.isalnum() and (i == 0 or not (text[i - 1].isalnum() or text[i - 1] == "_")):
|
|
201
|
+
m = syntax.string_prefix.match(text, i)
|
|
202
|
+
if m:
|
|
203
|
+
i = m.end()
|
|
204
|
+
c = text[i]
|
|
205
|
+
q = next((t for t in syntax.triple_quotes if text.startswith(t, i)), None)
|
|
206
|
+
if q is not None:
|
|
207
|
+
end = scan_quoted(i, q, True)
|
|
208
|
+
emit_string(start, end, i - start + 3, 3 if text.endswith(q, 0, end) else 0)
|
|
209
|
+
i = end
|
|
210
|
+
last_sig = '"'
|
|
211
|
+
continue
|
|
212
|
+
if c in syntax.quotes or (syntax.template_quote and c == "`"):
|
|
213
|
+
end = scan_quoted(i, c, c == "`")
|
|
214
|
+
emit_string(start, end, i - start + 1, 1 if end > i + 1 and text[end - 1] == c else 0)
|
|
215
|
+
i = end
|
|
216
|
+
last_sig = '"'
|
|
217
|
+
continue
|
|
218
|
+
i = start
|
|
219
|
+
c = text[i]
|
|
220
|
+
|
|
221
|
+
# JavaScript regex literals.
|
|
222
|
+
if syntax.regex_literals and c == "/" and (last_sig in _JS_REGEX_PREV or last_sig == "" or last_word in _JS_REGEX_KEYWORDS):
|
|
223
|
+
j, in_class = i + 1, False
|
|
224
|
+
while j < n and text[j] != "\n":
|
|
225
|
+
d = text[j]
|
|
226
|
+
if d == "\\":
|
|
227
|
+
j += 2
|
|
228
|
+
continue
|
|
229
|
+
if d == "[":
|
|
230
|
+
in_class = True
|
|
231
|
+
elif d == "]":
|
|
232
|
+
in_class = False
|
|
233
|
+
elif d == "/" and not in_class:
|
|
234
|
+
break
|
|
235
|
+
j += 1
|
|
236
|
+
if j < n and text[j] == "/":
|
|
237
|
+
j += 1
|
|
238
|
+
while j < n and text[j].isalpha():
|
|
239
|
+
j += 1
|
|
240
|
+
emit_string(i, j, 1, 0)
|
|
241
|
+
i = j
|
|
242
|
+
last_sig = '"'
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
code.append(c)
|
|
246
|
+
bare.append(c)
|
|
247
|
+
if c == "\n":
|
|
248
|
+
line += 1
|
|
249
|
+
elif not c.isspace():
|
|
250
|
+
if c.isalnum() or c == "_" or c == "$":
|
|
251
|
+
j = i
|
|
252
|
+
while j < n and (text[j].isalnum() or text[j] in "_$"):
|
|
253
|
+
j += 1
|
|
254
|
+
word = text[i:j]
|
|
255
|
+
code.append(word[1:])
|
|
256
|
+
bare.append(word[1:])
|
|
257
|
+
last_word = word
|
|
258
|
+
last_sig = "a"
|
|
259
|
+
i = j
|
|
260
|
+
continue
|
|
261
|
+
last_sig = c
|
|
262
|
+
last_word = ""
|
|
263
|
+
i += 1
|
|
264
|
+
|
|
265
|
+
return Source(text, "".join(code), "".join(bare), comments, strings)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def literal_value(raw: str) -> str | None:
|
|
269
|
+
"""Best-effort value of a string or char literal (quotes stripped).
|
|
270
|
+
|
|
271
|
+
Returns None for literals whose value cannot be known statically
|
|
272
|
+
(template literals with substitutions, Python f-strings, regexes).
|
|
273
|
+
"""
|
|
274
|
+
m = re.match(r'^(?:[A-Za-z0-9]{0,2})("""|\'\'\'|"|\'|`)', raw)
|
|
275
|
+
if not m:
|
|
276
|
+
return None
|
|
277
|
+
prefix = raw[: m.start(1)].lower()
|
|
278
|
+
if "f" in prefix:
|
|
279
|
+
return None
|
|
280
|
+
q = m.group(1)
|
|
281
|
+
body = raw[m.end(1) :]
|
|
282
|
+
if body.endswith(q):
|
|
283
|
+
body = body[: -len(q)]
|
|
284
|
+
if q == "`" and "${" in body:
|
|
285
|
+
return None
|
|
286
|
+
return body
|
dotless/rules.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Rule catalogue.
|
|
2
|
+
|
|
3
|
+
Every rule here corresponds to a row marked locale-dependent in
|
|
4
|
+
docs/research.md. Functions that were measured to be locale independent
|
|
5
|
+
(Python str.lower(), JavaScript toLowerCase(), Rust to_lowercase(),
|
|
6
|
+
g_ascii_*, g_utf8_casefold(), g_unichar_tolower(), Vala ascii_down() ...)
|
|
7
|
+
deliberately have no rule.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
C_LIKE = ("c",)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Rule:
|
|
19
|
+
id: str
|
|
20
|
+
name: str
|
|
21
|
+
languages: tuple[str, ...]
|
|
22
|
+
summary: str
|
|
23
|
+
fix: str
|
|
24
|
+
help_uri: str = "https://gitlab.com/rehimovv/dotless/-/blob/main/docs/rules.md"
|
|
25
|
+
title: str = "" # one-line headline for text output
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
RULES: dict[str, Rule] = {
|
|
29
|
+
r.id: r
|
|
30
|
+
for r in (
|
|
31
|
+
Rule(
|
|
32
|
+
"DL001",
|
|
33
|
+
"c-ctype-case",
|
|
34
|
+
("c", "rust"),
|
|
35
|
+
"tolower()/toupper() follow LC_CTYPE: in tr_TR.UTF-8 and az_AZ.UTF-8 "
|
|
36
|
+
"glibc leaves 'I' and 'i' unchanged, in tr_TR.ISO-8859-9 it returns "
|
|
37
|
+
"the dotless/dotted variants.",
|
|
38
|
+
"Use g_ascii_tolower()/g_ascii_toupper() for ASCII data; in Rust, "
|
|
39
|
+
"u8::to_ascii_lowercase()/to_ascii_uppercase().",
|
|
40
|
+
title="tolower()/toupper() depend on the locale",
|
|
41
|
+
),
|
|
42
|
+
Rule(
|
|
43
|
+
"DL002",
|
|
44
|
+
"c-strcasecmp",
|
|
45
|
+
("c", "rust"),
|
|
46
|
+
"strcasecmp()-family comparisons follow LC_CTYPE: "
|
|
47
|
+
"strcasecmp(\"INTEGER\", \"integer\") != 0 in tr_TR and az_AZ.",
|
|
48
|
+
"Use g_ascii_strcasecmp()/g_ascii_strncasecmp() for ASCII keywords.",
|
|
49
|
+
title="strcasecmp()-style comparison depends on the locale",
|
|
50
|
+
),
|
|
51
|
+
Rule(
|
|
52
|
+
"DL003",
|
|
53
|
+
"c-wide-case",
|
|
54
|
+
C_LIKE,
|
|
55
|
+
"towlower()/towupper() map 'I' to U+0131 and 'i' to U+0130 in Turkic locales.",
|
|
56
|
+
"Use g_unichar_tolower()/g_unichar_toupper() (locale independent), "
|
|
57
|
+
"or g_ascii_tolower() for ASCII data.",
|
|
58
|
+
title="towlower()/towupper() depend on the locale",
|
|
59
|
+
),
|
|
60
|
+
Rule(
|
|
61
|
+
"DL004",
|
|
62
|
+
"glib-utf8-case",
|
|
63
|
+
("c", "python", "js", "rust"),
|
|
64
|
+
"g_utf8_strdown()/g_utf8_strup() use Turkic rules when LC_CTYPE is "
|
|
65
|
+
"tr_* or az_*: \"INTEGER\" becomes \"ınteger\".",
|
|
66
|
+
"Use g_ascii_strdown()/g_ascii_strup() for identifiers, or "
|
|
67
|
+
"g_utf8_casefold() to compare human-readable text.",
|
|
68
|
+
title="g_utf8_strdown()/g_utf8_strup() follow Turkic rules in tr/az locales",
|
|
69
|
+
),
|
|
70
|
+
Rule(
|
|
71
|
+
"DL005",
|
|
72
|
+
"glib-deprecated-case",
|
|
73
|
+
C_LIKE,
|
|
74
|
+
"Deprecated g_strdown()/g_strup()/g_strcasecmp()/g_strncasecmp() "
|
|
75
|
+
"use the C library's locale-dependent tolower()/toupper().",
|
|
76
|
+
"Use g_ascii_strdown()/g_ascii_strup()/g_ascii_strcasecmp().",
|
|
77
|
+
title="deprecated GLib case function depends on the locale",
|
|
78
|
+
),
|
|
79
|
+
Rule(
|
|
80
|
+
"DL006",
|
|
81
|
+
"vala-string-case",
|
|
82
|
+
("vala",),
|
|
83
|
+
"Vala string.down()/string.up() call g_utf8_strdown()/g_utf8_strup() "
|
|
84
|
+
"and follow the Turkic rules.",
|
|
85
|
+
"Use string.ascii_down()/string.ascii_up() for identifiers, or "
|
|
86
|
+
"string.casefold() to compare human-readable text.",
|
|
87
|
+
title="string.down()/string.up() follow Turkic rules in tr/az locales",
|
|
88
|
+
),
|
|
89
|
+
Rule(
|
|
90
|
+
"DL007",
|
|
91
|
+
"gjs-locale-case",
|
|
92
|
+
("js",),
|
|
93
|
+
"Under gjs, toLocaleLowerCase()/toLocaleUpperCase() without an explicit "
|
|
94
|
+
"locale use the process locale: 'INTEGER' becomes 'ınteger' in "
|
|
95
|
+
"tr_TR/az_AZ.",
|
|
96
|
+
"Use toLowerCase()/toUpperCase() (locale independent), or pass an "
|
|
97
|
+
"explicit locale.",
|
|
98
|
+
title="toLocaleLowerCase()/toLocaleUpperCase() without an explicit locale",
|
|
99
|
+
),
|
|
100
|
+
Rule(
|
|
101
|
+
"DL008",
|
|
102
|
+
"js-localecompare-base",
|
|
103
|
+
("js",),
|
|
104
|
+
"localeCompare() with sensitivity 'base' or 'accent' treats 'I' and 'i' "
|
|
105
|
+
"as different letters in Turkic locales.",
|
|
106
|
+
"Compare a.toLowerCase() === b.toLowerCase(), or pass an explicit "
|
|
107
|
+
"locale to localeCompare().",
|
|
108
|
+
title="localeCompare() equality with sensitivity 'base' or 'accent'",
|
|
109
|
+
),
|
|
110
|
+
Rule(
|
|
111
|
+
"DL009",
|
|
112
|
+
"python-re-locale",
|
|
113
|
+
("python",),
|
|
114
|
+
"re.LOCALE together with re.IGNORECASE uses the C library's tolower(): "
|
|
115
|
+
"b'INTEGER' does not match b'integer' in tr_TR/az_AZ.",
|
|
116
|
+
"Drop re.LOCALE (decode to str and use re.IGNORECASE), or use "
|
|
117
|
+
"explicit character classes.",
|
|
118
|
+
title="re.LOCALE with re.IGNORECASE depends on the locale",
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
SEVERITIES = ("low", "high")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def severity_rank(severity: str) -> int:
|
|
127
|
+
return SEVERITIES.index(severity)
|
dotless/runtime.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""``dotless run``: run a command under Turkic locales and diff its output.
|
|
2
|
+
|
|
3
|
+
The command runs once under a baseline locale (C.UTF-8) and once per test
|
|
4
|
+
locale. Messages are kept untranslated (LANGUAGE=en) so that translation
|
|
5
|
+
catalogues do not show up as differences; everything else follows the
|
|
6
|
+
test locale, which is what a Turkish or Azerbaijani user gets.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import difflib
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
TURKIC_LETTERS = set("ıİ")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Result:
|
|
23
|
+
locale: str
|
|
24
|
+
returncode: int | None
|
|
25
|
+
stdout: str
|
|
26
|
+
stderr: str
|
|
27
|
+
error: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _norm(name: str) -> str:
|
|
31
|
+
return name.lower().replace("utf-8", "utf8").replace("-", "")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def available_locales() -> set[str] | None:
|
|
35
|
+
"""Normalised names from ``locale -a``, or None if unknown."""
|
|
36
|
+
if not shutil.which("locale"):
|
|
37
|
+
return None
|
|
38
|
+
try:
|
|
39
|
+
out = subprocess.run(["locale", "-a"], capture_output=True, text=True, timeout=10).stdout
|
|
40
|
+
except (OSError, subprocess.SubprocessError):
|
|
41
|
+
return None
|
|
42
|
+
return {_norm(line.strip()) for line in out.splitlines() if line.strip()}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def env_for(locale: str) -> dict[str, str]:
|
|
46
|
+
env = dict(os.environ)
|
|
47
|
+
for k in list(env):
|
|
48
|
+
if k.startswith("LC_"):
|
|
49
|
+
del env[k]
|
|
50
|
+
env["LC_ALL"] = locale
|
|
51
|
+
env["LANG"] = locale
|
|
52
|
+
env["LANGUAGE"] = "en"
|
|
53
|
+
return env
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run_once(cmd: list[str], locale: str, timeout: float, stdin_data: bytes | None) -> Result:
|
|
57
|
+
try:
|
|
58
|
+
p = subprocess.run(cmd, env=env_for(locale), input=stdin_data, capture_output=True, timeout=timeout)
|
|
59
|
+
except FileNotFoundError:
|
|
60
|
+
return Result(locale, None, "", "", f"command not found: {cmd[0]}")
|
|
61
|
+
except subprocess.TimeoutExpired:
|
|
62
|
+
return Result(locale, None, "", "", f"timed out after {timeout:g}s")
|
|
63
|
+
dec = lambda b: b.decode("utf-8", errors="replace") # noqa: E731
|
|
64
|
+
return Result(locale, p.returncode, dec(p.stdout), dec(p.stderr))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _diff(a: str, b: str, name_a: str, name_b: str, limit: int = 40) -> list[str]:
|
|
68
|
+
lines = list(difflib.unified_diff(a.splitlines(), b.splitlines(), name_a, name_b, lineterm="", n=1))
|
|
69
|
+
if len(lines) > limit:
|
|
70
|
+
lines = lines[:limit] + [f"... ({len(lines) - limit} more diff lines)"]
|
|
71
|
+
return lines
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def compare(base: Result, other: Result) -> list[str]:
|
|
75
|
+
"""Human-readable differences between two runs (empty if identical)."""
|
|
76
|
+
report = []
|
|
77
|
+
if base.returncode != other.returncode:
|
|
78
|
+
report.append(f"exit status: {base.returncode} under {base.locale}, {other.returncode} under {other.locale}")
|
|
79
|
+
for stream in ("stdout", "stderr"):
|
|
80
|
+
a, b = getattr(base, stream), getattr(other, stream)
|
|
81
|
+
if a != b:
|
|
82
|
+
report.append(f"{stream} differs:")
|
|
83
|
+
report.extend(" " + line for line in _diff(a, b, f"{stream}@{base.locale}", f"{stream}@{other.locale}"))
|
|
84
|
+
new = (set(b) & TURKIC_LETTERS) - set(a)
|
|
85
|
+
if new:
|
|
86
|
+
report.append(f" note: output under {other.locale} contains {', '.join(sorted(new))}, "
|
|
87
|
+
f"which the {base.locale} output does not (Turkic case mapping)")
|
|
88
|
+
return report
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run_compare(cmd: list[str], locales: list[str], baseline: str, timeout: float, stdin_path: str | None) -> int:
|
|
92
|
+
stdin_data = None
|
|
93
|
+
if stdin_path:
|
|
94
|
+
with open(stdin_path, "rb") as fh:
|
|
95
|
+
stdin_data = fh.read()
|
|
96
|
+
|
|
97
|
+
known = available_locales()
|
|
98
|
+
if known is None:
|
|
99
|
+
print("dotless run: cannot list locales (no `locale` command); results may be meaningless "
|
|
100
|
+
"on systems without glibc locales", file=sys.stderr)
|
|
101
|
+
else:
|
|
102
|
+
missing = [loc for loc in [baseline, *locales] if _norm(loc) not in known]
|
|
103
|
+
for loc in missing:
|
|
104
|
+
lang, _, enc = loc.partition(".")
|
|
105
|
+
print(f"dotless run: locale {loc} is not installed; generate it with: "
|
|
106
|
+
f"localedef -i {lang} -f {enc or 'UTF-8'} {loc}", file=sys.stderr)
|
|
107
|
+
if baseline in missing:
|
|
108
|
+
return 2
|
|
109
|
+
locales = [loc for loc in locales if loc not in missing]
|
|
110
|
+
if not locales:
|
|
111
|
+
return 2
|
|
112
|
+
|
|
113
|
+
base = run_once(cmd, baseline, timeout, stdin_data)
|
|
114
|
+
if base.error:
|
|
115
|
+
print(f"dotless run: {base.error}", file=sys.stderr)
|
|
116
|
+
return 2
|
|
117
|
+
|
|
118
|
+
differs = False
|
|
119
|
+
for loc in locales:
|
|
120
|
+
res = run_once(cmd, loc, timeout, stdin_data)
|
|
121
|
+
if res.error:
|
|
122
|
+
print(f"[{loc}] {res.error}")
|
|
123
|
+
differs = True
|
|
124
|
+
continue
|
|
125
|
+
report = compare(base, res)
|
|
126
|
+
if report:
|
|
127
|
+
differs = True
|
|
128
|
+
print(f"[{loc}] output differs from {baseline}:")
|
|
129
|
+
print("\n".join(report))
|
|
130
|
+
else:
|
|
131
|
+
print(f"[{loc}] same output as {baseline}")
|
|
132
|
+
return 1 if differs else 0
|