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. |
$PS1
-PromptJedes 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. |
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.
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.
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.
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.
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.
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.
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2019/09/snapshot/
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc