summary refs log tree commit diff
path: root/dot_local
diff options
context:
space:
mode:
Diffstat (limited to 'dot_local')
-rwxr-xr-xdot_local/bin/nano_config.py346
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()