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

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

5 

6from __future__ import annotations 

7 

8from pathlib import Path 

9from typing import NamedTuple 

10 

11MAX_FILE_SIZE = 8 * 1024 * 1024 # (bigger files aren't searched: see search_text) 

12 

13 

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 

19 

20 

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 

30 

31 

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" 

44 

45 

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) 

52 

53 

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")