#!/usr/bin/env python3 from __future__ import annotations import argparse import json import subprocess import tempfile from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Mapping from typing import TypeAlias Version: TypeAlias = tuple[int, ...] OPTIONS: Mapping[str, tuple[str, Version, Version | None]] = { "afterends": ("nextword function (Ctrl+Right) stops at word ends", (4, 0), None), "atblanks": ("Soft wrapping breaks on spaces", (4, 0), None), "nonewlines": ("Do not ensure final newline", (4, 1), None), "historylog": ("Remember search history", (4, 0), None), "linenumbers": ("Show line numbers", (4, 0), None), "locking": ("Vim-style soft lock files", (4, 0), None), "mouse": ("Enable mouse", (4, 0), None), "multibuffer": ("Allow multiple buffers", (4, 0), None), "positionlog": ("Remember place in file", (4, 0), None), "quickblank": ("Clear status bar after one keystroke", (4, 0), None), "regexp": ("Regex search by default", (4, 0), None), "smarthome": ("Home toggles start of line and start of text", (4, 0), None), "speller": ("Spelling checker", (4, 0), None), "suspend": ("Allow suspending", (4, 0), (4, 9)), "suspendable": ("Allow suspending", (4, 9), (6, 0)), "tabsize 4": ("Sane tab size", (4, 0), None), "tabstospaces": ("Convert tabs to spaces", (4, 0), None), "whitespace": ("Whitespace characters", (4, 0), None), "zap": ("Let delete/backspace delete selection", (4, 0), None), "indicator": ("Scrollbar on the right", (5, 0), None), "minibar": ("Supress title bar and show state at the bottom", (5, 5), None), "guidestripe 80": ("Show guide stripe", (6, 1), None), "stateflags": ("Show state in title bar", (5, 3), None), "magic": ("Fall back to libmagic for syntax choosing", (4, 0), (5, 3)), } BINDS: Mapping[str, tuple[str, Version, Version | None]] = { "suspend main": ("Allow suspending", (6, 0), None), } DEFAULT_BINDS: Mapping[str, tuple[str, Version, Version | None]] = {} def _named_colour( version: Version, colour: str, base16: bool, ) -> str: """Based on src/rcfile.c""" _ansi_8 = [ "red", "green", "blue", "magenta", "yellow", "cyan", "white", "black", ] _ansi_16 = [ *_ansi_8, *(f"light{colour}" for colour in _ansi_8), *(f"bright{colour}" for colour in _ansi_8), "grey", "gray", ] _named_256 = [ "pink", "purple", "mauve", "lagoon", "mint", "lime", "peach", "orange", "latte", "rosy", "beet", "plum", "sea", "sky", "slate", "teal", "sage", "brown", "ocher", "sand", "tawny", "brick", "crimson", "normal", ] if colour in _named_256: if base16: raise ValueError("Fuck off, use base 16 colours") elif colour not in _ansi_16: raise ValueError(f"Unknown colour {colour}") if colour.startswith("light") and version < (5, 0): colour = f"bright{colour[5:]}" if colour.startswith("bright") and version >= (5, 0): colour = f"light{colour[5:]}" return colour def _segment( version: Version, name: str, value: str, base16: bool, ) -> str | None: # Segment name part valid = { "titlecolor": (4, 0), "statuscolor": (4, 0), "errorcolor": (4, 0), "selectedcolor": (4, 0), "stripecolor": (4, 0), "numbercolor": (4, 0), "keycolor": (4, 0), "functioncolor": (4, 0), "scrollercolor": (5, 3), "promptcolor": (5, 5), "spotlightcolor": (5, 6), } if name not in valid: raise ValueError(f"Unknown segment: {name}") if version < valid[name]: return None if name == "spotlightcolor" and version < (5, 6, 1): name = "highlightcolor" # Colour value part raw_parts = value.split(",") bold = "bold" in raw_parts italic = "italic" in raw_parts fg_bg = [part for part in raw_parts if part != "bold" if part != "italic"] if len(fg_bg) == 1: (fg,) = fg_bg bg = None else: fg, bg = fg_bg parts = [] if version >= (5, 0): if bold: parts.append("bold") if italic: parts.append("italic") elif bold: try: fg = _named_colour(version, f"bright{fg}", base16) except ValueError: pass parts.append(_named_colour(version, fg, base16) if fg else "") if bg: parts.append(_named_colour(version, bg, base16)) value = ",".join(parts) return f"set {name} {value}" def _version(string: str) -> Version: if string.startswith("v"): string = string[1:] return tuple(map(int, string.split("."))) def generate_changes(start_version: Version): with tempfile.TemporaryDirectory() as temp_dir: nano_git = Path(temp_dir) / "nano.git" # Clone repo subprocess.check_call( [ "git", "clone", "--bare", "https://git.savannah.gnu.org/git/nano.git", nano_git, ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) # Find relevant tags tags = [ tag for tag in subprocess.check_output( [ "git", "--git-dir", nano_git, "tag", "--list", ], stderr=subprocess.PIPE, ) .decode() .splitlines() if tag.startswith("v") if all(map(str.isnumeric, tag[1:].split("."))) ] # Built a history of changes to the sample configuration _configs_per_version = [] _versions = [] tracked_config = set() for tag in sorted(tags, key=_version): version = _version(tag) if version < start_version: continue nanorc = subprocess.check_output( ["git", "--git-dir", nano_git, "show", f"{tag}:doc/sample.nanorc.in"], stderr=subprocess.PIPE, ).decode() config_lines = { line for line in nanorc.splitlines() if line.strip() if not line.startswith("##") if line != '" main' if line != '\x1bu" main' } assert all(line.startswith("#") for line in config_lines) added = config_lines - tracked_config removed = tracked_config - config_lines tracked_config = config_lines if added or removed: _configs_per_version.append((version, removed, added)) _versions.append(version) return _versions, _configs_per_version def generate_config( version: Version, config_path: Path, out_path: Path | None, base16: bool, ) -> None: config = json.loads(config_path.read_text()) output = [] # Non-theme options for option, value in config["set"].items(): if option not in OPTIONS: raise ValueError(f"Untracked option {option}") doc, start, stop = OPTIONS[option] if version >= start and (stop is None or version < stop): if value is True: output.extend((f"# {doc}", f"set {option}", "")) else: output.extend((f"# {doc}", f'set {option} "{value}"', "")) # Binds for key, bind in config["bind"].items(): if bind not in BINDS: raise ValueError(f"Untracked binding {bind}") doc, start, stop = BINDS[bind] if version >= start and (stop is None or version < stop): output.extend((f"# {doc}", f"bind {key} {bind}", "")) # Unbinds for key, _ in config["unbind"].items(): if key not in DEFAULT_BINDS: raise ValueError(f"Untracked unbinding {key}") doc, start, stop = DEFAULT_BINDS if version >= start and (stop is None or version < stop): output.extend((f"# {doc}", f"unbind {key}", "")) # Theme output.append("# Theme") for name, value in config["theme"].items(): segment = _segment(version, name, value, base16) if segment: output.append(segment) output.append("") if out_path: out_path.write_text("\n".join(output)) else: print("\n".join(output)) def check_updates( start_version: Version, last_checked_version: Version, ) -> None: versions, configs_per_version = generate_changes(start_version) last_changed_version, _, _ = configs_per_version[-1] if last_changed_version > last_checked_version: version_str = ".".join(map(str, last_changed_version)) raise RuntimeError(f"Update for newest version: {version_str}") def main(): parser = argparse.ArgumentParser() subparsers = parser.add_subparsers(dest="action") check_parser = subparsers.add_parser("check") check_parser.add_argument( "--start", type=_version, default=(4, 0), ) check_parser.add_argument( "--last-checked", type=_version, default=(7, 2), ) generate_parser = subparsers.add_parser("generate") generate_parser.add_argument( "--version", type=_version, default=(7, 2), ) generate_parser.add_argument( "--config", type=Path, required=True, ) generate_parser.add_argument( "--out", type=Path, ) generate_parser.add_argument( "--256-colours", action="store_false", default=True, dest="base16", ) args = parser.parse_args() if args.action == "check": check_updates(args.start, args.last_checked) elif args.action == "generate": generate_config(args.version, args.config, args.out, args.base16) if __name__ == "__main__": main()