diff options
Diffstat (limited to 'dot_local/bin/nano_config.py')
-rwxr-xr-x | dot_local/bin/nano_config.py | 346 |
1 files changed, 346 insertions, 0 deletions
diff --git a/dot_local/bin/nano_config.py b/dot_local/bin/nano_config.py new file mode 100755 index 0000000..8d35801 --- /dev/null +++ b/dot_local/bin/nano_config.py @@ -0,0 +1,346 @@ +#!/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() |