#!/usr/bin/env python3 """Guard the nomarchy-menu Back/Left contract (BACKLOG #106). Every *internal* list picker built with `rofi_menu` must emit the shared `↩ Back` row (via `back`, `printf … "$BACK"`, or a literal). External modi/tools and free-text prompts are listed as exceptions — Esc is their back path. Usage: check-menu-back.py Pure — no rofi runtime. Fails on a list menu that can strand the user. """ from __future__ import annotations import re import sys from pathlib import Path if len(sys.argv) != 2: sys.exit(f"usage: {sys.argv[0]} ") text = Path(sys.argv[1]).read_text(encoding="utf-8") # Strip Nix multi-line comments lightly? Keep simple — work on the whole file. # Find `| rofi_menu` call sites (possibly with line breaks before `|`). # Look at a window of lines before each call for BACK emission. lines = text.splitlines() # Exception markers: if the window mentions these, allow no BACK row. EXCEPTION_MARKERS = ( "External tool:", "external modi", "Esc closes", "Esc is their back", "free-text", "Free-text", "no list to", "rofi_menu -p search", # web: empty stdin free-text "rofi_menu -p ask", # calc/emoji/network are exec-only without rofi_menu list construction ) def window_has_back(chunk: str) -> bool: # Shared helpers emit ↩ Back: bare `back`, printf of $BACK, or a literal. if re.search(r"(?m)^\s*back\s*$", chunk): return True if re.search(r"\bback\b", chunk) and "back()" not in chunk.split("rofi_menu")[0][-20:]: # `back` as a command (not only the word in comments). Prefer line-start. if re.search(r"(?m)^\s*back\b", chunk): return True if "$BACK" in chunk or r"\$BACK" in chunk or "↩ Back" in chunk: return True if re.search(r'printf[^\n]*BACK', chunk): return True return False def window_is_exception(chunk: str) -> bool: return any(m in chunk for m in EXCEPTION_MARKERS) # Match lines that invoke rofi_menu as a picker (not the function definition). invoke_re = re.compile(r"\brofi_menu\b") bad: list[str] = [] for i, line in enumerate(lines): if "rofi_menu()" in line or "rofi_menu ()" in line: continue if not invoke_re.search(line): continue # Skip comments and the definition of the helper's docs. stripped = line.lstrip() if stripped.startswith("#"): continue # Window: up to 40 lines before this call (menu construction). start = max(0, i - 40) chunk = "\n".join(lines[start : i + 1]) if window_is_exception(chunk): continue # Free-text: empty stdin piped in if re.search(r"rofi_menu[^\n]*<\s*/dev/null", chunk) or re.search( r"rofi_menu[^\n]*\n[^\n]*<\s*/dev/null", chunk ): continue if not window_has_back(chunk): bad.append(f"line {i + 1}: rofi_menu without ↩ Back in preceding construction:\n {line.strip()}") if bad: print("check-menu-back: FAIL — internal list menus must end with ↩ Back:\n", file=sys.stderr) for b in bad: print(b, file=sys.stderr) sys.exit(1) print(f"check-menu-back: ok — {sum(1 for l in lines if invoke_re.search(l) and 'rofi_menu()' not in l)} rofi_menu sites scanned")