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