Eine fish-Konfiguration nach nushell portieren: zwei Bugs, die sich nur in einer echten Shell zeigen
Ich nutze fish seit Jahren als tägliche Shell, mit einer chezmoi-verwalteten Konfiguration für das PATH-Setup, Tool-Integrationen (starship, zoxide, atuin, mise), ein paar Dutzend Abbreviations und eine Handvoll eigener Funktionen. Nushell stand daneben als unkonfiguriertes Gerüst, installiert, aber nie wirklich genutzt. Diese Woche habe ich es auf denselben Stand gebracht.
Es geht nicht darum, fish zu ersetzen. Ich greife für unterschiedliche Dinge zu den beiden Shells: fish bleibt meine interaktive Standard-Shell, und nu kommt zum Einsatz, wenn ich strukturierte Daten-Pipelines statt Text-Gefummel will. Was ich wollte, war, dass sich der Alltagskram identisch anfühlt, egal in welcher der beiden ich gerade sitze, damit mich das Wechseln nie einen Gedanken kostet. Mein starship-Prompt zeigt an, welche Shell aktiv ist, also ist der einzige sichtbare Unterschied ein Label und keine Änderung im Muskelgedächtnis.
Der mechanische Teil lief ungefähr so, wie man es erwartet: nu hat keine conf.d-Autoload-Konvention wie fish, also teilt sich die Konfiguration auf eine Handvoll explizit eingebundener Dateien auf statt auf eine Datei pro Thema. Manches lässt sich direkt übersetzen (alias/def für fishs abbr). Manches lässt sich gar nicht übersetzen und brauchte einen komplett anderen Mechanismus, allen voran cd: fishs Variante springt per zoxide fuzzy zum Ziel und gibt danach ein Listing aus, und nu hat schlicht keine Möglichkeit, cd zu überschreiben und trotzdem noch das echte Builtin aufzurufen, sobald man es überschattet hat. Die Lösung war, cd gar nicht mehr überschreiben zu wollen und stattdessen $env.config.hooks.env_change.PWD zu hooken, was bei jedem Verzeichniswechsel feuert (cd, z, cd -) statt bei einer bestimmten überschriebenen Funktion. Das ist wohl ohnehin das bessere Design.
Nichts davon ist allerdings der interessante Teil. Der interessante Teil ist das, was sich erst zeigte, als ich aufhörte, mit nu -c "..." zu testen, und in einer echten interaktiven Session testete.
Lektion eins: nu -c lädt config.nu nicht
Ich hatte jede Änderung mit Befehlen wie diesem verifiziert:
nu -c "print (which ll | length)"
Das funktioniert einwandfrei, wenn man explizite --config/--env-config-Flags übergibt, was ich während des anfänglichen Aufbaus getan hatte. Aber schlichtes nu -c "..." ohne diese Flags lädt nur env.nu. Es lädt config.nu standardmäßig nicht. env.nu setzt Umgebungsvariablen ($env.EDITOR, PATH-Einträge), also gingen diese Checks weiterhin durch. config.nu ist der Ort, an dem eigene Befehle, Aliases und der Prompt leben, also blieb alles, was davon abhing, die ganze Zeit still ungetestet, während ich dachte, ich würde es testen.
Der Fehlerfall, als das live ging, war genau das, was man erwarten würde: ll nicht gefunden, falscher Prompt, überhaupt keine eigenen Befehle. Sobald ich für die Verifikation auf nu -i (interaktiver Modus) umstieg, tauchten beide verbleibenden Bugs unten sofort auf.
Lektion zwei: nu löst Konfigurationspfade pro OS auf, nicht nur pro XDG-Spec
Fish liest immer ~/.config/fish, punktum, auf jeder Plattform. Nushell arbeitet nicht so. Auf macOS greift nu ohne ein explizites XDG_CONFIG_HOME standardmäßig auf ~/Library/Application Support/nushell/ für seine Konfiguration zurück, nicht auf ~/.config/nushell/. Mein chezmoi-Setup deployt alles nach ~/.config/nushell (der XDG-konventionelle Ort, passend zu fish, passend zum Rest der Dotfiles), also las nu still eine völlig andere, automatisch generierte Standard-Konfiguration, die dort seit dem allerersten Mal lag, als nu ohne eine echte Konfiguration lief.
Zwei Wege, das zu beheben: XDG_CONFIG_HOME auf Session-Ebene exportieren, sodass jedes XDG-fähige Tool im System der Konvention folgt, oder eng nur nu umleiten. Ich habe mich für die enge Lösung entschieden, einen chezmoi-verwalteten Symlink:
~/Library/Application Support/nushell -> ~/.config/nushell
nur für darwin, da ~/Library unter Linux nicht existiert und nu dort schon ohne Hilfe das XDG-konventionelle Verhalten zeigt. Ein netter Nebeneffekt: nus data-dir und config-dir stellten sich auf macOS als derselbe Pfad heraus, also deckt dieser eine Symlink auch das Verzeichnis vendor/autoload ab, das nu für automatisch geladene Completions nutzt, nicht nur config.nu selbst.
Lektion drei: Closures lösen Namen zur Parse-Zeit auf, nicht zur Aufrufzeit
Diese hier war subtiler. config.nu setzt $env.config.hooks.env_change.PWD auf eine Closure, die einen eigenen Befehl aufruft, und setzt separat den externen Completer auf eine Closure, die einen has-cmd-Helper aufruft. Beide Befehle stammen aus Dateien, die weiter unten im selben Skript mit use importiert werden. Meine Annahme war, dass eine Closure, die ja ein zur Laufzeit zugewiesener Wert ist, die Namen in ihrem Rumpf dann auflösen würde, wenn die Closure tatsächlich läuft, also dynamisches Scoping. Das ist für nu falsch: Befehlsnamen-Referenzen innerhalb eines Closure-Literals werden an dem Punkt aufgelöst, an dem die Closure geparst wird, mit allem, was an dieser Stelle in der Datei lexikalisch im Scope ist. Es spielt keine Rolle, dass die Closure erst viel später aufgerufen wird, lange nachdem die Imports ausgeführt wurden. Wenn die use-Anweisung im Quelltext nach dem Closure-Literal kommt, kann die Closure sie nicht sehen.
Der Fehler ist eindeutig, sobald man in einer echten Session darauf stößt:
Error: nu::shell::external_command
× External command failed
Command `on-directory-change` not found
help: A command with that name exists in module `commands`. Try importing it with `use`
Die Lösung war mechanisch, sobald sie diagnostiziert war: die use-Anweisungen ganz an den Anfang von config.nu verschieben, vor alles, was sie in einem Closure-Rumpf referenzieren könnte.
Fazit
Jede dieser drei Lektionen läuft auf dasselbe hinaus: Die Semantik des Konfigurations-Ladens einer Shell lässt sich nicht vollständig verifizieren, indem man Snippets durch -c jagt. Man muss das Ganze so laden, wie es ein echtes Terminal täte. Ich hatte diesen Port über den Aufbau hinweg ziemlich gründlich getestet und trotzdem eine Konfiguration ausgeliefert, die beim ersten echten Einsatz scheiterte, zweimal, aus Gründen, die nichts mit der eigentlichen fish-zu-nu-Übersetzungsarbeit zu tun hatten und alles mit Annahmen darüber, wie nu selbst Dinge lädt und scoped.