"""What a page title may be, stated as pure functions. A title becomes a file name one to one, so the rule is the intersection of what Windows, macOS and Linux accept - checked on every platform, because a corpus written on Linux is checked out on the others. The normative statement is `kb/CONTRACT.md` § "Titles are identifiers"; this module implements it and holds no `fail()`, so `lint_core` can use it without going through `commands/`. """ from __future__ import annotations import unicodedata FORBIDDEN_CHARS = frozenset('<>:"/\\|?*') # Windows MAX_PATH is 259 usable characters for the whole path, install folder # included, and long paths are off on the target system. A file's path below the # instance root may use 160 of them; the folder limit that `doctor` and preflight # enforce is the other half of the same sum, so change one only together with it. PATH_BUDGET = 160 # Windows device names, matched on the part before the first dot. The superscript # forms are reserved by some Windows versions and not others; refusing all of # them costs nothing. _WINDOWS_RESERVED = frozenset( {"CON", "PRN", "AUX", "NUL"} | {f"{device}{digit}" for device in ("COM", "LPT") for digit in "0123456789¹²³"} ) # Names the stack itself owns next to a page: the generated catalog shard and # the per-collection authoring contract (`kb_scan.GENERATED_INDEX`, # `kb_scan._COLLECTION_CONTRACT`, both matched here by the part before the dot). _STACK_RESERVED = frozenset({"INDEX", "COLLECTION"}) def collision_key(name: str) -> str: """The key under which two names count as the same file on a case-insensitive, normalizing file system (NTFS, APFS): NFC, then `casefold`.""" return unicodedata.normalize("NFC", name).casefold() def title_problems(title: str) -> list[str]: """Every reason `title` cannot be a page title; empty when it can.""" if not title: return ["the title is empty"] problems: list[str] = [] forbidden = sorted({c for c in title if c in FORBIDDEN_CHARS}) if forbidden: shown = " ".join(f"'{c}'" for c in forbidden) problems.append(f"contains {shown}, which Windows does not allow in a file name") control = sorted({c for c in title if unicodedata.category(c) == "Cc"}) if control: shown = " ".join(f"U+{ord(c):04X}" for c in control) problems.append(f"contains the control character(s) {shown}") # Windows ignores trailing spaces and dots on the name before the extension, # so `CON .x` opens the console device just as `CON.x` does. base = title.split(".", 1)[0].rstrip(" ").upper() if base in _WINDOWS_RESERVED: problems.append(f"'{base}' is a reserved device name on Windows") elif base in _STACK_RESERVED: problems.append(f"'{base}' is reserved for a file the stack generates or owns") if title.endswith((".", " ")): problems.append("ends with a dot or a space, which Windows strips from file names") return problems def path_length(rel_path: str) -> int: """`rel_path`'s length in UTF-16 code units, which is how Windows counts MAX_PATH - a character outside the BMP (an emoji) counts twice.""" return len(rel_path.encode("utf-16-le")) // 2 def path_budget_problem(rel_path: str) -> str | None: """Why the path of a file, relative to the instance root and written with `/`, is over `PATH_BUDGET`; None when it fits.""" length = path_length(rel_path) if length <= PATH_BUDGET: return None excess = length - PATH_BUDGET return ( f"{rel_path} is {length} characters long (counted in UTF-16 code units, as Windows " f"counts MAX_PATH) and the budget is {PATH_BUDGET}: the path has to get {excess} shorter" )