From 8a9384de1510c3edc0f063e6c67950685c1a0594 Mon Sep 17 00:00:00 2001 From: imxyy_soope_ Date: Sat, 29 Aug 2026 09:56:33 +0800 Subject: [PATCH] noctalia: add just sync-noctalia to fold GUI overrides --- Justfile | 8 + .../neovim/nvim/lua/plugins/lsp/lspconfig.lua | 1 + modules/coding/langs/python.nix | 8 +- scripts/sync-noctalia.py | 488 ++++++++++++++++++ treefmt.nix | 1 + 5 files changed, 505 insertions(+), 1 deletion(-) create mode 100755 scripts/sync-noctalia.py diff --git a/Justfile b/Justfile index 4a99091..f8ff3c3 100644 --- a/Justfile +++ b/Justfile @@ -49,3 +49,11 @@ alias offline := switch-offline @fmt: echo "Formatting files..." nix fmt + +@sync-noctalia: + echo "Folding noctalia GUI overrides back into the repo..." + python3 scripts/sync-noctalia.py --apply + +@sync-noctalia-preview: + echo "Previewing noctalia config sync (no changes)..." + python3 scripts/sync-noctalia.py diff --git a/modules/coding/editor/neovim/nvim/lua/plugins/lsp/lspconfig.lua b/modules/coding/editor/neovim/nvim/lua/plugins/lsp/lspconfig.lua index e8bfa6a..6603d16 100644 --- a/modules/coding/editor/neovim/nvim/lua/plugins/lsp/lspconfig.lua +++ b/modules/coding/editor/neovim/nvim/lua/plugins/lsp/lspconfig.lua @@ -81,6 +81,7 @@ local servers = { java_language_server = {}, jsonls = {}, pyrefly = {}, + ruff = {}, -- taplo = {}, tombi = {}, ts_ls = {}, diff --git a/modules/coding/langs/python.nix b/modules/coding/langs/python.nix index 95db061..fc8765f 100644 --- a/modules/coding/langs/python.nix +++ b/modules/coding/langs/python.nix @@ -14,9 +14,15 @@ in config = lib.mkIf cfg.enable { my.hm.home.packages = with pkgs; [ - python3 + (python3.withPackages ( + ps: with ps; [ + tomli + tomli-w + ] + )) uv pyrefly + ruff ]; }; } diff --git a/scripts/sync-noctalia.py b/scripts/sync-noctalia.py new file mode 100755 index 0000000..6cff5dd --- /dev/null +++ b/scripts/sync-noctalia.py @@ -0,0 +1,488 @@ +#!/usr/bin/env python3 +"""Fold noctalia GUI overrides back into the declarative dotfiles config. + +This script folds `noctalia config export merged` back into the per-aspect +*.toml files so the repo alone reproduces the current effective config: + + * export values win (added if missing, updated if changed), + * repo-only keys and empty-table declarations are never deleted, + * f32 serialization noise (0.74999998696148396 vs 0.75) is ignored, + * floats are written back clean (0.8400000000000001 -> 0.84), + * comments (tombi directives) are preserved across the rewrite. + +Files are parsed with `tomli` and rewritten with `tomli_w`, then touched +files are re-formatted with `tombi` to match repo style. Note that `tomli_w` +renders every non-empty array on multiple lines and `tombi` preserves that +layout, so arrays in a folded file are reflowed from inline to multiline the +first time that file is touched. + +After a successful fold the GUI override file is moved to +/settings.bak.d/.toml (the 3 most recent are kept) and +the running instance is reloaded. + +Usage: + sync-noctalia.py # preview the fold; change nothing + sync-noctalia.py --apply # apply, backup settings.toml, reload + sync-noctalia.py --export-file # read the export from F instead of noctalia + sync-noctalia.py --config-dir # target a different config dir + sync-noctalia.py --state-dir # state dir holding settings.toml +""" + +import argparse +import math +import os +import re +import shutil +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from subprocess import CalledProcessError + +import tomli +import tomli_w + +REPO = Path(__file__).resolve().parent.parent +DEFAULT_CONFIG_DIR = REPO / "modules" / "desktop" / "wm" / "niri" / "noctalia" +DEFAULT_STATE_DIR = Path.home() / ".local" / "state" / "noctalia" +KEEP_BACKUPS = 3 +FLOAT_TOL = 1e-4 +FLOAT_PRECISION = 6 + + +def parse_export(export_file: Path | None) -> dict: + if export_file: + return tomli.loads(export_file.read_text()) + try: + proc = subprocess.run( + ["noctalia", "config", "export", "merged"], + capture_output=True, + text=True, + check=True, + ) + except FileNotFoundError: + sys.exit("error: `noctalia` not found on PATH") + except CalledProcessError as err: + sys.exit(f"error: `noctalia config export merged` failed:\n{err.stderr}") + return tomli.loads(proc.stdout) + + +def values_differ(a, b) -> bool: + """True if two values differ as config values (float-tolerant, recursive).""" + if isinstance(a, (int, float)) and isinstance(b, (int, float)): + return not math.isclose(float(a), float(b), rel_tol=0.0, abs_tol=FLOAT_TOL) + if isinstance(a, list) and isinstance(b, list): + return len(a) != len(b) or any(values_differ(x, y) for x, y in zip(a, b)) + if isinstance(a, dict) and isinstance(b, dict): + return set(a) != set(b) or any(values_differ(a[k], b[k]) for k in a) + return a != b + + +def collect_changes(repo_sub, exp_sub, prefix, changes): + """Recursively diff export subtree against repo subtree, appending + (path, old_value, new_value) tuples. New subtrees carry old_value=None + and a dict new_value.""" + for k, v in exp_sub.items(): + path = prefix + [k] + if isinstance(v, dict): + cur = repo_sub.get(k) + if isinstance(cur, dict): + collect_changes(cur, v, path, changes) + else: + changes.append((path, cur, v)) + else: + cur = repo_sub.get(k) + if cur is None or values_differ(cur, v): + changes.append((path, cur, v)) + + +def clean_value(v): + """Round floats to FLOAT_PRECISION places, recursively. + + tomli_w serialises floats with str(), which would keep f32 noise like + 0.74999998696148396 written by a float slider; rounding keeps written + values clean without losing precision we care about. + """ + if isinstance(v, float): + return round(v, FLOAT_PRECISION) + if isinstance(v, list): + return [clean_value(x) for x in v] + if isinstance(v, dict): + return {k: clean_value(x) for k, x in v.items()} + return v + + +# Preview rendering (display only; file I/O goes through tomli_w) + + +def _dotted(segments) -> str: + """Render segments as a TOML dotted path, quoting non-bare segments.""" + out = [] + for s in segments: + if not s or not all(c.isalnum() or c in "_-" for c in s): + out.append('"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"') + else: + out.append(s) + return ".".join(out) + + +def _clean_float(v: float) -> str: + s = f"{v:.6f}".rstrip("0").rstrip(".") + return s + ".0" if "." not in s else s + + +def serialize_value(v, indent: str = "") -> str: + if isinstance(v, bool): + return "true" if v else "false" + if isinstance(v, int): + return str(v) + if isinstance(v, float): + return _clean_float(v) + if isinstance(v, str): + return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"' + if isinstance(v, list): + return "[" + ", ".join(serialize_value(e, indent) for e in v) + "]" + if isinstance(v, dict): + inner = ", ".join( + f"{_dotted([k])} = {serialize_value(x, '')}" for k, x in v.items() + ) + return "{ " + inner + " }" + raise TypeError(f"unsupported TOML value: {v!r}") + + +def serialize_subtree(path, subtree) -> str: + """Serialize a dict as a TOML section block: headers followed by keys.""" + out = [] + + def walk(p, d): + out.append(f"[{_dotted(p)}]\n") + for k, v in d.items(): + if not isinstance(v, dict): + out.append(f"{k} = {serialize_value(v)}\n") + for k, v in d.items(): + if isinstance(v, dict): + walk(p + [k], v) + + walk(path, subtree) + return "".join(out) + + +# Comment preservation + + +def extract_comment_blocks(text: str) -> list[tuple[list[str], str | None]]: + """Contiguous comment lines, each paired with the table header that follows + them (or None for a trailing run). tomli_w drops comments when rewriting a + file, so they are re-anchored from the original text.""" + lines = text.splitlines() + runs = [] + i = 0 + while i < len(lines): + if lines[i].lstrip().startswith("#"): + j = i + while j < len(lines) and lines[j].lstrip().startswith("#"): + j += 1 + runs.append((i, j)) + i = j + else: + i += 1 + blocks = [] + for start, end in runs: + anchor = None + for k in range(end, len(lines)): + t = lines[k].strip() + if t.startswith("[") and t.endswith("]"): + anchor = t + break + blocks.append((lines[start:end], anchor)) + return blocks + + +def restore_comments(original_text: str, rendered_text: str) -> str: + """Re-insert the original file's comments into tomli_w's output, before the + table header each comment preceded (any stragglers go at EOF).""" + blocks = extract_comment_blocks(original_text) + if not blocks: + return rendered_text + pending = list(blocks) + out = [] + for line in rendered_text.splitlines(): + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + for block, anchor in list(pending): + if anchor == stripped: + out.extend(block) + pending.remove((block, anchor)) + out.append(line) + for block, _anchor in pending: + out.extend(block) + return "\n".join(out) + "\n" + + +# Dict-based edits + + +def apply_path(data: dict, path, value, *, as_table: bool, old): + """Set ``path`` to ``value`` in ``data``, creating intermediate tables. + + Leaf values are assigned in place (a new key appends at the end of its + table). Sub-tables are inserted at their sorted position among sibling + sub-tables, matching the repo's alphabetical `[section]` order; a scalar + being replaced by a table is dropped first. + """ + node = data + for seg in path[:-1]: + nxt = node.get(seg) + if not isinstance(nxt, dict): + nxt = {} + node[seg] = nxt + node = nxt + key = path[-1] + if as_table: + if old is not None: + node.pop(key, None) + items = list(node.items()) + pos = len(items) + for i, (k, v) in enumerate(items): + if isinstance(v, dict) and k > key: + pos = i + break + items.insert(pos, (key, value)) + node.clear() + node.update(items) + else: + node[key] = value + + +# Verification and state handling + + +def completeness_diffs(export, repo_after) -> list: + """Leaf paths where repo_after still differs from the export.""" + diffs = [] + + def walk(e, r, prefix): + for k, v in e.items(): + p = prefix + [k] + if isinstance(v, dict): + if not isinstance(r.get(k), dict): + diffs.append((p, None, v)) + else: + walk(v, r[k], p) + else: + cur = r.get(k) + if cur is None or values_differ(cur, v): + diffs.append((p, cur, v)) + + walk(export, repo_after, []) + return diffs + + +def run_validate(config_dir: Path) -> bool: + print(f" validate: noctalia config validate {config_dir}") + proc = subprocess.run( + ["noctalia", "config", "validate", str(config_dir)], + capture_output=True, + text=True, + check=False, + ) + out = proc.stdout + proc.stderr + for line in out.splitlines(): + print(f" {line}") + if "WARN" in out: + print( + " note: confirm none of the warnings above were newly introduced by this fold" + ) + return proc.returncode == 0 + + +def run_tombi(files) -> None: + for f in files: + proc = subprocess.run( + ["tombi", "format", str(f)], capture_output=True, text=True, check=False + ) + if proc.returncode != 0: + print(f" tombi format {f.name}: FAILED\n{proc.stdout}{proc.stderr}") + + +def backup_settings(state_dir: Path, keep: int = KEEP_BACKUPS): + src = state_dir / "settings.toml" + if not src.exists(): + return None + bak = state_dir / "settings.bak.d" + bak.mkdir(parents=True, exist_ok=True) + ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + dest = bak / f"{ts}.toml" + n = 1 + while dest.exists(): + dest = bak / f"{ts}-{n}.toml" + n += 1 + shutil.move(str(src), str(dest)) + # Prune oldest backups, counting only timestamp-shaped files we own. + backups = sorted( + f for f in bak.glob("*.toml") if re.fullmatch(r"\d{8}-\d{6}(?:-\d+)?", f.stem) + ) + for old in backups[:-keep]: + old.unlink() + return dest + + +def reload_instance(): + proc = subprocess.run( + ["noctalia", "msg", "config-reload"], + capture_output=True, + text=True, + timeout=15, + check=False, + ) + if proc.returncode == 0: + print(" reload: noctalia msg config-reload") + else: + print(" reload: noctalia not running or reload failed") + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument( + "--apply", action="store_true", help="apply the fold (default: preview only)" + ) + ap.add_argument( + "--config-dir", type=Path, default=DEFAULT_CONFIG_DIR, help=argparse.SUPPRESS + ) + ap.add_argument( + "--state-dir", type=Path, default=DEFAULT_STATE_DIR, help=argparse.SUPPRESS + ) + ap.add_argument("--export-file", type=Path, default=None, help=argparse.SUPPRESS) + args = ap.parse_args() + + config_dir = args.config_dir.resolve() + state_dir = args.state_dir + if not config_dir.is_dir(): + sys.exit(f"error: config dir not found: {config_dir}") + + if not args.export_file: + live = Path(os.path.realpath(Path.home() / ".config" / "noctalia")) + if live != config_dir: + print( + f"warning: ~/.config/noctalia is not linked to {config_dir} (found {live});" + ) + print(" the live export may describe a different config") + + export = parse_export(args.export_file) + + # Parse per-aspect files; map each top-level root to its owning file. + files = sorted(config_dir.glob("*.toml")) + texts = {} + parsed = {} + for f in files: + text = f.read_text() + texts[f] = text + parsed[f] = tomli.loads(text) + root_map = {} + for f, d in parsed.items(): + for root in d: + root_map.setdefault(root, f) + + # Collect every change the export implies relative to the repo. + changes = [] + new_files = {} + for root, subtree in export.items(): + if not isinstance(subtree, dict): + continue # do not fold top-level scalars + tgt = root_map.get(root) + if tgt is None: + new_files[root] = subtree + continue + cur = parsed[tgt].get(root) + collect_changes(cur if isinstance(cur, dict) else {}, subtree, [root], changes) + + sets_by_file, subs_by_file = {}, {} + for path, old, val in changes: + f = root_map[path[0]] + if isinstance(val, dict): # new subtree + subs_by_file.setdefault(f, []).append((path, old, val)) + else: + sets_by_file.setdefault(f, []).append((path, old, val)) + + if not changes: + print("No changes to fold. Repo config already matches the export.") + return + + # Preview + touched = set(sets_by_file) | set(subs_by_file) + for f in sorted(touched, key=str): + rel = os.path.relpath(f, REPO) + print(f"\n{rel}") + for path, old, val in sorted( + sets_by_file.get(f, []), key=lambda c: _dotted(c[0]) + ): + key = _dotted(path) + if old is None: + print(f" + {key} = {serialize_value(val)}") + else: + print(f" ~ {key}: {serialize_value(old)} -> {serialize_value(val)}") + for path, old, subtree in subs_by_file.get(f, []): + print(f" + [{_dotted(path)}]") + for line in serialize_subtree(path, subtree).splitlines(): + if line.strip(): + print(f" {line}") + for root in new_files: + print( + f"\n + new section root: {root} (new file {root.replace('_', '-')}.toml)" + ) + + if not args.apply: + print( + f"\n{len(changes)} change{'s' if len(changes) > 1 else ''} in {len(touched)} file(s)." + ) + print("Run with --apply to fold into the repo.") + return + + # Apply + for f in sorted(touched, key=str): + data = parsed[f] + for path, old, val in sets_by_file.get(f, []): + apply_path(data, path, clean_value(val), as_table=False, old=old) + for path, old, subtree in subs_by_file.get(f, []): + apply_path(data, path, clean_value(subtree), as_table=True, old=old) + f.write_text(restore_comments(texts[f], tomli_w.dumps(data))) + + new_paths = [] + for root, subtree in new_files.items(): + p = config_dir / (root.replace("_", "-") + ".toml") + p.write_text(tomli_w.dumps(clean_value({root: subtree}))) + new_paths.append(p) + files.append(p) + + print(" formatting changed files with tombi...") + run_tombi(sorted(touched, key=str) + new_paths) + + # Completeness: repo-as-loaded must now equal the export (within tolerance). + repo_after = {} + for f in files: + repo_after.update(tomli.loads(f.read_text())) + diffs = completeness_diffs(export, repo_after) + if diffs: + print("error: repo does not fully reproduce the export after applying:") + for p, old, val in diffs[:20]: + print(f" {_dotted(p)}: {serialize_value(old)} != {serialize_value(val)}") + sys.exit(1) + + if not run_validate(config_dir): + sys.exit("error: validation failed after applying changes") + + bak = backup_settings(state_dir) + if bak: + print(f" backed up {state_dir}/settings.toml -> {bak}") + reload_instance() + else: + # Should not reach this branch, since `len(changes) > 0`, GUI overrides must exist. + print("warning: settings.toml does not exist. Nothing backuped") + + print(f"\nDone. {len(changes)} change{'s' if len(changes) > 1 else ''} folded.") + + +if __name__ == "__main__": + main() diff --git a/treefmt.nix b/treefmt.nix index b75025e..f9d14ac 100644 --- a/treefmt.nix +++ b/treefmt.nix @@ -8,6 +8,7 @@ nixfmt.enable = true; keep-sorted.enable = true; stylua.enable = true; + ruff.enable = true; }; settings.formatter.tombi = { command = "${pkgs.bash}/bin/bash";