Aus Linux-Magazin 09/2019

Go-Programm speichert Verzeichnispfade

© petar dojkic, 123RF

Wer auf der Kommandozeile das Verzeichnis wechselt, springt oft zwischen bekannten Pfaden hin und her. Mit einem in Go geschriebenen Utility protokolliert Mike Schilli die Sprünge mit und zeigt Rückwege auf.

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 Sourcecode abzufeuern. Dazu jedes Mal den Verzeichnispfad eintippen nervt schnell, zudem geht es meist nur zwischen einem halben Dutzend Pfaden hin und her – also das sollte sich die Kommandozeile doch merken können.

Die C-Shell hat dazu schon 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 Generierung 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).

Abbildung 1: Das Kommando »c« bietet zuletzt besuchte Verzeichnisse an …

Abbildung 1: Das Kommando »c« bietet zuletzt besuchte Verzeichnisse an …


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

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

Wächst die Liste mit Treffern über die eingestellte Grenze von fünf Einträgen hinaus, zeigt das 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, indem 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?

Per Anhalter am Prompt

Hat die Bash-Shell ein Kommando ausgeführt, generiert sie den Prompt, damit der User weiß, dass er wieder dran ist. Statt langweiliger »$« oder »#«-Zeichen definieren erfahrene Shell-User in der Variablen »$PS1« individuelle Prompts, die etwa den Usernamen, den Hostnamen und noch das gegenwärtige Verzeichnis anzeigen. So definiert 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 im Verzeichnis »git« also dies:

mybox.mschilli:git$

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

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

Diese Definition weist die Bash an, nach jedem ausgeführten Shellbefehl das Programm »cdbm« mit der Option »-add« aufzurufen. Dabei handelt es sich um das Go-Programm in Listing 1, das im »add«-Modus das gegenwärtige Verzeichnis ermittelt und den Pfad mit aktuellem Zeitstempel in einer Tabelle einer automatisch angelegten SQlite-Dateidatenbank ablegt. Falls der Pfad bereits existiert, frischt »cdbm« nur den Zeitstempel des Eintrags auf. Während der User also mit »cd« Verzeichnisse wechselt, sammeln sich in der Datenbank Pfade mit Zeitstempeln an (Abbildung 3).

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     "addition mode")
017   flag.Parse()
018
019   db, err :=
020     sql.Open("sqlite3", dbPath())
021   panicOnErr(err)
022   defer db.Close()
023
024   _, err = os.Stat(dbPath())
025   if os.IsNotExist(err) {
026     create(db)
027   }
028
029   dir, err := os.Getwd()
030   panicOnErr(err)
031
032   if *addMode {
033     dirInsert(db, dir)
034   } else {
035     items := dirList(db)
036     prompt := promptui.Select{
037       Label: "Pick a directory",
038       Items: items,
039     }
040
041     _, result, err := prompt.Run()
042     panicOnErr(err)
043
044     fmt.Fprintf(os.Stderr,
045       "%s\n", result)
046   }
047 }
048
049 func dirList(db *sql.DB) []string {
050   items := []string{}
051
052   rows, err := db.Query(`SELECT dir FROM
053     dirs ORDER BY date DESC LIMIT 10`)
054   panicOnErr(err)
055
056   usr, err := user.Current()
057   panicOnErr(err)
058
059   for rows.Next() {
060     var dir string
061     err = rows.Scan(&dir)
062     panicOnErr(err)
063     items = append(items, dir)
064   }
065
066   if len(items) == 0 {
067     items = append(items, usr.HomeDir)
068   } else if len(items) > 1 {
069     items = items[1:] // skip first
070   }
071
072   return items
073 }
074
075 func create(db *sql.DB) {
076   _, err := db.Exec(`CREATE TABLE dirs
077     (dir text, date text)`)
078   panicOnErr(err)
079
080   _, err = db.Exec(`CREATE UNIQUE INDEX
081     idx ON dirs (dir)`)
082   panicOnErr(err)
083 }
084
085 func dirInsert(db *sql.DB, dir string) {
086   stmt, err := db.Prepare(`REPLACE INTO
087     dirs(dir, date)
088     VALUES(?, datetime('now'))`)
089   panicOnErr(err)
090
091   _, err = stmt.Exec(dir)
092   panicOnErr(err)
093 }
094
095 func dbPath() string {
096   var dbFile = ".cdbm.db"
097
098   usr, err := user.Current()
099   panicOnErr(err)
100   return path.Join(usr.HomeDir, dbFile)
101 }
102
103 func panicOnErr(err error) {
104     if err != nil {
105     panic(err)
106     }
107 }

Freilich gibt »cdbm -add« nichts aus, 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.

Los geht’s

Zum Übersetzen von Listing 1 generiert die folgende Befehlsfolge im selben Verzeichnis ein neues Go-Modul, in dem startet dann der Buildprozess:

go mod init cdbm
go build

Listing 1 referenziert eine Reihe nützlicher Go-Pakete auf Github, die der Aufruf von »go build« wegen der vorangegangenen Moduldefinition automatisch als Sourcecode einholt und als Libraries kompiliert, bevor es ans Kompilieren von Listing 1 geht. Das entstehende Binary »cdbm« enthält alles, inklusive eines Treibers für das Anlegen und Abfragen von SQlite-Datenbanken.

Hat der User das Binary an eine Stelle kopiert, an der die Shell es im Suchpfad »$PATH« findet, muss er im Bash-Profile ».bashrc« zwei Dinge ändern, um in den Genuss des neuen Utility 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. Das 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.

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

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

Nun muss die Funktion den Inhalt von Stderr nur noch an die Shellfunktion »cd« übergeben, die in das angegebene Verzeichnis wechselt. Das ist allerdings einfacher gesagt als getan, denn »cd« ist kein Programm, sondern eine eingebaute Shellfunktion. 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.

Daher 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 File-Deskriptor »3« und nordet ihn auf denselben Kanal wie File-Deskriptor »1« ein, also Stdout. Die folgende Umleitung »1>&2« weist dem Deskriptor »1« einen neuen Wert zu und lässt ihn auf Deskriptor »2« zeigen, also Stderr. Die dritte – also »2>&3« – weist Stderr den Wert des temporär genutzten File-Deskriptors »3« zu, also das zwischengespeicherte Stdout. Damit schreibt das 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 Stdout und weist es der Variablen »$dir« zu. Die mit einem Semikolon abgetrennte »cd«-Anweisung zum Verzeichniswechsel liest den Wert aus der Variablen und springt in das angegebene Verzeichnis. Der einfache Weg, Stdout einzufangen, funktioniert nicht, weil das Terminal-UI sonst nicht auf dem Terminal erscheint und der User nicht interagieren kann.

Ans Eingemachte

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

Dazu definiert es 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 Kommandozeilen-Argumente 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 34 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.

Das 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, geben die Zeilen 44 und 45 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 am Vorhandensein der Datenbankdatei prüft, baut 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, doch 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).

Die funktioniert ähnlich wie »UPDATE«, legt aber fehlende Einträge neu an – doch 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 zehn, da aber die angezeigte Terminalliste beliebig nach unten scrollt, könnte sie auch entfallen.

Die For-Schleife ab Zeile 59 fängt 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 Slice »items«. Falls die Datenbank noch jungfräulich ist und keine Einträge enthält, fügt Zeile 67 das Homeverzeichnis 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 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 Homeverzeichnis.

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 einem 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 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 da alle Nutzdaten 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 1 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 lange scrollen müssen. Und, wer weiß, vielleicht lohnt es sich ja, ein paar Gramm künstliche Intelligenz zuzugeben, ein selbstlernender Verzeichnisbutler wäre wohl ein Hingucker für jüngere Kollegen. (uba)

Online PLUS

Im Screencast demonstriert Michael Schilli das Programmierbeispiel: http://www.linux-magazin.de/videos/

Der Autor

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 mailto:mschilli@perlmeister.com beantwortet er gerne Fragen.

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 4 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
2 Kommentare
Älteste
Neuste Beste Bewertung
Michael Espey
6 Jahre her

Vielen Dank für das nette Tool.

Ich habe es bei mir in ZSH eingebaut:

setopt PROMPT_SUBST
export PROMPT=’$(cdbm -add)’$PROMPT
function c() { dir=$(cdbm 3>&1 1>&2 2>&3); cd $dir; }

Ich habe auch ein öffentliches Repository mit configs und dem Binary, bin mir aber wegen der Lizenz (keine?) unschlüssig, ob ich das veröffentlichen darf.

mfg,
Micha

Micha Espey
6 Jahre her
Reply to  Michael Espey

So, ich habe Feedback von Mike bekommen, vielen Dank dafür.
Das Repo ist hier: https://git.24unix.net/tracer/Settings
Es sind einige nette defaults für vim und zsh dabei.
Und cdbm, der Quellcode, Binaries für macOS und Linux ADM64.
Ich bin auf ein kleines Problem gestossen, was in der Praxis für die wenigsten User relevant sein sollte, aber wenn der User keine Schreibrechte auf sein Homedir hat wirft cdbm eine unschöne Fehlermeldung. Mike hat mir einen Tipp gegeben, wie man das umschiffen kann, ich werde demnächst den Code dementsprechend anpassen, habe aber bis jetzt noch nie im leben eine Zeile in Go. geschrieben :)

Nach oben