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/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