Verzeichnisbutler (Linux-Magazin, September 2019)

Während junge Kollegen ihre Programme mit ausgefuchsten IDEs editieren, finde ich es immer noch am natürlichsten, mit cd auf der Kommandozeile in lokale Git-Repositories zu springen und dort vim auf Dateien mit Source-Code abzufeuern. Dazu jedesmal den Verzeichnispfad einzutippen, nervt schnell, zudem geht es meist nur zwischen einem halben Dutzend Pfaden hin und her, das sollte sich die Kommandozeile doch merken können. Die C-Shell hat dazu vor vielen Jahren die Kommandos pushd und popd erfunden, aber wäre es nicht viel komfortabler, die besuchten Verzeichnisse automatisch zu erfassen, in einer Datenbank zu speichern und sogar Suchabfragen auf bisher angefahrene Verzeichnisse nach beliebigen Kriterien wie Häufigkeit oder Zeitstempel des letzten Besuchs anzubieten?

Das Go-Programm cdbm in diesem Programmier-Snapshot sammelt dazu während einer Shell-Session mittels cd angesteuerte Pfade des Users, indem es sich in die Generation des $PS1-Prompts der Shell einklinkt. Wechselt das Verzeichnis, bekommt cdbm das mit und legt den Pfad in einer SQLite-Datenbank auf der Platte ab, die später Suchabfragen erlaubt, deren Ergebnisse der User direkt anfahren kann. Dazu modifiziert der Bash-User seine .bashrc-Datei und bekommt dann mit dem Kommando "c" eine Auswahlliste mit zuletzt angefahrenen Verzeichnissen (Abbildung 1). Wählt er davon eines mit den Cursortasten aus und drückt Enter, springt die Shell direkt dorthin (Abbildung 2).

Wächst die Liste mit Treffern über die standardmäßig eingestellte Grenze von fünf Einträgen hinaus, zeigt die kleine Terminal-UI wie in Abbildung 1 zu sehen einen kleinen nach unten gerichteten Pfeil an und gibt damit zu verstehen, dass der User zu den weiter unten liegenden Einträgen kommt, in dem er den Cursor immer weiter nach unten bugsiert, bis er nach dem untersten angezeigten Eintrag die nächsten fünf Einträge holt und anzeigt. Wie funktioniert das nun?

Abbildung 1: Das Kommando "c" bietet zuletzt besuchte Verzeichnisse an ...

Abbildung 2: ... und springt in das vom User ausgewählte.

Per Anhalter mit dem $PS1-Prompt

Jedes Mal, wenn die Bash-Shell ein Kommando ausgeführt hat, generiert sie den Prompt, damit der User weiß, dass er wieder an der Reihe ist. Statt eines langweiligen "$" oder "#"-Zeichens definieren viele erfahrene Shell-User in der Variablen $PS1 luxuriösere Prompts, die etwa den Usernamen, den Hostnamen und vielleicht auch das gegenwärtige Verzeichnis anzeigen. So definiert zum Beispiel die Anweisung

    export PS1='\h.\u:\W$ '

einen Prompt mit dem Hostnamen (\h), einem trennenden Punkt, dem Usernamen (\u), einem trennenden Doppelpunkt, das aktuelle Verzeichnis, ein Dollarzeichen und ein Leerzeichen, auf meinem Rechner zuhause im Verzeichnis git also etwa dies:

    mybox.mschilli:git$

Nun versteht diese Prompt-Variable $PS1 nicht nur obige Kürzel, die sie durch aktuelle Werte ersetzt, sondern auch auszuführende Kommandos, deren Ausgabe sie in den Promptstring interpoliert:

    export PS1='$(cdbm -add)\h.\u:\W\$ '

Diese Definition veranlasst die Bash-Shell dazu, nach jedem ausgeführten Shell-Befehl das Programm cdbm mit der Option -add aufzurufen. Hierbei handelt es sich um das vorgestellte Go-Programm in Listing 1, das im add-Modus das gegenwärtige Verzeichnis ermittelt und den Pfad mit dem aktuellen Zeitstempel in einer Tabelle einer automatisch angelegten SQLite-Dateidatenbank ablegt. Falls der Pfad bereits existiert, der User also schon früher in diesem Verzeichnis zugange war, frischt cdbm lediglich den Zeitstempel des Eintrags auf. Während der User also mit cd munter Verzeichnisse wechselt, sammeln sich in der Datenbank Pfade mit Zeitstempeln an (Abbildung 3).

Ausgeben tut cdbm -add freilich nichts, sondern kehrt nach getaner Arbeit wortlos zurück, sodass der oben definierte $PS1-Prompt gleich bleibt, auch wenn die Bash-Shell während seiner Zusammenstellung heimlich den Verzeichnisbutler aufgerufen hat.

Abbildung 3: Die SQLite-Datenbank speichert zuletzt angefahrene Pfade mit Zeitstempel.

Los geht's

Zum Übersetzen von Listing 1 generiert die folgende Befehlsfolge im gleichen Verzeichnis ein neues Go-Modul, und in diesem startet dann der Build-Prozess:

    $ go mod init cdbm
    $ go build

Listing 1 referenziert eine Reihe von nützlichen Go-Paketen auf Github, die der Aufruf von go build wegen der vorangegangenen Moduldefinition automatisch als Source-Code einholt und als Libraries compiliert, bevor es ans compilieren von Listing 1 geht. Das entstehende Binary cdbm enthält alles, inklusive eines kompletten Treibers für das Anlegen und Abfragen von SQLite-Datenbanken. Nachdem das Binary an eine Stelle kopiert wurde, an der die Shell es im eingestellten Suchpfad $PATH findet, muss der User im Bash-Profile .bashrc zwei Dinge ändern, um in den Genuss der neuen Utilty zu kommen: Erstens die $PS1-Definition von oben übernehmen und zweitens eine Bash-Funktion c definieren, die cdbm im Auswahlmodus aufruft und den vom User herausgesuchten Pfad ausspuckt:

    export PS1='$(cdbm -add)\h.\u:\W\$ '
    function c() { dir=$(cdbm 3>&1 1>&2 2>&3); cd $dir; }

Tippt der User nach dem Ablauf von .bashrc (entweder automatisch beim Aufruf einer neuen Shell oder manuell mit . .bashrc) in der Shell dann c, ruft die Bash-Funktion oben das Programm cdbm auf. Dieses schreibt die Auswahlliste auf Stdout, der User interagiert damit mit den Cursortasten, wählt ein Verzeichnis mit Enter aus und cdbm schreibt das Resultat nach Stderr. Nun muss die Funktion den Inhalt von Stderr nur noch an die Shell-Funktion cd übergeben, die das Verzeichnis in das angegebene Verzeichnis wechselt. Das ist allerdings einfacher gesagt als getan, denn cd ist kein Programm, sondern eine eingebaute Shell-Funktion. Ein Programm könnte zwar sein eigenes Arbeitsverzeichnis wechseln, aber nicht das des Elternprozesses, der Shell selbst. Anders als Unix-Kommandos kann cd sein Argument, das Verzeichnis, aber nicht aus einer Pipe oder einer Datei einlesen.

Deshalb hilft sich die Bash-Funktion oben mit einem Trick: Nach dem Aufruf von cdbm vertauscht sie dessen Stdout und Stderr-Kanäle. Dazu definiert sie zunächst mit 3>&1 einen neuen File-Deskriptor 3 und nordet ihn auf denselben Kanal wie File-Deskriptor 1 ein, also Stdout. Die nächste Umleitung, 1>&2 weist dem File-Deskriptor 1 einen neuen Wert zu und lässt ihn auf File-Deskriptor 2 zeigen, also Stderr. Bleibt noch die dritte Zuweisung 2>&3, die Stderr den Wert des temporär genutzten File-Deskriptors 3 zuweist, also das zwischengespeicherte Stdout. Im Endeffeckt macht der Dreierpack also nichts anderes, als Stdout und Stderr zu vertauschen. Damit schreibt die Terminal-UI von cdbm nicht mehr auf Stdout, sondern auf Stderr, und das Ergebnis des ausgewählten Verzeichnisses kommt auf Stdout daher. Das Konstrukt dir=$(...) schnappt sich dann Stdout und weist es der Variablen $dir zu. Die mit einem Semicolon abgetrennte cd-Anweisung zum Wechseln des Verzeichnisses liest den Wert aus der Variablen und springt in das angegebene Verzeichnis. Ganz schön kompliziert, aber der einfache Weg, Stdout einzufangen, funktioniert nicht, da die Terminal-UI sonst nicht auf dem Terminal erscheint und der User damit nicht interagieren kann.

Ans Eingemachte

Das Programm cdbm.go in Listing 1 muss also nur zwei Dinge können: Mit der Option -add aufgerufen das gegenwärtige Arbeitsverzeichnis in der SQLite-Datenbank ablegen, und ohne die Option die Terminal-UI mit den SQLite-Einträgen präsentieren, den User eines auswählen lassen und das Ergebnis auf Stderr ausgeben.

Dazu definiert sie die Option -add mit Hilfe des Standardpakets flag in Zeile 15. Erfolgt der Aufruf von cdbm mit -add, führt der mit *addMode dereferenzierte Pointerwert nach dem Parsen der Kommandozeilenargumente mit flag.Parse() einen wahren Wert, und Zeile 33 verzweigt zur Funktion dirInsert() ab Zeile 85. Ist dies nicht der Fall, kommt der else-Zweig ab Zeile 35 dran, der mittels dirList() alle bislang in der SQLite-Datenbank abgelegten Pfade holt, und absteigend nach dem Datum sortiert, an dem sie eingefügt wurden.

Listing 1: cdbm.go

    001 package main
    002 
    003 import (
    004   "database/sql"
    005   "flag"
    006   "fmt"
    007   "github.com/manifoldco/promptui"
    008   _ "github.com/mattn/go-sqlite3"
    009   "os"
    010   "os/user"
    011   "path"
    012 )
    013 
    014 func main() {
    015   addMode := flag.Bool("add", false,
    016     "dir addition mode")
    017   addItem := flag.String("add-item", "",
    018     "item addition mode")
    019   dbname := flag.String("db-name", "",
    020     "db file name")
    021   flag.Parse()
    022 
    023   db, err :=
    024     sql.Open("sqlite3", dbPath(*dbname))
    025   panicOnErr(err)
    026   defer db.Close()
    027 
    028   _, err = os.Stat(dbPath(*dbname))
    029   if os.IsNotExist(err) {
    030     create(db)
    031   }
    032 
    033   dir, err := os.Getwd()
    034   panicOnErr(err)
    035 
    036   if *addMode {
    037     dirInsert(db, dir)
    038   } else if len(*addItem) > 0 {
    039     dirInsert(db, *addItem)
    040   } else {
    041     items := dirList(db)
    042     prompt := promptui.Select{
    043       Label: "Pick one item",
    044       Items: items,
    045       Size: 10,
    046     }
    047 
    048     _, result, err := prompt.Run()
    049     panicOnErr(err)
    050 
    051     fmt.Fprintf(os.Stderr,
    052       "%s\n", result)
    053   }
    054 }
    055 
    056 func dirList(db *sql.DB) []string {
    057   items := []string{}
    058 
    059   rows, err := db.Query(`SELECT dir FROM
    060     dirs ORDER BY date DESC LIMIT 10`)
    061   panicOnErr(err)
    062 
    063   usr, err := user.Current()
    064   panicOnErr(err)
    065 
    066   for rows.Next() {
    067     var dir string
    068     err = rows.Scan(&dir)
    069     panicOnErr(err)
    070     items = append(items, dir)
    071   }
    072 
    073   if len(items) == 0 {
    074     items = append(items, usr.HomeDir)
    075   } else if len(items) > 1 {
    076     //items = items[1:] // skip first
    077   }
    078 
    079   return items
    080 }
    081 
    082 func create(db *sql.DB) {
    083   _, err := db.Exec(`CREATE TABLE dirs 
    084     (dir text, date text)`)
    085   panicOnErr(err)
    086 
    087   _, err = db.Exec(`CREATE UNIQUE INDEX
    088     idx ON dirs (dir)`)
    089   panicOnErr(err)
    090 }
    091 
    092 func dirInsert(db *sql.DB, dir string) {
    093   stmt, err := db.Prepare(`REPLACE INTO
    094     dirs(dir, date)
    095     VALUES(?, datetime('now'))`)
    096   panicOnErr(err)
    097 
    098   _, err = stmt.Exec(dir)
    099   panicOnErr(err)
    100 }
    101 
    102 func dbPath(filename string) string {
    103   var dbFile = ".cdbm.db"
    104   if len(filename) != 0 {
    105       dbFile = filename
    106   }
    107 
    108   usr, err := user.Current()
    109   panicOnErr(err)
    110   return path.Join(usr.HomeDir, dbFile)
    111 }
    112 
    113 func panicOnErr(err error) {
    114     if err != nil {
    115 	panic(err)
    116     }
    117 }

Die Terminal-UI zur Auswahl eines Verzeichnisses zeichnet das Paket promptui und dessen Funktionen Select() und Run(). Das Resultat, den vom User ausgewählten Pfad als String, gibt Zeile 44 schließlich auf Stderr aus, worauf das Programm sich beendet.

Datenbank-Voodoo

Das Einfügen eines neuen Verzeichnisses in die Datenbank erledigt dirInsert() ab Zeile 85. Existiert die SQLite-Datenbank noch nicht, was Listing 1 einfach am Vorhandensein der Datebankdatei prüft, macht Zeile 76 eine neue und legt darin mit dem SQL-Kommando create eine frische Tabelle dirs an, deren zwei Spalten dir und date jeweils vom Typ text sind. Dass der Verzeichnispfad ein Textstring ist, überrascht nicht, allerdings speichert SQLite auch Datumsangaben als Text und vergleicht sie im String-Modus, was funktioniert, weil die Zeitstempel im Format "YYYY-MM-DD HH::MM::SS" vorliegen, spätere Zeitpunkte also auch alphanumerisch hinter früheren liegen.

SQLite soll bei bereits bestehenden Pfaden keine neue Tabellenzeile generieren, sondern einfach den Zeitstempel des existierenden Eintrags auffrischen. Das könnte man in SQL durch eine vorangestellte Select-Abfrage und folgender Bedingungslogik lösen, aber im SQLite-Dialekt geht das eleganter mit der Spezialfunktion replace (Zeile 86). Diese funktioniert so ähnlich wie UPDATE, legt aber fehlende Einträge neu an, aber nur, falls auf die entsprechende Tabellenspalte ein eindeutiger Index definiert ist. Darum fügt Zeile 80 nach der Tabellendefinition noch einen Index auf die Spalte dir ein, damit replace in Zeile 86 neue Einträge anlegt und alte auffrischt.

Vorhandene Datenbankeinträge holt die Funktion dirList() hervor. Die Select-Anweisung in Zeile 52 sortiert sie absteigend nach dem Einfügedatum, kurz vorher angelegte Einträge erscheinen also ganz oben in der Auswahlliste. Die Anweisung LIMIT 10 holt maximal 10, da aber die angezeigte Terminalliste beliebig nach unten scrollt, könnte sie auch entfallen. Die For-Schleife ab Zeile 59 holt mit rows.Next() und rows.Scan() die nächsten Treffer der Suchabfrage ein, die append-Anweisung in Zeile 63 hängt sie jeweils ans Ende des Array Slices items an. Falls die Datenbank noch jungfräulich ist und keinerlei Einträge enthält, fügt Zeile 67 das Home-Verzeichnis des Users ein, sonst wäre die angezeigte Auswahlliste leer und der User verwirrt.

Finden sich aber zwei oder mehr Treffer, mopst Zeile 69 den ersten und entfernt ihn aus der Liste, denn es handelt sich hier um den Eintrag des zuletzt besuchten, also des gegenwärtigen Verzeichnisses, in das der User ja wohl nicht springen wollen wird. Die Funktion dbPath() ab Zeile 95 gibt den Pfad zur SQLite-Datei an, in der die Daten liegen, im Listing hart kodiert als ~/.cdbm.db im Home-Verzeichnis.

Ganz schön wortreich

Es fällt auf, dass ein Go-Programm, das eigentlich doch gar nicht so viel Logik enthält, doch ganz schön viele Zeilen braucht. Schuld ist teilweise Gos unnachgiebig geforderte explizite Fehlerbehandlung jedes Rückgabewerts. Ein Exception-Handling wäre bei so einer einfachen Utility kompakter. Die Funktion panicOnErr ab Zeile 103, die einen ihr übergebenen Fehlerwert überprüft und sofort mit panic() das Programm abbricht, hilft, Zeilen zu sparen. Man munkelt, dass die nächste Version von Go hier Programmautoren mit kompakteren Mechanismen entgegenkommen wird.

Mehr Komfort

Für Bastler beginnt aber hier der Spaß erst. Erweitern ließe sich das Skript zum Beispiel noch um eine Suchfunktion, die nur Pfade zur Auswahl stellt, die auf einen auf der Kommandozeile eingegebenen Suchbegriff passen. So gäbe der Nutzer etwa "c usr" ein und bekäme nur Pfade zur Auswahl, die "usr" enthalten. Und nachdem die Nutzdaten alle in einer SQLite-Datenbank liegen, deren Schema sich leicht erweitern lässt, liegt es nahe, jedem gespeicherten Pfad einen Zähler zuzuordnen, den cdbm bei jedem Besuch eines Verzeichnisses um Eins erhöht. Damit könnten oft besuchte Pfade per Algorithmus höher in der Auswahlliste stehen, denn bei häufig genutzten Pfaden sollte der User nicht lang scrollen müssen. Und, wer weiß, vielleicht lohnt es sich ja, ein paar Gramm künstliche Intelligenz zuzugeben, ein selbstlernender Verzeichnisbutler wäre sicher der Hingucker bei den jüngeren Kollegen.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2019/09/snapshot/

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc