Files changed: - CHANGES.md - README.md - VERSION - instructions/page-lifecycle.md - kb/CONTRACT.md - tools/CONTRACT.md - tools/chemenu/commands/_util.py - tools/chemenu/commands/lint.py - tools/chemenu/commands/new_page.py - tools/chemenu/commands/page_ops.py - tools/chemenu/commands/raw_cmd.py - tools/chemenu/lint_core.py - tools/chemenu/tests/test_lint.py - tools/chemenu/tests/test_new_page.py - tools/chemenu/tests/test_page_ops.py - tools/chemenu/tests/test_raw_cmd.py - tools/chemenu/tests/test_titles.py - tools/chemenu/titles.py
89 lines
3.7 KiB
Python
89 lines
3.7 KiB
Python
"""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"
|
|
)
|