Zeilenwechsel mit sed ersetzen

Juli 2020
Der Streameditor sed ist ein Kommandozeilen-Tool. Er erhält seine Daten vorzugsweise von der Standardeingabe und leitet die Ergebnisdaten zur Standardausgabe. Wird gern zum automatischen Suchen und Ersetzen von Text verwendet.
sed wurde 1973/74 von Lee E. McMahon entwickelt.
Das Tool wurde zu einer Zeit entwickelt, als Hauptspeicherkapazitäten im Kilobytebereich(!) lagen. Jedes überflüssige Byte musste eingespart werden. Ganz klar, dass damals Entwickler von Tools ganz anders vorgehen mussten als wir heute. Daher funktionieren alte Tools in manchen Details nicht so wie erwartet.
Dieser Beitrag ist ziemlich speziell und hat das Ziel, grundlegende Features und die innere Arbeitsweise von sed kennenzulernen, indem wir eine Aufgabe mit sed lösen, die scheinbar nicht funktionieren will. Die Aufgabe kann man mit anderen Tools ganz einfach lösen, aber darum geht es hier nicht.

Das Problem

Der Zweck des sed-Kommandos im folgenden Listing ist leicht zu erraten: Ersetzen aller Zeilenwechsel (Zeilenenden) durch ein Komma. Damit könnte man z.B. aus einem mehrzeiligen Text eine CSV-Datei machen.
otto@DESKTOP:~
$ echo 'Hallo
> Welt' | sed 's/\n/,/g'
Hallo
Welt
Im Beispiel sollte also eine ähnliche Ausgabe wie Hallo,Welt herauskommen. Passiert aber nicht. Ein Fehler wird auch nicht angezeigt. Es ist auch kein Syntaxfehler drin, sed arbeitet bloß nicht so wie erwartet.

Grundlegende Arbeitsweise

Die Arbeitsweise von sed wird sehr ausführlich und vollständig erklärt in https://www.gnu.org/software/sed/manual/sed.html, hier nur folgendes Grundprinzip.
  • ohne besondere Optionen erwartet sed Daten von der Standardeingabe und sendet Ergebnisdaten zur Standardausgabe.
    sed liegt im Datenstrom, daher Streameditor.
  • sed liest genau eine Textzeile in einen Musterspeicher (pattern space) ein
    Jedes Zeilenwechselzeichen ist für sed Zeilenende.
    "Musterspeicher" kann m.E. verwirren, es wird einfach nur Text gespeichert und kein besonderes Muster.
  • Die Textzeile in diesem Speicher wird von einer sed-Kommandofolge, mindestens jedoch von einem sed-Kommando, bearbeitet.
  • Im Beispiel wird das sed-Kommando s verwendet, es sucht nach allen Zeilenwechselzeichen, die durch Komma ersetzt werden sollen.
      Kommandoname 
      |Trennzeichen an Ende des Kommandonamens
      ||        Trennzeichen, Ende Suchtext
      ||        |          Trennzeichen, Ende Ersatztext
      ||        |          | Flag beeinflusst Arbeitsweise des Kommandos
      ||        |          | |
      s/Suchtext/Ersatztext/[Flag]
    Das Flag g bedeutet: Jedes Vorkommen des Suchtextes ersetzen.
sed-Kommandoangaben immer in Singlequotes ('') setzen, um eine Veränderung durch die aktive Shell zu vermeiden.
Hier ein typisches Beispiel für die Verwendung von sed mit dem Kommando s
otto@DESKTOP:~
$ echo  "Hallo Welt" | sed 's/Welt/Universum/'
Hallo Universum

Kommando p (wie print)

Die bearbeitete Textzeile wird aus dem Musterspeicher automatisch über die Standardausgabe ausgegeben. Das nicht immer günstig. Das Kommando p ist gezielter einsetzbar, und die automatische Ausgabe kann man mit der Option -n abschalten.
otto@DESKTOP:~
$ echo "Hallo Welt" | sed -n 's/Welt/Universum/;p'
Hallo Universum
Mehrere Kommandos nacheinander werden mit Semikolon (;) getrennt.

Besonderheiten bei Ein- und Ausgabe der Textzeilen

Da sed nach jedem Zeilenwechsel mit dem Einlesen einer neuen Zeile beginnt, kann ein Zeilenwechsel nur am Ende des Musterspeichers vorkommen.
Lesen wir die Beschreibung von sed in der oben angegebenen Quelle, so finden wir unter Punkt 6.1. folgendes: ... sed reads one line from the input stream, removes any trailing newline, and places it in the pattern space.
Ja, und da haben wir die Erklärung, warum sed kein Zeilenende findet: Im Musterspeicher wird der Zeilentext ohne Zeilenwechselzeichen abgelegt.
Dann müsste ja ein mehrzeiliger Text nach Ausgabe wie eine einzige Zeile zusammenkleben. Jedoch wird ein mehrzeiliger Text auch mehrzeilig ausgegeben.
otto@DESKTOP:~
$ echo 'A
> B
> C' | sed -n 'p'
A
B
C
Lesen wir unter dem Punkt 6.1 weiter: ... the contents of pattern space are printed out to the output stream, adding back the trailing newline if it was removed.
Wurde also einer Eingabezeile das Newline-Zeichen entzogen, wird es bei der Ausgabe wieder hinzugefügt (Dazu gibt es noch eine Anmerkung, die spezielle Fälle regelt, was wir hier nicht vertiefen müssen).
Leider werden in vielen anderen Beschreibungen von sed diese Zusammenhänge nicht genannt.

Zeilen trennen

Versuchen wir mal das umgekehrte, nämlich ein Zeichen durch einen Zeilenwechsel zu ersetzen.
Wenn das gehen soll, müsste sed die Kombination \n als Zeilenwechsel interpretieren können.
otto@DESKTOP:~
$ echo 'Hallo, Welt, mein Universum!' | sed -n 's/,/\n/g;p'
Hallo
 Welt
 mein Universum!
Das also funktioniert ohne Probleme. Das g am Ende des sed-Kommandos ist ein Flag, das dafür sorgt, dass jedes Vorkommen des gesuchten Zeichens ersetzt wird.

Das Zusammenfügen von Zeilen

Um Zeilen zusammenzufügen findet man das Kommando N. Genauer gesagt: Es fügt an eine gelesene Zeile die nächste an. Einfach mal ausprobiert scheint es nicht zu funktionieren.
otto@DESKTOP:~
$ echo 'Hallo
> Welt' | sed -n 'N;p'
Hallo
Welt
Unter 3.2. in der Beschreibung finden wir Hinweise zum sed-Kommando N : Add a newline to the pattern space, then append the next line of input to the pattern space. ...
N kann also an eine gelesene Zeile die nächste Zeile anhängen, allerdings mit Zeilenwechsel dazwischen! Logisch, dass dann der Text zweizeilig ausgegeben wird.
Damit jedoch haben wir zum ersten Mal einen Text mit einem Zeilenwechsel im Musterspeicher!
Nun könnten wir mit dem Kommando s testen, ob sed diesen Zeilenwechsel findet und z.B. durch ein Komma ersetzen kann.
otto@DESKTOP:~
$ echo 'Hallo
> Welt' | sed -n 'N;s/\n/,/;p'
Hallo,Welt
Na bitte, geht doch! Allerdings ist damit noch nicht die Aufgabe gelöst, alle Zeilenwechsel eines Textes durch Komma zu ersetzen.

Die Lösung

Es gibt noch einen zweiten Speicher im sed, den Haltespeicher (Hold Space). Auch dieser ist einfach nur ein Textspeicher. Damit können Operationen mit mehreren Zeilen organisiert werden, doch schauen wir uns die Lösung für das obige Problem erst einmal an.
otto@DESKTOP:~
$ echo 'A
> B
> C
> D' | sed -n 'H;${x;s/\n/,/g;p};'
,A,B,C,D
Die sed-Befehlskette stammt aus einer sed-Script-Sammlung im Internet. Leider nicht wiedergefunden.
  • Wir sehen mehrere sed-Kommandos, nämlich H,x,s und p.
  • Das $-Zeichen an dieser Stelle ist eine sogenannte Adresse
    Es adressiert die letzte Zeile. Das heißt, das nachfolgende Kommando wird erst ausgeführt, wenn die letzte Zeile gelesen wurde.
  • Die Klammern {} fassen mehrere Befehle zu einer Gruppe zusammen.
    Weil davor das $-Zeichen steht, werden alle Befehle in den Klammern erst nach dem Lesen der letzten Zeile ausgeführt.
Das heißt, nur das Kommando H wird beim Lesen jeder Zeile ausgeführt. Es packt die gelesene Zeile mit einem führenden Zeilenwechsel in den Haltespeicher. Die Zeile wird dabei an den bisherigen Inhalt des Haltespeichers angehängt. Aus diese Weise landen am Ende alle Zeilen im Haltespeicher.
Am Ende des Einlesens wird zuerst das Kommando x ausgeführt. Haltespeicher und Musterspeicher tauschen dadurch ihre Inhalte. Jetzt sind die gesammelten Zeilen im Musterspeicher.
Das nachfolgende Kommando s ersetzt alle Zeilenwechsel durch Kommata und p gibt das Ergebnis aus. Mit einem zusätzlichen Kommando s vor der Ausgabe mit p entfernen wir das Komma am Anfang des Ergebnisses ...
otto@DESKTOP:~
$ echo 'A
> B
> C
> D' | sed -n 'H;${x;s/\n/,/g;s/,//;p};'
A,B,C,D
... und die Aufgabe ist gelöst.