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/scanner.py ADDED
@@ -0,0 +1,418 @@
1
+ """Find locale-dependent case conversions in source files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import os
7
+ import re
8
+ from dataclasses import asdict, dataclass
9
+ from typing import Callable, Iterable, Iterator
10
+
11
+ from . import lexer
12
+ from .analysis import (
13
+ BOTH,
14
+ LOWER,
15
+ UPPER,
16
+ Verdict,
17
+ enclosing,
18
+ judge_call,
19
+ judge_literals,
20
+ literals_in,
21
+ matching_close,
22
+ )
23
+ from .lexer import Source
24
+ from .rules import RULES, severity_rank
25
+
26
+ EXTENSIONS = {
27
+ ".c": "c", ".h": "c", ".cc": "c", ".cpp": "c", ".cxx": "c", ".hh": "c", ".hpp": "c",
28
+ ".vala": "vala", ".gs": "vala",
29
+ ".py": "python",
30
+ ".js": "js", ".mjs": "js",
31
+ ".rs": "rust",
32
+ }
33
+ SYNTAX = {"c": lexer.C, "vala": lexer.VALA, "python": lexer.PYTHON, "js": lexer.JS, "rust": lexer.RUST}
34
+
35
+ DEFAULT_EXCLUDES = (
36
+ ".git", ".hg", "node_modules", "_build", "build", "builddir", "target",
37
+ "__pycache__", ".venv", "venv", ".flatpak-builder", "subprojects", "po",
38
+ )
39
+
40
+ _SUPPRESS = re.compile(r"dotless:\s*ignore(?P<file>-file)?(?:\[(?P<ids>[A-Z0-9_,\s]+)\])?")
41
+
42
+
43
+ @dataclass
44
+ class Finding:
45
+ rule: str
46
+ severity: str
47
+ path: str
48
+ line: int
49
+ column: int
50
+ end_column: int
51
+ language: str
52
+ call: str
53
+ message: str
54
+ reason: str
55
+ fix: str
56
+ snippet: str
57
+
58
+ def to_dict(self) -> dict:
59
+ return asdict(self)
60
+
61
+
62
+ @dataclass
63
+ class Match:
64
+ rule: str
65
+ start: int # start of the reported expression
66
+ end: int # end of the reported expression
67
+ call: str
68
+ kind: str
69
+ verdict: Verdict | None = None # pre-decided verdict
70
+
71
+
72
+ Matcher = Callable[[Source], Iterator[Match]]
73
+
74
+ # ---------------------------------------------------------------- C / C++
75
+
76
+ _C_DECL_BEFORE = re.compile(r"(?:\b(?:int|extern|static|inline|define|undef|wint_t|char)\s*\**\s*|#\s*(?:define|undef)\s+)$")
77
+ _C_PARAMS = re.compile(r"\s*(?:(?:const|unsigned|signed|struct|volatile)\s+)*[A-Za-z_]\w*(?:\s+|\s*\*+\s*)[A-Za-z_]\w*\s*[,)]")
78
+ _C_CTYPE = re.compile(r"(?<![\w$.>:])(?:std\s*::\s*)?(tolower|toupper|_tolower|_toupper)\s*\(")
79
+ _C_WIDE = re.compile(r"(?<![\w$.>:])(?:std\s*::\s*)?(towlower|towupper)\s*\(")
80
+ _C_CASECMP = re.compile(r"(?<![\w$.>:])(strcasecmp|strncasecmp|strcasestr|wcscasecmp|wcsncasecmp)\s*\(")
81
+ _C_GLIB_DEPR = re.compile(r"(?<![\w$.>:])(g_strdown|g_strup|g_strcasecmp|g_strncasecmp)\s*\(")
82
+ _C_GLIB_UTF8 = re.compile(r"(?<![\w$.>:])(g_utf8_strdown|g_utf8_strup)\s*\(")
83
+ _C_CALLBACK = re.compile(r"(?<=[(,])\s*(?:\(\s*\w+\s*\)\s*)?(strcasecmp|strncasecmp|g_strcasecmp|wcscasecmp)\s*(?=[,)])")
84
+ _LOOKUP_CALLERS = re.compile(r"^(bsearch|lfind|lsearch|g_hash_table_new\w*|g_tree_new\w*|g_\w*find_custom|g_ptr_array_find_with_equal_func|g_\w*_lookup\w*)$")
85
+
86
+
87
+ def _kind_of(name: str) -> str:
88
+ n = name.lower()
89
+ if "casecmp" in n or "casestr" in n:
90
+ return BOTH
91
+ if "low" in n or "down" in n:
92
+ return LOWER
93
+ return UPPER
94
+
95
+
96
+ def _calls(src: Source, pattern: re.Pattern, rule: str, decl_guard: bool = True) -> Iterator[Match]:
97
+ bare = src.bare
98
+ for m in pattern.finditer(bare):
99
+ if decl_guard and _C_DECL_BEFORE.search(bare, max(0, m.start() - 40), m.start()):
100
+ continue
101
+ close = matching_close(bare, m.end() - 1)
102
+ # Function definitions and prototypes: "name (const gchar *str, ..."
103
+ if decl_guard and _C_PARAMS.match(bare, m.end()):
104
+ continue
105
+ yield Match(rule, m.start(), close, re.sub(r"\s+", "", m.group(0)[:-1]), _kind_of(m.group(1)))
106
+
107
+
108
+ def _casecmp_calls(src: Source, pattern: re.Pattern, rule: str) -> Iterator[Match]:
109
+ """strcasecmp-family: the comparison constant is an argument."""
110
+ for match in _calls(src, pattern, rule):
111
+ open_idx = src.bare.index("(", match.start)
112
+ lits = literals_in(src, open_idx, match.end)
113
+ verdict = judge_literals(lits, BOTH, "compares with") if lits else None
114
+ if verdict is not None and verdict.severity is None:
115
+ continue
116
+ match.verdict = verdict
117
+ yield match
118
+
119
+
120
+ def match_c(src: Source) -> Iterator[Match]:
121
+ yield from _calls(src, _C_CTYPE, "DL001")
122
+ yield from _casecmp_calls(src, _C_CASECMP, "DL002")
123
+ yield from _calls(src, _C_WIDE, "DL003")
124
+ yield from _calls(src, _C_GLIB_UTF8, "DL004")
125
+ for m in _calls(src, _C_GLIB_DEPR, "DL005"):
126
+ if m.kind == BOTH:
127
+ open_idx = src.bare.index("(", m.start)
128
+ lits = literals_in(src, open_idx, m.end)
129
+ m.verdict = judge_literals(lits, BOTH, "compares with") if lits else None
130
+ if m.verdict is not None and m.verdict.severity is None:
131
+ continue
132
+ yield m
133
+ # strcasecmp passed as a callback (bsearch, g_hash_table_new, ...)
134
+ for m in _C_CALLBACK.finditer(src.bare):
135
+ enc = enclosing(src.bare, m.start(1))
136
+ name = enc[1] if enc else ""
137
+ if _LOOKUP_CALLERS.match(name):
138
+ v = Verdict("high", f"used as the comparison function of {name}()")
139
+ else:
140
+ v = Verdict("low", f"passed as a callback to {name or 'a function'}()")
141
+ rule = "DL005" if m.group(1).startswith("g_") else "DL002"
142
+ yield Match(rule, m.start(1), m.end(1), m.group(1), BOTH, v)
143
+
144
+
145
+ # ------------------------------------------------------------------- Vala
146
+
147
+ _VALA_CASE = re.compile(r"\.\s*(down|up)\s*\(")
148
+
149
+
150
+ def match_vala(src: Source) -> Iterator[Match]:
151
+ bare = src.bare
152
+ for m in _VALA_CASE.finditer(bare):
153
+ close = matching_close(bare, m.end() - 1)
154
+ args = bare[m.end() : close - 1].strip()
155
+ if args and not re.fullmatch(r"[\w.\-+ ]+", args):
156
+ continue
157
+ # Start the expression at the receiver so comparisons in front of
158
+ # it ("x" == s.down ()) are seen.
159
+ rs = m.start()
160
+ recv = re.search(r"([\w.]+|\))\s*$", bare[max(0, rs - 200) : rs])
161
+ start = rs - len(recv.group(0)) if recv and recv.group(1) != ")" else rs
162
+ yield Match("DL006", start, close, m.group(1) + "()", LOWER if m.group(1) == "down" else UPPER)
163
+
164
+
165
+ # ----------------------------------------------------------------- Python
166
+
167
+ _PY_GLIB = re.compile(r"\bGLib\s*\.\s*(utf8_strdown|utf8_strup)\s*\(")
168
+ _PY_RE_LOCALE = re.compile(r"\bre\s*\.\s*(?:L|LOCALE)\b")
169
+ _PY_RE_ICASE = re.compile(r"\bre\s*\.\s*(?:I|IGNORECASE)\b")
170
+
171
+
172
+ def match_python(src: Source) -> Iterator[Match]:
173
+ yield from _calls(src, _PY_GLIB, "DL004", decl_guard=False)
174
+ bare = src.bare
175
+ for m in _PY_RE_LOCALE.finditer(bare):
176
+ enc = enclosing(bare, m.start())
177
+ if enc is None:
178
+ continue
179
+ open_idx, name, _ = enc
180
+ close = matching_close(bare, open_idx)
181
+ call_text = bare[open_idx:close]
182
+ lits = literals_in(src, open_idx, close)
183
+ if not (_PY_RE_ICASE.search(call_text) or any(v.startswith("(?i") for v in lits)):
184
+ continue
185
+ pattern = lits[0] if lits else None
186
+ if pattern is None:
187
+ v = Verdict("low", "pattern is not a constant")
188
+ elif re.search(r"[iI]|a-z|A-Z", pattern):
189
+ v = Verdict("high", f"case-insensitive pattern {pattern!r} contains i/I or a letter range")
190
+ else:
191
+ continue
192
+ name_start = bare.rfind(name, 0, open_idx)
193
+ yield Match("DL009", name_start if name_start >= 0 else open_idx, close, f"re.{name}(..., re.LOCALE)", BOTH, v)
194
+
195
+
196
+ # ------------------------------------------------------------- JavaScript
197
+
198
+ _JS_LOCALE_CASE = re.compile(r"\.\s*(toLocaleLowerCase|toLocaleUpperCase)\s*\(")
199
+ _JS_GLIB = re.compile(r"\bGLib\s*\.\s*(utf8_strdown|utf8_strup)\s*\(")
200
+ _JS_LOCALE_COMPARE = re.compile(r"\.\s*localeCompare\s*\(")
201
+ _JS_NO_LOCALE = re.compile(r"^\s*(?:undefined|null|\[\s*\])?\s*$")
202
+
203
+
204
+ def match_js(src: Source) -> Iterator[Match]:
205
+ bare = src.bare
206
+ for m in _JS_LOCALE_CASE.finditer(bare):
207
+ close = matching_close(bare, m.end() - 1)
208
+ args = bare[m.end() : close - 1]
209
+ if not _JS_NO_LOCALE.match(args):
210
+ lits = literals_in(src, m.end(), close)
211
+ if lits:
212
+ continue # explicit locale, e.g. 'en-US' or deliberately 'tr'
213
+ start = _receiver_start(bare, m.start())
214
+ yield Match("DL007", start, close, m.group(1) + "()", LOWER if "Lower" in m.group(1) else UPPER)
215
+ yield from _calls(src, _JS_GLIB, "DL004", decl_guard=False)
216
+ for m in _JS_LOCALE_COMPARE.finditer(bare):
217
+ close = matching_close(bare, m.end() - 1)
218
+ args_code = src.code[m.end() : close - 1]
219
+ if not re.search(r"sensitivity\s*:\s*['\"](base|accent)['\"]", args_code):
220
+ continue
221
+ # Explicit locale as second argument?
222
+ lits = literals_in(src, m.end(), close)
223
+ start = _receiver_start(bare, m.start())
224
+ cmp = re.match(r"\s*(===|!==|==|!=|<|>)\s*0\b", bare[close:])
225
+ if lits and re.match(r"['\"]", args_code.split(",")[1].strip() if "," in args_code else ""):
226
+ continue
227
+ receiver_lits = literals_in(src, start, m.start())
228
+ candidates = [v for v in lits if not re.fullmatch(r"base|accent", v)] + receiver_lits
229
+ if cmp and any(_has_i(v) for v in candidates):
230
+ v = Verdict("high", "equality check against a constant containing i/I")
231
+ elif cmp:
232
+ v = Verdict("low", "equality check with sensitivity base/accent")
233
+ else:
234
+ continue # sorting: locale-aware order is intended
235
+ yield Match("DL008", start, close, "localeCompare()", BOTH, v)
236
+
237
+
238
+ def _has_i(v: str) -> bool:
239
+ return v.isascii() and ("i" in v or "I" in v)
240
+
241
+
242
+ def _receiver_start(bare: str, dot: int) -> int:
243
+ recv = re.search(r"([\w$.]+)\s*$", bare[max(0, dot - 200) : dot])
244
+ return dot - len(recv.group(0)) if recv else dot
245
+
246
+
247
+ # ------------------------------------------------------------------- Rust
248
+
249
+ _RS_LIBC_CTYPE = re.compile(r"\blibc\s*::\s*(tolower|toupper)\s*\(")
250
+ _RS_LIBC_CASECMP = re.compile(r"\blibc\s*::\s*(strcasecmp|strncasecmp)\s*\(")
251
+ _RS_GLIB_FFI = re.compile(r"(?<![\w$])(?:(?:glib\s*::\s*)?ffi\s*::\s*|glib_sys\s*::\s*)?(g_utf8_strdown|g_utf8_strup)\s*\(")
252
+
253
+
254
+ def match_rust(src: Source) -> Iterator[Match]:
255
+ yield from _calls(src, _RS_LIBC_CTYPE, "DL001", decl_guard=False)
256
+ yield from _casecmp_calls(src, _RS_LIBC_CASECMP, "DL002")
257
+ for m in _calls(src, _RS_GLIB_FFI, "DL004", decl_guard=False):
258
+ if re.search(r"\bfn\s*$", src.bare[max(0, m.start - 20) : m.start]):
259
+ continue
260
+ yield m
261
+
262
+
263
+ MATCHERS: dict[str, Matcher] = {
264
+ "c": match_c,
265
+ "vala": match_vala,
266
+ "python": match_python,
267
+ "js": match_js,
268
+ "rust": match_rust,
269
+ }
270
+
271
+ # Cheap prefilter: skip lexing files that cannot contain a match.
272
+ PREFILTER = {
273
+ "c": re.compile(r"tolower|toupper|towlower|towupper|casecmp|casestr|g_strdown|g_strup|g_utf8_str(?:down|up)"),
274
+ "vala": re.compile(r"\.\s*(?:down|up)\s*\("),
275
+ "python": re.compile(r"utf8_str(?:down|up)|re\s*\.\s*(?:L\b|LOCALE)"),
276
+ "js": re.compile(r"toLocale(?:Lower|Upper)Case|utf8_str(?:down|up)|localeCompare"),
277
+ "rust": re.compile(r"libc\s*::\s*(?:to(?:lower|upper)|str\w*casecmp)|g_utf8_str(?:down|up)"),
278
+ }
279
+
280
+
281
+ def language_of(path: str) -> str | None:
282
+ return EXTENSIONS.get(os.path.splitext(path)[1].lower())
283
+
284
+
285
+ def _suppressions(src: Source) -> tuple[bool, dict[int, set[str] | None]]:
286
+ """(whole file ignored, {line: rule ids or None for all rules})."""
287
+ lines: dict[int, set[str] | None] = {}
288
+ for line, text in src.comments:
289
+ for m in _SUPPRESS.finditer(text):
290
+ if m.group("file"):
291
+ return True, {}
292
+ ids = {i.strip() for i in m.group("ids").split(",")} if m.group("ids") else None
293
+ comment_line = line + text[: m.start()].count("\n")
294
+ targets = [comment_line]
295
+ if src.line_text(comment_line).strip().startswith(("//", "/*", "#", "*")):
296
+ targets.append(comment_line + 1)
297
+ for t in targets:
298
+ prev = lines.get(t, set())
299
+ lines[t] = None if ids is None or prev is None else prev | ids
300
+ return False, lines
301
+
302
+
303
+ def scan_text(text: str, path: str, lang: str) -> list[Finding]:
304
+ if not PREFILTER[lang].search(text):
305
+ return []
306
+ src = lexer.lex(text, SYNTAX[lang])
307
+ ignore_file, suppressed = _suppressions(src)
308
+ if ignore_file:
309
+ return []
310
+ findings = []
311
+ seen: set[tuple[str, int]] = set()
312
+ for m in MATCHERS[lang](src):
313
+ verdict = m.verdict or judge_call(src, m.start, m.end, m.kind, lang)
314
+ if verdict.severity is None:
315
+ continue
316
+ line = src.line_of(m.start)
317
+ sup = suppressed.get(line, set())
318
+ if sup is None or m.rule in sup:
319
+ continue
320
+ key = (m.rule, m.start)
321
+ if key in seen:
322
+ continue
323
+ seen.add(key)
324
+ rule = RULES[m.rule]
325
+ col = src.col_of(m.start)
326
+ end_col = col + (m.end - m.start) if src.line_of(m.end) == line else len(src.line_text(line)) + 1
327
+ findings.append(
328
+ Finding(
329
+ rule=m.rule,
330
+ severity=verdict.severity,
331
+ path=path,
332
+ line=line,
333
+ column=col,
334
+ end_column=end_col,
335
+ language=lang,
336
+ call=m.call,
337
+ message=f"{m.call}: {rule.summary}",
338
+ reason=verdict.reason,
339
+ fix=rule.fix,
340
+ snippet=src.line_text(line).strip(),
341
+ )
342
+ )
343
+ findings.sort(key=lambda f: (f.line, f.column))
344
+ return findings
345
+
346
+
347
+ def iter_files(
348
+ paths: Iterable[str], excludes: Iterable[str] = (), force_exclude: bool = False
349
+ ) -> Iterator[tuple[str, str]]:
350
+ """Source files under ``paths`` as (path, language).
351
+
352
+ Exclude patterns apply to files found while walking directories. Files
353
+ named explicitly are always scanned, unless ``force_exclude`` is set
354
+ (useful for pre-commit, which passes file names).
355
+ """
356
+ excludes = tuple(excludes)
357
+
358
+ def excluded(rel: str) -> bool:
359
+ rel = rel.replace(os.sep, "/")
360
+ return any(fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(os.path.basename(rel), pat) for pat in excludes)
361
+
362
+ for root in paths:
363
+ if os.path.isfile(root):
364
+ lang = language_of(root)
365
+ if lang and not (force_exclude and _explicit_excluded(root, excluded)):
366
+ yield root, lang
367
+ continue
368
+ for dirpath, dirnames, filenames in os.walk(root):
369
+ dirnames[:] = sorted(
370
+ d for d in dirnames
371
+ if d not in DEFAULT_EXCLUDES and not excluded(os.path.relpath(os.path.join(dirpath, d), root))
372
+ )
373
+ for f in sorted(filenames):
374
+ full = os.path.join(dirpath, f)
375
+ lang = language_of(f)
376
+ if lang and not excluded(os.path.relpath(full, root)):
377
+ yield full, lang
378
+
379
+
380
+ def _explicit_excluded(path: str, excluded: Callable[[str], bool]) -> bool:
381
+ """Whether an explicitly named file, or one of its directories, is excluded."""
382
+ rel = os.path.relpath(path).replace(os.sep, "/")
383
+ parts = rel.split("/")
384
+ if any(p in DEFAULT_EXCLUDES for p in parts[:-1]):
385
+ return True
386
+ return any(excluded("/".join(parts[: i + 1])) for i in range(len(parts)))
387
+
388
+
389
+ def scan_paths(
390
+ paths: Iterable[str],
391
+ min_severity: str = "low",
392
+ excludes: Iterable[str] = (),
393
+ disable: Iterable[str] = (),
394
+ force_exclude: bool = False,
395
+ errors: list[str] | None = None,
396
+ ) -> tuple[list[Finding], int]:
397
+ """Scan files and directories. Returns (findings, number of files scanned).
398
+
399
+ Files that cannot be read are skipped; their error messages are added
400
+ to ``errors`` when given.
401
+ """
402
+ findings: list[Finding] = []
403
+ count = 0
404
+ floor = severity_rank(min_severity)
405
+ disabled = set(disable)
406
+ for path, lang in iter_files(paths, excludes, force_exclude):
407
+ try:
408
+ with open(path, "rb") as fh:
409
+ text = fh.read().decode("utf-8", errors="replace")
410
+ except OSError as e:
411
+ if errors is not None:
412
+ errors.append(f"{path}: {e.strerror or e}")
413
+ continue
414
+ count += 1
415
+ for f in scan_text(text, path.replace(os.sep, "/"), lang):
416
+ if severity_rank(f.severity) >= floor and f.rule not in disabled:
417
+ findings.append(f)
418
+ return findings, count
@@ -0,0 +1,278 @@
1
+ Metadata-Version: 2.4
2
+ Name: dotless
3
+ Version: 1.0.0
4
+ Summary: Find Turkish and Azerbaijani dotted/dotless I bugs caused by locale-dependent case mapping in C/GLib, Vala, Python, JavaScript and Rust
5
+ Author-email: Ramal Rahimov <jhonaluman@gmail.com>
6
+ License-Expression: GPL-3.0-or-later
7
+ Project-URL: Homepage, https://gitlab.com/rehimovv/dotless
8
+ Project-URL: Source, https://gitlab.com/rehimovv/dotless
9
+ Project-URL: Issues, https://gitlab.com/rehimovv/dotless/-/issues
10
+ Project-URL: Changelog, https://gitlab.com/rehimovv/dotless/-/blob/main/CHANGELOG.md
11
+ Project-URL: Mirror, https://github.com/rehimovv/dotless
12
+ Keywords: i18n,l10n,locale,turkish,azerbaijani,gnome,glib,linter,static-analysis,dotless-i,case-mapping
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Natural Language :: English
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: C
19
+ Classifier: Programming Language :: JavaScript
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3 :: Only
22
+ Classifier: Programming Language :: Python :: 3.9
23
+ Classifier: Programming Language :: Python :: 3.10
24
+ Classifier: Programming Language :: Python :: 3.11
25
+ Classifier: Programming Language :: Python :: 3.12
26
+ Classifier: Programming Language :: Python :: 3.13
27
+ Classifier: Programming Language :: Rust
28
+ Classifier: Topic :: Software Development :: Localization
29
+ Classifier: Topic :: Software Development :: Quality Assurance
30
+ Requires-Python: >=3.9
31
+ Description-Content-Type: text/markdown
32
+ License-File: LICENSE
33
+ Provides-Extra: test
34
+ Requires-Dist: pytest; extra == "test"
35
+ Dynamic: license-file
36
+
37
+ # dotless
38
+
39
+ [![PyPI](https://img.shields.io/pypi/v/dotless)](https://pypi.org/project/dotless/)
40
+ [![Pipeline](https://gitlab.com/rehimovv/dotless/badges/main/pipeline.svg)](https://gitlab.com/rehimovv/dotless/-/pipelines)
41
+ [![Python versions](https://img.shields.io/pypi/pyversions/dotless)](https://pypi.org/project/dotless/)
42
+ [![License: GPL-3.0-or-later](https://img.shields.io/badge/license-GPL--3.0--or--later-blue)](https://gitlab.com/rehimovv/dotless/-/blob/main/LICENSE)
43
+
44
+ Find the Turkish and Azerbaijani "I" bug in C/GLib, Vala, Python,
45
+ JavaScript (gjs) and Rust code.
46
+
47
+ ![dotless scanning evolution-data-server](https://gitlab.com/rehimovv/dotless/-/raw/main/docs/demo.svg)
48
+
49
+ In Turkish and Azerbaijani, the upper-case form of `i` is `İ` and the
50
+ lower-case form of `I` is `ı`. Code that changes case with the user's locale
51
+ and then compares the result with an ASCII keyword breaks for everyone
52
+ running `tr_TR` or `az_AZ`:
53
+
54
+ ```c
55
+ char *t = g_utf8_strdown (type, -1); /* "INTEGER" -> "ınteger" in tr_TR */
56
+ if (strcmp (t, "integer") == 0) /* never true for Turkish users */
57
+ ...
58
+ if (strcasecmp (ext, "ini") == 0) /* "INI" != "ini" in tr_TR/az_AZ */
59
+ ```
60
+
61
+ | | Upper | Lower |
62
+ |---|---|---|
63
+ | English | I | i |
64
+ | Turkish / Azerbaijani | I | ı |
65
+ | Turkish / Azerbaijani | İ | i |
66
+
67
+ ## Why dotless?
68
+
69
+ This bug has been found and fixed one project at a time for decades.
70
+ Python's codec lookup failed under Turkish locales because it normalised
71
+ names with the C library's `tolower()`
72
+ ([bpo-1813](https://bugs.python.org/issue1813), fixed 2011). LibreOffice's
73
+ resource compiler had to be fixed to work in `tr_TR.UTF-8` because of
74
+ `toupper()`/`tolower()`
75
+ ([commit](https://github.com/LibreOffice/core/commit/81d2967bc7be11e7efa40a296fee40210843e5e7),
76
+ 2016), and later failed to substitute configuration entries
77
+ ([tdf#125284](https://github.com/LibreOffice/core/commit/7bc3da4ff0a6fda621340a4ded79f7ed4ce31d85),
78
+ 2019). Geary did not sync mail from Gmail with `LC_CTYPE=tr_TR.UTF-8`
79
+ ([GNOME bug 795906](https://bugzilla.gnome.org/show_bug.cgi?id=795906), fixed in 0.12.3).
80
+ dotless looks for the pattern systematically.
81
+
82
+ Every rule is backed by a measurement, not by documentation alone: small
83
+ probe programs run under `C.UTF-8`, `tr_TR.UTF-8`, `az_AZ.UTF-8` and
84
+ `tr_TR.ISO-8859-9` in CI ([docs/research.md](https://gitlab.com/rehimovv/dotless/-/blob/main/docs/research.md)). Some
85
+ results were not what one would guess:
86
+
87
+ * glibc's `toupper('i')` in a UTF-8 Turkish locale returns `'i'`, not `İ`:
88
+ the answer does not fit in a byte, so the letter is left alone.
89
+ * GLib only applies Turkic rules after the program calls `setlocale()`.
90
+ Every GTK application does; a plain Rust binary using `glib::ffi` does not.
91
+ * gjs and node disagree: `toLocaleLowerCase()` without arguments follows the
92
+ locale in gjs but not in node 20.
93
+ * musl (Alpine) has no Turkish case mapping at all; the bug is a glibc bug in
94
+ practice, which is what GNOME distributions ship.
95
+ * Python 3's `str.lower()` is locale independent. Only `re.LOCALE` on bytes
96
+ is affected.
97
+
98
+ ## Install
99
+
100
+ ```bash
101
+ pipx install dotless
102
+ ```
103
+
104
+ or `pip install dotless`. dotless needs Python 3.9 or newer and has no
105
+ dependencies. The development version installs with
106
+ `pipx install git+https://gitlab.com/rehimovv/dotless.git`.
107
+
108
+ ## Usage
109
+
110
+ ```bash
111
+ dotless scan path/to/project
112
+ ```
113
+
114
+ | Option | |
115
+ |---|---|
116
+ | `--format text\|json\|sarif\|gitlab` | output format (default `text`) |
117
+ | `--min-severity low\|high` | hide low-severity findings |
118
+ | `--output FILE` | write the report to a file |
119
+ | `--exclude GLOB` | skip paths (repeatable); `.git`, `build`, `subprojects`, `node_modules`, `po` ... are skipped by default |
120
+ | `--disable RULE` | turn a rule off, e.g. `--disable DL005` (repeatable) |
121
+ | `--color auto\|always\|never` | colour in the terminal; `auto` honours `NO_COLOR` and turns colour off when output is piped |
122
+ | `--config FILE`, `--no-config` | read settings from another file, or ignore `pyproject.toml` |
123
+ | `--force-exclude` | apply excludes to files named on the command line too |
124
+ | `--exit-zero` | always exit with 0 |
125
+
126
+ `dotless rules` lists the rules and `dotless --version` prints the version.
127
+
128
+ **Exit status:** `0` nothing found, `1` findings reported, `2` usage or
129
+ configuration error (unknown option or rule, missing path, invalid
130
+ `[tool.dotless]`). Files that cannot be read are reported on stderr and
131
+ skipped.
132
+
133
+ To silence a finding, add a comment `dotless: ignore` (or
134
+ `dotless: ignore[DL002]`) on the same line or on the line above;
135
+ `dotless: ignore-file` skips a file.
136
+
137
+ ### Configuration
138
+
139
+ dotless reads `[tool.dotless]` from the nearest `pyproject.toml` (in the
140
+ current directory or a parent). Command line options take precedence.
141
+
142
+ ```toml
143
+ [tool.dotless]
144
+ exclude = ["vendor", "tests/data/*"]
145
+ min-severity = "high"
146
+ disable = ["DL005"]
147
+ ```
148
+
149
+ Reading `pyproject.toml` needs Python 3.11 or newer (`tomllib`). On 3.9 and
150
+ 3.10 dotless prints a warning and ignores the table; command line options
151
+ work everywhere.
152
+
153
+ ### pre-commit
154
+
155
+ ```yaml
156
+ repos:
157
+ - repo: https://gitlab.com/rehimovv/dotless
158
+ rev: v1.0.0
159
+ hooks:
160
+ - id: dotless
161
+ ```
162
+
163
+ The hook passes `--force-exclude`, so `exclude` patterns from
164
+ `[tool.dotless]` also apply to the files pre-commit hands over.
165
+
166
+ ### CI
167
+
168
+ GitLab — one line in `.gitlab-ci.yml`; findings show up in the merge
169
+ request Code Quality widget:
170
+
171
+ ```yaml
172
+ include: 'https://gitlab.com/rehimovv/dotless/-/raw/main/ci/dotless.gitlab-ci.yml'
173
+ ```
174
+
175
+ GitHub Actions — upload SARIF to code scanning:
176
+
177
+ ```yaml
178
+ - run: pipx install dotless
179
+ - run: dotless scan . --format sarif --output dotless.sarif --exit-zero
180
+ - uses: github/codeql-action/upload-sarif@v4
181
+ with:
182
+ sarif_file: dotless.sarif
183
+ ```
184
+
185
+ ### Runtime check
186
+
187
+ ```bash
188
+ dotless run -- ./my-program --some-args
189
+ ```
190
+
191
+ runs the command under `C.UTF-8`, then under `tr_TR.UTF-8` and
192
+ `az_AZ.UTF-8`, and shows any difference in exit status, stdout or stderr.
193
+ Messages stay untranslated (`LANGUAGE=en`), so differences come from the
194
+ locale itself. Missing locales can be generated with
195
+ `localedef -i tr_TR -f UTF-8 tr_TR.UTF-8`. Exit status: `0` same output,
196
+ `1` differences, `2` the comparison could not run.
197
+
198
+ ## Severity
199
+
200
+ Only I/i behaves differently, so dotless can often tell whether a call is
201
+ actually broken. A conversion is **high** when its result is compared with
202
+ an ASCII constant containing the affected letter (`"ini"`, `"file"`, `'I'`),
203
+ or is looked up as a hash table key, config key, file extension, MIME type
204
+ or protocol name. A comparison with a constant that has no i/I (`"png"`) is
205
+ the same in every locale and is not reported. Everything else is **low**.
206
+ Details: [docs/rules.md](https://gitlab.com/rehimovv/dotless/-/blob/main/docs/rules.md).
207
+
208
+ ## Languages and rules
209
+
210
+ | Rule | Language | Flagged (locale-dependent) | Not flagged (measured safe) |
211
+ |---|---|---|---|
212
+ | DL001 | C, Rust `libc::` | `tolower`, `toupper` | `g_ascii_tolower`, `g_ascii_toupper` |
213
+ | DL002 | C, Rust `libc::` | `strcasecmp`, `strncasecmp`, `strcasestr`, `wcscasecmp` | `g_ascii_strcasecmp`, `g_ascii_strncasecmp` |
214
+ | DL003 | C | `towlower`, `towupper` | `g_unichar_tolower`, `g_unichar_toupper` |
215
+ | DL004 | C, Python, gjs, Rust `glib::ffi` | `g_utf8_strdown`, `g_utf8_strup` | `g_ascii_strdown`, `g_utf8_casefold` |
216
+ | DL005 | C | `g_strdown`, `g_strup`, `g_strcasecmp`, `g_strncasecmp` (deprecated) | |
217
+ | DL006 | Vala | `string.down()`, `string.up()` | `ascii_down()`, `ascii_up()`, `casefold()` |
218
+ | DL007 | JavaScript (gjs) | `toLocaleLowerCase()`, `toLocaleUpperCase()` without a locale | `toLowerCase()`, `toUpperCase()` |
219
+ | DL008 | JavaScript | `localeCompare(..., {sensitivity: 'base'}) === 0` | `localeCompare` for sorting |
220
+ | DL009 | Python | `re.IGNORECASE \| re.LOCALE` | `str.lower()`, `str.upper()`, `str.casefold()`, `re.IGNORECASE` |
221
+ | — | Rust | | `to_lowercase()`, `to_uppercase()`, `to_ascii_lowercase()` |
222
+
223
+ ## Bugs found
224
+
225
+ * evolution-data-server: IMAP `NIL` detection and the NTLM LanManager hash
226
+ use `toupper()`, so a lower-case `nil` is rejected and the LM response
227
+ differs for passwords containing `i` under `tr_TR`/`az_AZ`
228
+ ([#677](https://gitlab.gnome.org/GNOME/evolution-data-server/-/issues/677), reported).
229
+
230
+ A scan of ten GNOME modules and the reproducers behind this report are in
231
+ [reports/](https://gitlab.com/rehimovv/dotless/-/blob/main/reports/README.md) and [reproducers/](https://gitlab.com/rehimovv/dotless/-/tree/main/reproducers).
232
+
233
+ ## Contributing
234
+
235
+ See [CONTRIBUTING.md](https://gitlab.com/rehimovv/dotless/-/blob/main/CONTRIBUTING.md). False positives are bugs; please
236
+ report them with the code that triggered them.
237
+
238
+ ## License
239
+
240
+ GPL-3.0-or-later.
241
+
242
+ ## Türkçe
243
+
244
+ dotless, büyük/küçük harf dönüşümünü sistem diline göre yapıp sonucu ASCII
245
+ bir anahtar kelimeyle karşılaştıran kodu bulur. Bu tür kod `tr_TR` ve
246
+ `az_AZ` yerellerinde bozulur: `"INTEGER"` küçültülünce `"ınteger"` olur,
247
+ `strcasecmp("INI", "ini")` eşit dönmez.
248
+
249
+ ```bash
250
+ pipx install dotless
251
+ dotless scan <dizin>
252
+ ```
253
+
254
+ Ayarlar `pyproject.toml` içindeki `[tool.dotless]` tablosundan okunur
255
+ (Python 3.11 ve üstü); pre-commit kancası ve GitLab/GitHub CI örnekleri
256
+ yukarıdadır. Bir bulguyu susturmak için satıra `dotless: ignore` yorumu
257
+ eklenir. Hangi fonksiyonun gerçekten yerele bağlı olduğu
258
+ [docs/research.md](https://gitlab.com/rehimovv/dotless/-/blob/main/docs/research.md) dosyasında ölçüm sonuçlarıyla
259
+ belgelenmiştir.
260
+
261
+ ## Azərbaycanca
262
+
263
+ dotless böyük/kiçik hərf çevirməsini sistemin dilinə görə edib nəticəni
264
+ ASCII açar sözlə müqayisə edən kodu tapır. Belə kod `az_AZ` və `tr_TR`
265
+ lokallarında səhv işləyir: `"INTEGER"` kiçildiləndə `"ınteger"` olur,
266
+ `strcasecmp("INI", "ini")` bərabər qaytarmır.
267
+
268
+ ```bash
269
+ pipx install dotless
270
+ dotless scan <qovluq>
271
+ ```
272
+
273
+ Parametrlər `pyproject.toml` faylındakı `[tool.dotless]` cədvəlindən oxunur
274
+ (Python 3.11 və daha yeni); pre-commit və GitLab/GitHub CI nümunələri
275
+ yuxarıdadır. Tapıntını susdurmaq üçün sətrə `dotless: ignore` şərhi
276
+ əlavə olunur. Hansı funksiyanın həqiqətən lokaldan asılı olduğu
277
+ [docs/research.md](https://gitlab.com/rehimovv/dotless/-/blob/main/docs/research.md) faylında ölçmə nəticələri ilə
278
+ sənədləşdirilib.