Coverage for vibefpmgui/source_text.py: 100%
36 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""Reading source files: the one set of rules for the editor, Search,
2Rename and Replace in Files (so they agree on a file's text, its encoding
3and its line ends). No Qt.
4"""
6from __future__ import annotations
8from pathlib import Path
9from typing import NamedTuple
11MAX_FILE_SIZE = 8 * 1024 * 1024 # (bigger files aren't searched: see search_text)
14class SourceText(NamedTuple):
15 text: str # with "\n" line ends
16 encoding: str # "utf-8", or "latin-1" for a file that isn't UTF-8
17 newline: str # the line end most of its lines have: "\n" or "\r\n"
18 mixed: bool # it has more than one kind of line end
21def line_endings(text: str) -> tuple[str, bool]:
22 """(the line end most lines of a file's text have: "\\n" or "\\r\\n",
23 whether it has more than one kind). Old Mac line ends (a lone "\\r") count
24 as another kind: Qt reads them as line breaks, saved as the common one."""
25 crlf = text.count("\r\n")
26 lone_cr = text.count("\r") - crlf
27 lf = text.count("\n") - crlf
28 kinds = sum(1 for n in (crlf, lone_cr, lf) if n)
29 return ("\r\n" if crlf > lf else "\n"), kinds > 1
32def decode(raw: bytes) -> tuple[str, str]:
33 """(text, encoding) of a file's bytes: UTF-8 (with its byte-order mark:
34 "utf-8-sig", the mark not in the text but written back), or Latin-1
35 (which reads any bytes) for one that isn't. (Qt's editor drops a mark
36 left in the text: saved, the file lost it; blamed, its first line was
37 "not committed yet".)"""
38 try:
39 if raw.startswith(b"\xef\xbb\xbf"):
40 return raw.decode("utf-8-sig"), "utf-8-sig"
41 return raw.decode("utf-8"), "utf-8"
42 except UnicodeDecodeError:
43 return raw.decode("latin-1"), "latin-1"
46def read_source(path: Path) -> SourceText:
47 """A file's text as the editor shows it, and how to write it back (raises
48 OSError if it can't be read)."""
49 text, encoding = decode(path.read_bytes())
50 newline, mixed = line_endings(text)
51 return SourceText(text.replace("\r\n", "\n"), encoding, newline, mixed)
54def search_text(path: Path) -> str | None:
55 """A file's text ("\\n" line ends) to search, or None for one that's
56 binary, huge or unreadable."""
57 try:
58 if path.stat().st_size > MAX_FILE_SIZE:
59 return None
60 raw = path.read_bytes()
61 except OSError:
62 return None
63 if b"\0" in raw[:8192]:
64 return None
65 return decode(raw)[0].replace("\r\n", "\n")