max / quasi
1 file changed,
+402 insertions,
-0 deletions
| @@ -1,0 +1,402 @@ | |||
| 1 | + | #!/usr/bin/env python3 | |
| 2 | + | """Count the shape functions the `declare!` form has to cover. | |
| 3 | + | ||
| 4 | + | The population is the denominator of phase 1 of the declaration transition | |
| 5 | + | (GoingsOn `65d99281`, wiki `quasi-declare-form`). Five counts of it existed | |
| 6 | + | before this script -- 289, 375, 402, 425, 487 -- and none reproduced, because | |
| 7 | + | none had written its predicate down. This is the predicate, executable. | |
| 8 | + | ||
| 9 | + | THE PREDICATE. A Rust `fn` item with a body, in one of the three shape | |
| 10 | + | directories, outside `tests.rs` and `parity.rs`, outside any `tests/` path | |
| 11 | + | segment, outside every inline `#[cfg(test)]` block, whose return type -- after | |
| 12 | + | stripping `Vec`, `Option`, `Result`, `Box`, `Arc`, `Rc` and `Cow` -- names a | |
| 13 | + | `quasi_router` description type, with `use ... as` aliases resolved and | |
| 14 | + | file-local types of the same name excluded. | |
| 15 | + | ||
| 16 | + | Comments, string literals, raw strings and char literals are blanked in place | |
| 17 | + | before anything is matched, so a type named in a doc comment is not a | |
| 18 | + | construction. That is the defect that produced two of the five old numbers. | |
| 19 | + | ||
| 20 | + | python3 scripts/population.py # the count, per tree | |
| 21 | + | python3 scripts/population.py --list # one `path:line name -> type` per line | |
| 22 | + | python3 scripts/population.py --json # the same, machine-readable | |
| 23 | + | ||
| 24 | + | Paths are read relative to `~/Code` by default; pass `--root` for a tree | |
| 25 | + | somewhere else. | |
| 26 | + | """ | |
| 27 | + | ||
| 28 | + | from __future__ import annotations | |
| 29 | + | ||
| 30 | + | import argparse | |
| 31 | + | import json | |
| 32 | + | import re | |
| 33 | + | import sys | |
| 34 | + | from pathlib import Path | |
| 35 | + | ||
| 36 | + | # The three shape directories, relative to the root. A fourth app joining the | |
| 37 | + | # transition is one line here and a re-run. | |
| 38 | + | SHAPE_DIRS = [ | |
| 39 | + | "MNW/server/src/quasi", | |
| 40 | + | "Apps/goingson/src-tauri/src/quasi", | |
| 41 | + | "Apps/audiofiles/crates/audiofiles-browser/src/quasi", | |
| 42 | + | ] | |
| 43 | + | ||
| 44 | + | # The vocabulary. Every public description type `quasi_router` exports that a | |
| 45 | + | # shape function can return, plus the two enums that are returned as leaves. | |
| 46 | + | # `Node` alone is a fifth of the population. | |
| 47 | + | VOCABULARY = { | |
| 48 | + | "Node", "Screen", "Slot", "Run", "Field", "Row", "Cell", "Cells", | |
| 49 | + | "Act", "Action", "Choice", "Column", "Tag", "Part", "Figure", | |
| 50 | + | "Placed", "Consult", "Meter", "Image", "Lexeme", "Document", "Feed", | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | # Returned by a handful of leaf suppliers that section 6 of `quasi-declare-form` | |
| 54 | + | # defers, so they sit outside the ratified population and inside `--wide`. | |
| 55 | + | # Adding all five moves the count by 10 on the trees as they stand. | |
| 56 | + | DEFERRED_LEAVES = {"Rest", "Candidate", "Accepted", "RegionKind", "Chrome"} | |
| 57 | + | ||
| 58 | + | # Wrappers stripped before the name is looked up. A `-> Result<Vec<Row>, E>` is | |
| 59 | + | # a shape function returning rows; the wrapper says how it fails, not what it is. | |
| 60 | + | WRAPPERS = {"Vec", "Option", "Result", "Box", "Arc", "Rc", "Cow"} | |
| 61 | + | ||
| 62 | + | ||
| 63 | + | def blank_noncode(src: str) -> str: | |
| 64 | + | """Replace every comment, string, raw string and char literal with spaces. | |
| 65 | + | ||
| 66 | + | Newlines are kept so line numbers still hold. Nothing is deleted, so every | |
| 67 | + | offset in the returned text is the offset in the original. | |
| 68 | + | """ | |
| 69 | + | out = list(src) | |
| 70 | + | i, n = 0, len(src) | |
| 71 | + | while i < n: | |
| 72 | + | c = src[i] | |
| 73 | + | # Raw string, with any hash count: r"..", r#".."#, br##".."## | |
| 74 | + | m = re.match(r'(?:b)?r(#*)"', src[i:]) | |
| 75 | + | if m and (i == 0 or not (src[i - 1].isalnum() or src[i - 1] == "_")): | |
| 76 | + | hashes = m.group(1) | |
| 77 | + | close = '"' + hashes | |
| 78 | + | end = src.find(close, i + m.end()) | |
| 79 | + | end = n if end < 0 else end + len(close) | |
| 80 | + | for j in range(i, end): | |
| 81 | + | if out[j] != "\n": | |
| 82 | + | out[j] = " " | |
| 83 | + | i = end | |
| 84 | + | continue | |
| 85 | + | if c == "/" and i + 1 < n and src[i + 1] == "/": | |
| 86 | + | end = src.find("\n", i) | |
| 87 | + | end = n if end < 0 else end | |
| 88 | + | for j in range(i, end): | |
| 89 | + | out[j] = " " | |
| 90 | + | i = end | |
| 91 | + | continue | |
| 92 | + | if c == "/" and i + 1 < n and src[i + 1] == "*": | |
| 93 | + | depth, j = 1, i + 2 | |
| 94 | + | while j < n and depth: | |
| 95 | + | if src.startswith("/*", j): | |
| 96 | + | depth += 1 | |
| 97 | + | j += 2 | |
| 98 | + | elif src.startswith("*/", j): | |
| 99 | + | depth -= 1 | |
| 100 | + | j += 2 | |
| 101 | + | else: | |
| 102 | + | j += 1 | |
| 103 | + | for k in range(i, j): | |
| 104 | + | if out[k] != "\n": | |
| 105 | + | out[k] = " " | |
| 106 | + | i = j | |
| 107 | + | continue | |
| 108 | + | if c == '"': | |
| 109 | + | j = i + 1 | |
| 110 | + | while j < n: | |
| 111 | + | if src[j] == "\\": | |
| 112 | + | j += 2 | |
| 113 | + | continue | |
| 114 | + | if src[j] == '"': | |
| 115 | + | j += 1 | |
| 116 | + | break | |
| 117 | + | j += 1 | |
| 118 | + | for k in range(i, min(j, n)): | |
| 119 | + | if out[k] != "\n": | |
| 120 | + | out[k] = " " | |
| 121 | + | i = j | |
| 122 | + | continue | |
| 123 | + | if c == "'": | |
| 124 | + | # A char literal, not a lifetime: `'a` is a lifetime, `'a'` is a char. | |
| 125 | + | m = re.match(r"'(?:\\.|[^\\'])'", src[i:]) | |
| 126 | + | if m: | |
| 127 | + | for k in range(i, i + m.end()): | |
| 128 | + | out[k] = " " | |
| 129 | + | i += m.end() | |
| 130 | + | continue | |
| 131 | + | i += 1 | |
| 132 | + | return "".join(out) | |
| 133 | + | ||
| 134 | + | ||
| 135 | + | def strip_cfg_test(src: str) -> str: | |
| 136 | + | """Blank every `#[cfg(test)]` item that is followed by an inline block. | |
| 137 | + | ||
| 138 | + | Only a block: `#[cfg(test)] mod tests;` names a file that is excluded by | |
| 139 | + | path anyway, and blanking to the next `}` there would eat the rest of the | |
| 140 | + | file. | |
| 141 | + | """ | |
| 142 | + | out = list(src) | |
| 143 | + | for m in re.finditer(r"#\[cfg\(test\)\]", src): | |
| 144 | + | j = m.end() | |
| 145 | + | while j < len(src) and src[j] not in "{;": | |
| 146 | + | j += 1 | |
| 147 | + | if j >= len(src) or src[j] == ";": | |
| 148 | + | continue | |
| 149 | + | depth, k = 0, j | |
| 150 | + | while k < len(src): | |
| 151 | + | if src[k] == "{": | |
| 152 | + | depth += 1 | |
| 153 | + | elif src[k] == "}": | |
| 154 | + | depth -= 1 | |
| 155 | + | if depth == 0: | |
| 156 | + | k += 1 | |
| 157 | + | break | |
| 158 | + | k += 1 | |
| 159 | + | for p in range(m.start(), k): | |
| 160 | + | if out[p] != "\n": | |
| 161 | + | out[p] = " " | |
| 162 | + | return "".join(out) | |
| 163 | + | ||
| 164 | + | ||
| 165 | + | def local_types(src: str) -> set[str]: | |
| 166 | + | """Types the file declares itself, which shadow the vocabulary name.""" | |
| 167 | + | return set( | |
| 168 | + | re.findall(r"^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|type|union)\s+(\w+)", | |
| 169 | + | src, re.M) | |
| 170 | + | ) | |
| 171 | + | ||
| 172 | + | ||
| 173 | + | def imported(src: str) -> tuple[set[str], dict[str, str]]: | |
| 174 | + | """Names brought in from `quasi_router`, and every `as` alias in the file. | |
| 175 | + | ||
| 176 | + | Returns (names in scope from quasi_router, alias -> real name). Braced and | |
| 177 | + | single-item `use` forms both, at any nesting depth: the only thing that | |
| 178 | + | matters is whether the path starts at `quasi_router`. | |
| 179 | + | """ | |
| 180 | + | names: set[str] = set() | |
| 181 | + | aliases: dict[str, str] = {} | |
| 182 | + | for m in re.finditer(r"use\s+([^;]+);", src): | |
| 183 | + | body = m.group(1) | |
| 184 | + | if "quasi_router" not in body and "quasi_webview" not in body: | |
| 185 | + | continue | |
| 186 | + | for leaf in re.findall(r"(\w+)(?:\s+as\s+(\w+))?", body): | |
| 187 | + | name, alias = leaf | |
| 188 | + | if name in VOCABULARY: | |
| 189 | + | names.add(alias or name) | |
| 190 | + | if alias: | |
| 191 | + | aliases[alias] = name | |
| 192 | + | return names, aliases | |
| 193 | + | ||
| 194 | + | ||
| 195 | + | def split_top(s: str) -> list[str]: | |
| 196 | + | """Split on commas at bracket depth zero.""" | |
| 197 | + | out, depth, start = [], 0, 0 | |
| 198 | + | for i, c in enumerate(s): | |
| 199 | + | if c in "<([": | |
| 200 | + | depth += 1 | |
| 201 | + | elif c in ">)]": | |
| 202 | + | depth -= 1 | |
| 203 | + | elif c == "," and depth == 0: | |
| 204 | + | out.append(s[start:i]) | |
| 205 | + | start = i + 1 | |
| 206 | + | out.append(s[start:]) | |
| 207 | + | return [p for p in (x.strip() for x in out) if p] | |
| 208 | + | ||
| 209 | + | ||
| 210 | + | def head_type(ret: str) -> str | None: | |
| 211 | + | """The vocabulary name a return type resolves to, or None. | |
| 212 | + | ||
| 213 | + | Strips the wrappers outside-in, takes the last `::` segment, and stops at | |
| 214 | + | the first name that is not a wrapper. `Result<Vec<Node>, RouteError>` is | |
| 215 | + | `Node`; `Result<(), E>` is nothing. | |
| 216 | + | """ | |
| 217 | + | ret = ret.strip() | |
| 218 | + | for _ in range(6): | |
| 219 | + | ret = ret.strip().lstrip("&").strip() | |
| 220 | + | ret = re.sub(r"^'\w+\s+", "", ret) | |
| 221 | + | m = re.match(r"([\w:]+)\s*<(.*)>$", ret, re.S) | |
| 222 | + | if not m: | |
| 223 | + | break | |
| 224 | + | head = m.group(1).split("::")[-1] | |
| 225 | + | if head not in WRAPPERS: | |
| 226 | + | return head | |
| 227 | + | inner = m.group(2) | |
| 228 | + | # `Result<T, E>`: the first argument at depth zero is the payload. | |
| 229 | + | depth, cut = 0, len(inner) | |
| 230 | + | for i, c in enumerate(inner): | |
| 231 | + | if c in "<([": | |
| 232 | + | depth += 1 | |
| 233 | + | elif c in ">)]": | |
| 234 | + | depth -= 1 | |
| 235 | + | elif c == "," and depth == 0: | |
| 236 | + | cut = i | |
| 237 | + | break | |
| 238 | + | ret = inner[:cut] | |
| 239 | + | ret = ret.strip().lstrip("&").strip() | |
| 240 | + | ret = re.sub(r"^'\w+\s+", "", ret) | |
| 241 | + | # A tuple return. One site in the trees as they stand (goingson | |
| 242 | + | # `data.rs:380 csv_rows`), and `shaped` names one type, so the form | |
| 243 | + | # defers it -- but it is a shape function and it counts. | |
| 244 | + | if ret.startswith("(") and ret.endswith(")"): | |
| 245 | + | for part in split_top(ret[1:-1]): | |
| 246 | + | head = head_type(part) | |
| 247 | + | if head in VOCABULARY: | |
| 248 | + | return head | |
| 249 | + | return None | |
| 250 | + | name = ret.split("::")[-1].strip() | |
| 251 | + | return name or None | |
| 252 | + | ||
| 253 | + | ||
| 254 | + | def match_angle(src: str, start: int) -> int: | |
| 255 | + | """Index just past the `>` closing the `<` at `start`.""" | |
| 256 | + | depth, i = 0, start | |
| 257 | + | while i < len(src): | |
| 258 | + | if src[i] == "<": | |
| 259 | + | depth += 1 | |
| 260 | + | elif src[i] == ">": | |
| 261 | + | depth -= 1 | |
| 262 | + | if depth == 0: | |
| 263 | + | return i + 1 | |
| 264 | + | elif src[i] == ";": | |
| 265 | + | return -1 | |
| 266 | + | i += 1 | |
| 267 | + | return -1 | |
| 268 | + | ||
| 269 | + | ||
| 270 | + | def match_paren(src: str, start: int) -> int: | |
| 271 | + | depth, i = 0, start | |
| 272 | + | while i < len(src): | |
| 273 | + | if src[i] == "(": | |
| 274 | + | depth += 1 | |
| 275 | + | elif src[i] == ")": | |
| 276 | + | depth -= 1 | |
| 277 | + | if depth == 0: | |
| 278 | + | return i + 1 | |
| 279 | + | i += 1 | |
| 280 | + | return -1 | |
| 281 | + | ||
| 282 | + | ||
| 283 | + | def shapes_in(path: Path) -> list[dict]: | |
| 284 | + | raw = path.read_text(encoding="utf-8", errors="replace") | |
| 285 | + | src = strip_cfg_test(blank_noncode(raw)) | |
| 286 | + | scope, aliases = imported(raw) | |
| 287 | + | shadowed = local_types(src) | |
| 288 | + | found = [] | |
| 289 | + | for m in re.finditer(r"\bfn\s+(\w+)\s*(?=[<(])", src): | |
| 290 | + | name = m.group(1) | |
| 291 | + | i = m.end() | |
| 292 | + | if src[i] == "<": | |
| 293 | + | i = match_angle(src, i) | |
| 294 | + | if i < 0: | |
| 295 | + | continue | |
| 296 | + | while i < len(src) and src[i].isspace(): | |
| 297 | + | i += 1 | |
| 298 | + | if i >= len(src) or src[i] != "(": | |
| 299 | + | continue | |
| 300 | + | i = match_paren(src, i) | |
| 301 | + | if i < 0: | |
| 302 | + | continue | |
| 303 | + | # Return type runs to the body's `{`, the `where` clause, or a `;`. | |
| 304 | + | j = i | |
| 305 | + | depth = 0 | |
| 306 | + | while j < len(src): | |
| 307 | + | c = src[j] | |
| 308 | + | if c in "<([": | |
| 309 | + | depth += 1 | |
| 310 | + | elif c in ">)]": | |
| 311 | + | depth -= 1 | |
| 312 | + | elif c == "{" and depth <= 0: | |
| 313 | + | break | |
| 314 | + | elif c == ";" and depth <= 0: | |
| 315 | + | j = -1 | |
| 316 | + | break | |
| 317 | + | j += 1 | |
| 318 | + | if j < 0 or j >= len(src): | |
| 319 | + | continue # no body: a trait signature or an extern declaration | |
| 320 | + | sig = src[i:j] | |
| 321 | + | if re.search(r"\bwhere\b", sig): | |
| 322 | + | sig = sig[: sig.index("where")] | |
| 323 | + | sig = sig.strip() | |
| 324 | + | if not sig.startswith("->"): | |
| 325 | + | continue # returns unit | |
| 326 | + | ret = sig[2:].strip() | |
| 327 | + | head = head_type(ret) | |
| 328 | + | if head is None: | |
| 329 | + | continue | |
| 330 | + | real = aliases.get(head, head) | |
| 331 | + | if real not in VOCABULARY: | |
| 332 | + | continue | |
| 333 | + | if head in shadowed: | |
| 334 | + | continue # a file-local type wearing a vocabulary name | |
| 335 | + | if head not in scope and real not in scope: | |
| 336 | + | # Reached by path (`quasi_router::Node`) rather than by import. | |
| 337 | + | if "quasi_router" not in ret and "quasi_webview" not in ret: | |
| 338 | + | continue | |
| 339 | + | found.append( | |
| 340 | + | {"file": str(path), "line": src.count("\n", 0, m.start()) + 1, | |
| 341 | + | "fn": name, "returns": " ".join(ret.split())} | |
| 342 | + | ) | |
| 343 | + | return found | |
| 344 | + | ||
| 345 | + | ||
| 346 | + | def main() -> int: | |
| 347 | + | ap = argparse.ArgumentParser(description=__doc__, | |
| 348 | + | formatter_class=argparse.RawDescriptionHelpFormatter) | |
| 349 | + | ap.add_argument("--root", default=str(Path.home() / "Code")) | |
| 350 | + | ap.add_argument("--list", action="store_true") | |
| 351 | + | ap.add_argument("--json", action="store_true") | |
| 352 | + | ap.add_argument("--wide", action="store_true", | |
| 353 | + | help="also count the deferred leaf suppliers (Rest, Candidate, " | |
| 354 | + | "Accepted, RegionKind, Chrome)") | |
| 355 | + | args = ap.parse_args() | |
| 356 | + | ||
| 357 | + | if args.wide: | |
| 358 | + | VOCABULARY.update(DEFERRED_LEAVES) | |
| 359 | + | ||
| 360 | + | root = Path(args.root) | |
| 361 | + | per_tree: dict[str, list[dict]] = {} | |
| 362 | + | missing = [] | |
| 363 | + | for rel in SHAPE_DIRS: | |
| 364 | + | d = root / rel | |
| 365 | + | if not d.is_dir(): | |
| 366 | + | missing.append(rel) | |
| 367 | + | continue | |
| 368 | + | hits: list[dict] = [] | |
| 369 | + | for f in sorted(d.rglob("*.rs")): | |
| 370 | + | parts = f.relative_to(root).parts | |
| 371 | + | if f.name in ("tests.rs", "parity.rs") or "tests" in parts: | |
| 372 | + | continue | |
| 373 | + | hits.extend(shapes_in(f)) | |
| 374 | + | for h in hits: | |
| 375 | + | h["file"] = str(Path(h["file"]).relative_to(root)) | |
| 376 | + | per_tree[rel] = hits | |
| 377 | + | ||
| 378 | + | total = sum(len(v) for v in per_tree.values()) | |
| 379 | + | ||
| 380 | + | if args.json: | |
| 381 | + | json.dump({"total": total, | |
| 382 | + | "per_tree": {k: len(v) for k, v in per_tree.items()}, | |
| 383 | + | "shapes": [h for v in per_tree.values() for h in v]}, | |
| 384 | + | sys.stdout, indent=1) | |
| 385 | + | print() | |
| 386 | + | return 0 | |
| 387 | + | ||
| 388 | + | if args.list: | |
| 389 | + | for rel, hits in per_tree.items(): | |
| 390 | + | for h in hits: | |
| 391 | + | print(f"{h['file']}:{h['line']} {h['fn']} -> {h['returns']}") | |
| 392 | + | ||
| 393 | + | for rel, hits in per_tree.items(): | |
| 394 | + | print(f"{len(hits):5} {rel}") | |
| 395 | + | print(f"{total:5} TOTAL") | |
| 396 | + | for rel in missing: | |
| 397 | + | print(f" ? {rel} (not present under {root})", file=sys.stderr) | |
| 398 | + | return 0 | |
| 399 | + | ||
| 400 | + | ||
| 401 | + | if __name__ == "__main__": | |
| 402 | + | raise SystemExit(main()) |