AWK: Mało znany język, który warto znać

Każdy, kto pracuje w powłoce *nix, prawdopodobnie widział gdzieś kawałki kodu w awk, a jednak chyba powoli język ten staje się zapomniany. Sam kiedyś myślałem, że to nadmiernie skomplikowany język do pisania dziwnych jednolinijkowców. Jednak później dowiedziałem się, że to bardzo ciekawy język, zarówno pod względem jego budowy, jak i pod względem historii oprogramowania.

w tym artykule:

  1. Do czego służy awk?
  2. Skąd się wziął
  3. Zasady działania AWK
  4. Funkcje
  5. Praktyczny przykład — raport z błędów GCC
  6. Wnioski

Do czego służy awk?

Krótko ujmując temat, awk jest językiem programowania szczególnego przeznaczenia, stworzonym do obróbki tekstu. Jeszcze konkretniej, jest przystosowany do obrabiania strumieni zapisów złożonych z pól. Na przykład:

~/tekst$ ls -lah
total 204K
drwxr-xr-x  8 lwik lwik 4,0K cze 30 17:47 .
drwxr-xr-x 38 lwik lwik 4,0K lip  1 10:40 ..
drwxr-xr-x  2 lwik lwik 4,0K cze 30 17:23 docs
drwxr-xr-x  8 lwik lwik 4,0K cze 30 17:23 .git
drwxr-xr-x  3 lwik lwik 4,0K cze 30 17:29 home
-rw-r--r--  1 lwik lwik 3,0K cze 30 17:55 index.html
-rw-r--r--  1 lwik lwik  356 cze 30 17:23 index.txt
drwxr-xr-x  4 lwik lwik 4,0K cze 30 17:47 it
-rw-r--r--  1 lwik lwik  147 cze 30 17:23 Makefile
-rwxr-xr-x  1 lwik lwik  127 cze 21 22:53 make.sh
drwxr-xr-x  2 lwik lwik 4,0K cze 21 15:51 nowe

Nie licząc pierwszej linijki wyjścia, pozostałe realizują taki wzór. Zapisy są oddzielane znakiem nowej linii, a pola, spacją. Podobny schemat zaobserwujemy wśród wielu innych programów w tym środowisku. Jeśli chcemy wybrać tylko niektóre informacje możemy napisać tak:

~/tekst$ ls -lah | awk 'NR > 1 { print substr($1, 0, 4), $3, $5, $9 }'
drwx lwik 4,0K .
drwx lwik 4,0K ..
drwx lwik 4,0K docs
drwx lwik 4,0K .git
drwx lwik 4,0K home
-rw- lwik 3,0K index.html
-rw- lwik 356 index.txt
drwx lwik 4,0K it
-rw- lwik 147 Makefile
-rwx lwik 127 make.sh
drwx lwik 4,0K nowe

Na początku wyrażenia mamy warunek (NR > 1, czyli od 2 zapisu), a potem, w klamerce, działanie… Do pól odwołuję się przez $1, $2, $3, itd. substr obcina łańcuch. Myślę, że to jest dosyć proste.

W awk możemy zdefiniować dowolną ilość takich reguł, dodatkowo są też możliwe reguły na rozpoczęcie i zakończenie pracy programu, dzięki czemu możemy sobie poradzić nawet w bardziej złożonych sytuacjach. Na przykład:

~/tekst$ git status | awk '/^Untracked/ {echo = 1;}
                /^\t/ && echo { print }'
    bak/
    home/
    index.html
    it/
    make.sh
    nowe/

Wybieram tylko nieśledzone pliki. W tym celu, wypisuję linie zaczynające się od tabulatora ale tylko jeśli zmienna echo jest ustawiona, a ustawiam ją dopiero napotykając linię Untracked files.

Skąd się wziął

Na początku był ed — pierwszy edytor tekstu w UNIX-ie. To jeszcze były te czasy gdy nie było monitorów, zamiast nich były drukarki (stąd, po dziś dzień, w wielu językach funkcja pisząca do strumienia to print lub coś podobnego). W związku z tym, taki edytor nie mógł pokazywać na bieżąco całej treści pliku. Dlatego operował na liniach i był raczej oszczędny w komunikatach. Przykładowa sesja wyglądała tak:

~/tekst$ ed -p "> " x.lisp
152
> 1,$p
(tagbody
    test (if (string= (read-line) "foo") (go foo) (go bar))
    foo (format t "this is foo~%") (go test)
    bar (format t "no fooo! Bye~%"))

(go foo)
> /bar
    test (if (string= (read-line) "foo") (go foo) (go bar))

    foo (format t "this is foo~%") (go test)
> s/foo\~%/bar\~%
    foo (format t "this is bar~%") (go test)
> /Bye
    bar (format t "no fooo! Bye~%"))
> s/ t / nil /
> p
    bar (format nil "no fooo! Bye~%"))
> 1,$p
(tagbody
    test (if (string= (read-line) "foo") (go foo) (go bar))
    foo (format t "this is bar~%") (go test)
    bar (format nil "no fooo! Bye~%"))

(go foo)

152 to rozmiar pliku; 1,$p — wypisz cały plik, p wypisz bieżącą linię; /bar — znajdź bar; s/foo/bar — zmień foo na bar w bieżącej linijce. Żeby dopisać po 5 linii, można było napisać 5a i od nowej linii pisać treść i zatwierdzić linią ..

Niektórzy z Was pewnie zauważą, że podobne komendy ma vim. Rzeczywiście, vim rozszerza ed o interfejs, kursor z dokładnością do znaku. Stąd wynika wiele nowych komend, język skryptowy. Z resztą, vim ma tryb ex, który działa jak ed. Taki tryb może wciąż być przydatny do pisania skryptów:

~/tekst$ cat > foo
foo
bar 
baz

~/tekst$ echo "2a
Hello World!

.
w
q" | ex foo

~/tekst$ cat foo
foo
bar
Hello World!

baz

Aby ułatwić sprawy powstały kolejne programy pochodne: grep i sed. Nazwa pierwszego pochodzi bezpośrednio od komendy g/re/p w ed, która wypisałaby wszystkie linie, które mają w sobie ciąg znaków re (od regex, wyrażenie regularne); to właśnie to polecenie robi. sed natomiast na ogół wykonuje jedno polecenie ed, z tą różnicą, że wynik (w postaci całego pliku) jest zapisywany do strumienia wyjścia (o ile nie ustawimy inaczej, np. przełącznik -i spowoduje zastosowanie zmian w samym pliku):

~/tekst$ nl x.lisp 
     1    (tagbody
     2        test (if (string= (read-line) "foo") (go foo) (go bar))
     3        foo (format t "this is foo~%") (go test)
     4        bar (format t "no fooo! Bye~%"))

     5    (go foo)

~/tekst$ sed '2,3s/^/foo/' x.lisp 
(tagbody
foo    test (if (string= (read-line) "foo") (go foo) (go bar))
foo    foo (format t "this is foo~%") (go test)
    bar (format t "no fooo! Bye~%"))

(go foo)

~/tekst$ sed -n '2,3s/^/foo/p' x.lisp 
foo    test (if (string= (read-line) "foo") (go foo) (go bar))
foo    foo (format t "this is foo~%") (go test)

W tym drugim przypadku, użyłem przełącznika -n, żeby wyłączyć automatyczne wypisywanie każdej linii, zaś w działaniu dodałem p, aby wypisał wybrane linie: 2 i 3.

Jednak i tego było mało. W końcu często na raz chcemy zrobić wiele przekształceń na jednym strumieniu. awk jest zatem bardzo naturalną kontynuacją. Mamy wzorce i reguły, co z nimi robić, wiele na raz. Mamy automatyczny podział na zapisy i pola. Dodatkowo dostajemy cały, dość ciekawy język programowania, który pozwala na wiele. Chociaż w dużej mierze zastąpił go później perl, przy całej sympatii dla tego języka, lubię składnię awk, bo jest dobrze skoncentrowana na problemie podczas gdy perl przyjmuje już formę języka ogólnego przeznaczenia.

Wydaje mi się, że to dość ciekawy wątek historii oprogramowania. Dobrze wiedzieć skąd pochodzą konstrukcje, których na co dzień używamy, jak ewoluowały i po co. Wtedy można lepiej zrozumieć czemu niektóre programy zostały zaprojektowane tak, a nie inaczej. Dodatkowo pokazuje, jak w środowisku *nix, przez długi czas utrzymywała się ciągłość pomysłów i konwencji. To, w rzeczy samej, dobra lekcja tworzenia oprogramowania.

Zasady działania AWK

awk jest dosyć prostym językiem. Praktycznie cały język jest opisany w podręczniku (man 1 awk), tam też odsyłam po dalsze szczegóły. Poniżej zaś przedstawiam najistotniejsze cechy. Wprowadzam również niektóre rozszerzenia implementacji GNU (gawk).

Zapisy, pola

Jak już wspomniałem, AWK widzi strumienie jako zapisy (ang. records) składających się z pól (fields). Zapisy i pola są oddzielane ciągami znaków przechowywanymi w zmiennych RS i FS. Ich domyślne wartości to RS="\n" i FS=" ", choć ten drugi zaakceptuje również znak tabulacji. Istnieją również analogiczne zmienne, o takich samych wartościach domyślnych, dla strumienia wyjściowego: ORS i OFS, są one używane w przypadku funkcji print (w odróżnieniu od printf):

~/tekst$ echo -e foo bar baz "\n" oof rab zab | \
        awk 'BEGIN{ ORS=" // "; OFS=", "}
            {print $1, $2, $3}
            END{printf "\n"}'
foo, bar, baz // oof, rab, zab //

Co ważne, operator , odpowiada za wstawienie separatora pól. W składni awk jest dozwolone umieszczenie kilku wartości pod rząd, wówczas zostaną połączone:

~/tekst$ echo -e foo bar baz "\n" oof rab zab | \
        awk 'BEGIN{ ORS=" // "; OFS=", "}
            {print $1 $2 $3}
            END{printf "\n"}'
foobarbaz // oofrabzab //

Warto zaznaczyć, że wartości tych zmiennych specjalnych mogą być zmienione w trakcie działania programu. Efekt będzie widoczny od następnego użycia.

Już teraz dochodzimy do punktu, w którym nasz program się robi trochę duży, jak na jednolinijkowca i w rzeczy samej żeby zrobić z tym językiem coś ciekawszego, warto jednak umieścić kod w pliku i uruchomić przy pomocy przełącznika -f. Wówczas istnieją szanse, że stworzymy coś funkcjonalnego i czytelnego za razem. Również warto zastosować przełącznik --lint/-L, który włączy wyświetlanie ostrzeżeń (w tym, związanych z funkcjami nieprzenośnymi na inne implementacje).

Reguły

Już zobaczyliśmy, że w źródle awk możemy zawrzeć dowolną ilość reguł. Reguły są rozpatrywane w takiej kolejności jak występują w kodzie. Zastosowanie jednej reguły nie wyklucza użycia innej, chyba że użyjemy operatora next:

~/tekst$ cat x.awk
/WARNING/ {
    print "warning caught in: " $0 "\n"
    next;
}

/^A: / {
    print "A, " $0 "\n"
}

/foo/ {
    print "foo in: " $0 "\n"
}

~/tekst$ awk --lint -f x.awk 
foo
foo in: foo

A: alohomora!
A, A: alohomora!

A: foo bar
A, A: foo bar
foo in: A: foo bar

A: to jest WARNING!
warning caught in: A: to jest WARNING!

Jak możecie się domyśleć, $0 zawiera zawsze tekst dopasowany jako bieżący zapis. Użyłem go by obejść możliwość występowania różnych ilości pól w zapisię. Oczywiście, możemy sobie z łatwością poradzić ze zmienną ilością pól. Zmienna NF przechowuje ich liczbę (można skojarzyć z NR — numer zapisu). Co ciekawe, możemy napisać tak:

~/tekst$ awk '{for(i = 1; i <= NF; i++) printf ">" $i " "; print ""}'
a be ce
>a >be >ce 
de ef gje
>de >ef >gje 

Zmienne, tablice, słowniki

W awk wszystkie zmienne są w pełni dynamiczne, globalne, nie wymagają wcześniejszej deklaracji, jeśli próbujemy użyć wartości zmiennej, której żadnej wcześniej nie przypisano, zostanie użyta wartość neutralna (pusty łańcuch lub 0). Typowanie przypomina nieco JavaScript. Wszystko domyślnie jest łańcuchem znaków, ale jeśli użyjemy operatora arytmetycznego to zostanie przekonwertowany na liczbę. Żadnego sprawdzania błędów, jeśli zmienna nie zawiera liczby — zwróci zero, "5s" -> 5, "s5" -> 0, itd. Aby sprawdzić czy zmienna ma przypisaną wartość (choćby ""), możemy napisać po prostu:

if(zmienna) print "jest!";

Aby zweryfikować czy zmienna zawiera typ liczbowy, możemy użyć wyrażenia regularnego:

if(match(zmienna, /^[0-9]+$/) > 0) print

W tym miejscu zwracam uwagę, że w awk, wyrażenia regularne są nieco skąpsze niż te z Perla, obecne w większości nowoczesnych języków. Na przykład nie ma w nich \d, \w ani bardziej zaawansowanych funkcji.

W awk, wszystkie tablice są asocjacyjne, również nie wymagają wcześniejszego deklarowania, nie mogą być zwracane słowem kluczowym return (można za to zwracać je przez parametr).

BEGIN {
    a[2] = 3;
    a[3] = 4;
    a[10] = 42;

    for(x in a) {
        printf ("%s -> %s\n", x, a[x]);
    }

    delete a[3];
    a[5] = "hello world!";

    for(x in a) {
        printf ("%s -> %s\n", x, a[x]);
    }
}

# 2 -> 3
# 3 -> 4
# 10 -> 42
# 2 -> 3
# 5 -> hello world!
# 10 -> 42

Niestety, jeśli spróbujemy sposobu sprawdzania istnienia zmiennej na nazwie z przypisaną tablicą, będzie to błąd (próba użycia tablicy w kontekście skalarnym). W gawk możemy to obejść używając funkcji isarray(), która zwróci 1 dla tablicy, a dla zmiennej bądź nazwy niezadeklarowanej 0. Możemy ewentualnie napisać następującą funkcję (lub pisać to krótkie wyrażenie za każdym razem).

function isdef(x) {
    return isarray(x) || x;
}

Uruchamianie z paramtrem

Często chcemy dać użytkownikowi możliwość podania parametrów działania programu. W awk możemy to zrobić przy pomocy parametru -v, który definiuje zmienną:

~/ $ awk -v 'foo=bar' 'BEGIN{print foo}'
foo

Funkcje

Mamy do dyspozycji kilka bardzo podstawowych funkcji, w tym pierwszą już wspomniałem:

match

match(string, regex[, arr])

Może posłużyć do dopasowania wyrażenia regularnego. Zwraca 0 jeśli dopasowanie nie powiedzie się lub numer znaku, w którym dopasowanie zaczyna się. Dodatkowo ustawia zmienne globalne RSTART i RLENGTH, które zawierają odpowiednio tę samą wartość i długość dopasowania. Trzeci parametr używany jest tylko jeśli chcemy przechwycić poszczególne składowe wyrażenia (rozszerzenie gawk), na przykład:

match($0, /^\s*([0-9]+):\s*(\w+)\s*$/, stuff) > 0 {
    printf ("'%s' @ %d\n", stuff[2], stuff[1]);
}

# wejście:
# 1: foo
#   2: barrr
# 8 : nanana
# fdglkdjf
# 42: baz

# wyjście:
# 'foo' @ 1
# 'barrr' @ 2
# 'baz' @ 42

sub, gsub i gensub

sub (regex, string[, target])
gsub (regex, string[, target])
gensub (regex, string, idx[, target]) # w gawk

We wszystkich przypadkach target ($0 jeśli nie podano), oznacza łańcuch źródłowy, regex poszukiwany wzorzec, a string wartość do podstawienia. sub zamieni tylko pierwsze wystąpienie, gsub zastąpi wszystkie. sub i gsub działają w miejscu (czyli zmieniają target), w odróżnieniu od gensub, który pozostawi target niezmieniony i tylko zwróci wynik po podstawieniu. Dodatkowo gensub ma dodatkowy parametr idx, który może przyjąć wartość g/G — wówczas wszystkie wystąpienia zostaną zastąpione — albo liczbę pozwalającą wybrać n-te dopasowanie. Dodatkowo, gensub pozwala użyć \\1, \\2, w string, aby użyć wartości przechwyconej nawiasem (tak jak w przykładzie match). Podobnie, \\0 i \\& wklejają całą dopasowaną wartość.

{
    x = gensub(/([a-z]+)\s*->\s*([0-9]+)/, "<\\1 oznacza \\2>", "g");
    printf ("'%s' -> '%s'\n\n", $0, x);
}

ff->4
'ff->4' -> '<ff oznacza 4>'

jeśli x->11 to b->42
'jeśli x->11 to b->42' -> 'jeśli <x oznacza 11> to <b oznacza 42>'

tak samo r->11 jest prawdą
'tak samo r->11 jest prawdą' -> 'tak samo <r oznacza 11> jest prawdą'

nic
'nic' -> 'nic'

index, length, substr

index (string, substring)
length (string)
substr (string, start[, len])

index szuka substring w łańcuchu string. Jeśli znajdzie, zwraca pozycję pierwszego wystąpienia, jeśli nie, 0. length zwraca długość łańcucha, w gawk może również podać rozmiar tablicy. Natomiast substr Zwraca podłańcuch string zaczynający się na pozycji start o maksymalnej długości len (jeśli podano). Nowoczesne implementacje, np. gawk, powinny radzić sobie z kodowaniem znaków i podawać długości i indeksy w znakach, a nie bajtach.

split, patsplit

split(string, array[, regex[, seps]])
patsplit(string, array[, regex[, seps]]) # w gawk

Obie funkcje rozdzielają string używając wyrażenia regularnego regex. Poszczególne elementy zostają zapisane w tablicy array, zaś, jeśli podano, w seps pojawią się części dopasowane jako separatory. Funkcje różnią się wartością domyślną regex, dla split jest to FS, a dla patsplitFPAT.

next, nextfile

Właściwie operatory, używane bez parametru. Pierwszy powoduje natychmiastowe wczytanie następnego zapisu. Natomiast, nextfile natychmiastowo przejdzie do następnego pliku. Następny zapis (jeśli istnieje) będzie przetwarzany od pierwszej reguły.

getline

getline
getline var
getline < file
getline var < file

getline, również operator, wczytuje linię ze strumienia (domyślnie bieżącego pliku). Możemy użyć operatora < aby podać plik. Możemy też, opcjonalnie podać nazwę zmiennej do której linia ma być wczytana. Użycie getline na bieżącym pliku wpływa zasadniczo na działanie programu, bo przesuwa pozycję w strumieniu wejściowym. Jeśli nie użyjemy next, a nadpiszemy $0 przy użyciu getline, kolejne reguły będą działać na tej nowej wartości. Jeśli wczytamy tę linię do innej zmiennej, kolejne reguły będą pracować na starym $0, a nowa linia więcej nie będzie wczytana. Dla ścisłości, kiedykolwiek nadpiszemy $0, zmiana się utrzyma na czas rozpatrywania kolejnych reguł.

print, printf, sprintf

print
print expr_list > file
print expr_list >> file
printf (format, values)
printf (format, values) > file
printf (format, values) >> file
sprintf (format, values)

Wszystkie warianty wypisują wyrażenia do pliku lub, w przypadku sprintf do wartości zwracanej przez tą funkcję. dla print domyślnym parametrem jest $0. W odróżnieniu od pozostałych, print stosuje zmienne OFS i ORS, aby ustalić, odpowiednio, jakim znakiem oddzielać poszczególne wyrażenia (w kodzie oddzielane przecinkiem, brak przecinka spowoduje połączenie łańcucha) i jaki znak wstawić na końcu. Operatory > i >> maja takie znaczenia jak w bashu, zapisz nadpisując starą treść i dopisz.

Pewnego rodzaju idiom stanowi pisanie do strumienia błędów:

print "idź do lasu!" > "/dev/sderr"

system i inne

Funkcja system pozwala wykonać komendę powłoki. Natomiast użycie tej funkcji można zablokować przełącznikiem --sandbox w linii poleceń dla awk. Jest też wiele innych funkcji, np. strtonum (łańcuch na liczbę, bez sprawdzania błędów), tolower i toupper (zmiana rozmiaru liter). Pozostałe można znaleźć w podręczniku (man awk).

Dyrektywa @include (poza regułami) pozwala załadować inny plik źródłowy — jest to rozszerzenie gawk. Podobnie, jako rozszerzenie gawk, można wywołać funkcję poprzez zmienną łańcuchową:

function foo() {
    print "abrakadabra"
}

BEGIN {
    m = "foo";
    @m();
}

#abrakadabra

Warto zaznaczyć, że jeśli mamy w programie tylko regułę BEGIN, zignoruje on strumień wejściowy i zakończy się wraz z końcem tej reguły.

Definiowanie funkcji

function nazwa ([par1[, …]) {
    #ciało
    return val #opcjonalnie
}

Taka definicja powinna znajdować się poza regułą. Podajemy dowolną ilość parametrów, nie jest ona w żaden sposób sprawdzana w czasię wywołania — jeśli będzie za mało, pozostałe będą miały wartość niezdefiniowaną — jeśli za dużo nie będzie można ich odczytać (choć dostaniemy ostrzeżenie w trybie --lint). W podręczniku proponuje się używać tej cechy by definiować zmienne lokalne, poprzez dodawanie zmiennych lokalnych w liście parametrów. :) Możemy zwrócić wartość, przy pomocy return. Jeśli go nie użyjemy, funkcja zwróci wartość niezdefiniowaną.

Praktyczny przykład — raport z błędów GCC

Pomimo, że jest to stosunkowo prosty język, może posłużyć szybkiemu rozwiązaniu różnych problemów. Zdarzyło mi się nawet kiedyś napisać w tym języku nakładkę na disassembler, podpowiadającą wskazywane zmienne, a nawet wartości rejestrów/komórek pamięci w danej instrukcji (w postaci symbolicznej, oczywiście). Innym razem, całkiem prosty język programowania, jako ćwiczenie 64-bitowego assemblera x86 (kto by tam pisał kod w assemblerze ręcznie?). Tu jednak skupię się na mniej złożonym przykładzie.

Kompilatory potrafią irytować ilością wypisywanego na wyjściu tekstu. A gdyby tak je pogrupować? Załóżmy, że używamy gcc, kompilujemy język C/C++ i chcemy zebrać wszystkie nieznalezione symbole (grupowane po nazwie, ze wszystkimi liniami w których występują), błędy składniowe, błędy dotyczące typów, i inne.

Dla łatwiejszej pracy, od razu napiszę kod, który posłuży nam do testów, żeby zobaczyć co zwróci kompilator:

#include <stdio.h>

int foo() {
    return bar;
}

doube z() {
    return bar + foo();
}

char *bar (int f) {
    return z;
}

inc main() {
    int a;
    float b = a + 2.5 + z;

    bar("zaza");
    printf("%f %d %lf\n", bar(b), a, b)
}}

kompiluję…

~/ $ gcc -Wall -pedantic -std=c99 x.c -o x
x.c: In function ‘foo’:
x.c:4:9: error: ‘bar’ undeclared (first use in this function); did you mean 
‘char’?
  return bar;
         ^~~
         char
x.c:4:9: note: each undeclared identifier is reported only once for each fun
ction it appears in
x.c: At top level:
x.c:7:1: error: unknown type name ‘doube’; did you mean ‘double’?
 doube z() {
 ^~~~~
 double
x.c: In function ‘z’:
x.c:8:9: error: ‘bar’ undeclared (first use in this function); did you mean 
‘char’?
  return bar + foo();
         ^~~
         char
x.c: In function ‘bar’:
x.c:12:9: warning: return from incompatible pointer type [-Wincompatible-poi
nter-types]
  return z;
         ^
x.c: At top level:
x.c:15:1: error: unknown type name ‘inc’; did you mean ‘int’?
 inc main() {
 ^~~
 int
x.c: In function ‘main’:
x.c:17:20: error: invalid operands to binary + (have ‘double’ and ‘int (*)()
’)
  float b = a + 2.5 + z;
            ~~~~~~~ ^
x.c:19:6: warning: passing argument 1 of ‘bar’ makes integer from pointer wi
thout a cast [-Wint-conversion]
  bar("zaza");
      ^~~~~~
x.c:11:7: note: expected ‘int’ but argument is of type ‘char *’
 char *bar (int f) {
       ^~~
x.c:20:11: warning: format ‘%f’ expects argument of type ‘double’, but argum
ent 2 has type ‘char *’ [-Wformat=]
  printf("%f %d %lf\n", bar(b), a, b)
          ~^            ~~~~~~
          %s
x.c:21:1: error: expected ‘;’ before ‘}’ token
 }}
 ^
x.c: At top level:
x.c:21:2: error: expected identifier or ‘(’ before ‘}’ token
 }}
  ^
x.c: In function ‘foo’:
x.c:5:1: warning: control reaches end of non-void function [-Wreturn-type]
 }
 ^
x.c: In function ‘z’:
x.c:9:1: warning: control reaches end of non-void function [-Wreturn-type]
 }
 ^

Dosyć prosty format. Mamy 3 typy linii. Pierwsze, mało interesujące — mówiące o miejscu kolejnych błędów (nazwa funkcji lub top level), druga przyporządkowująca błąd lub ostrzeżenie do danego miejsca w pliku, trzecia dająca dodatkowe informacje. Zacznijmy od tego by wypisać tylko drugi typ, z uwzględnieniem pierwszych.

function match_error_head (txt, params) {
    _ret = match(txt,
        /^([^:]+):([0-9]+):([0-9]+): ([^:]+): (.+)\s*$/,
        params \
    );
    if(_ret > 0) {
        params["file"] = params[1];
        params["line"] = params[2];
        params["col"] = params[3];
        params["type"] = params[4];
        params["msg"] = params[5];
        return 1;
    }
}	

match_error_head($0, desc) {
    printf ("%s(%d:%d @ %s): %s\n",
        desc["file"], desc["line"], desc["col"], where, desc["msg"] \
    );
}

match($0, /^([^:]+): In function ‘([a-zA-Z0-9_]+)’/, desc) > 0 {
    where = desc[2];
}

match($0, /^([^:]+): At top level/, desc) > 0 {
    where = "n/a";
}

i już jest bardziej kompaktowo:

x.c(4:9 @ foo): ‘bar’ undeclared (first use in this function); did you mean 
‘char’?
x.c(4:9 @ foo): each undeclared identifier is reported only once for each fu
nction it appears in
x.c(7:1 @ n/a): unknown type name ‘doube’; did you mean ‘double’?
x.c(8:9 @ z): ‘bar’ undeclared (first use in this function); did you mean ‘c
har’?
x.c(12:9 @ bar): return from incompatible pointer type [-Wincompatible-point
er-types]
x.c(15:1 @ n/a): unknown type name ‘inc’; did you mean ‘int’?
x.c(17:20 @ main): invalid operands to binary + (have ‘double’ and ‘int (*)(
)’)
x.c(19:6 @ main): passing argument 1 of ‘bar’ makes integer from pointer wit
hout a cast [-Wint-conversion]
x.c(11:7 @ main): expected ‘int’ but argument is of type ‘char *’
x.c(20:11 @ main): format ‘%f’ expects argument of type ‘double’, but argume
nt 2 has type ‘char *’ [-Wformat=]
x.c(21:1 @ main): expected ‘;’ before ‘}’ token
x.c(21:2 @ n/a): expected identifier or ‘(’ before ‘}’ token
x.c(5:1 @ foo): control reaches end of non-void function [-Wreturn-type]
x.c(9:1 @ z): control reaches end of non-void function [-Wreturn-type]

Zwracam uwagę na znak \ w match_error_head(). awk czasem tego wymaga gdy rozbijamy wyrażenie na kilka linii. Po przecinku nie jest to wymagane, ale na początku i końcu nawiasu już tak. Dwie ostatnie reguły raczej zostawimy w spokoju, natomiast w pierwszej rozpatrzymy kilka warunków. Po pierwsze, chcemy wyodrębnić brakujące return i niezdefiniowane symbole, bo one nie wymagają wiele wyjaśnień, można je lekko pogrupować. W tym celu definiuję funkcję:

function add_undef (symbol, line) {
    undefs[symbol] = undefs[symbol]
        sprintf("%s[%d,%s()] ", cfile, line, where);
    uds++;
}

Niezdefiniowane symbole zbieram w słowniku, dla symbolu przypisuję łańcuch znaków z lokacjami w których znaleziono taki błąd. Zliczam wystąpienia w uds (dla porządku inicjuję ją zerem w regule BEGIN). Dla brakujących return mam też tablicę. Aby je wychwycić, użyję funkcji:

function match_missing_ret (msg) {
    return match(msg, /control reaches end of non-void function/) > 0;
}

function match_undeclared (msg, _name) {
    if (match(msg, /‘([a-zA-Z0-9_]+)’ undeclared/, _name) > 0 \
        || match(msg, /unknown .* ‘([a-z0-9_]+)’;/, _name) > 0) {
        return _name[1];
    }
}

w głównej regule zaś:

if (match_missing_ret(desc["msg"])) {
    missing_ret[mis++] = where;
} else if (name = match_undeclared(desc["msg"])) {
    add_undef(name, desc["line"]);
}

i na końcu:

END {
    if(mis > 0) {
        printf ("missing returns in: ");
        for (mi in missing_ret)
            printf ("%s() ", missing_ret[mi]);
        printf ("\n\n");
    }

    if(uds > 0) {
        printf ("undefined symbols:\n");
        for (us in undefs) {
            printf ("\t%s: %s\n", us, undefs[us]);
        }
    }
}

Po prostu wypisywanie. Nic dodać nic ująć. Następnie doszedłem do wniosku, że dla niektórych błędów warto jednak zobaczyć całą linijkę. W tym celu wprowadzam jeszcze jeden słownik z błędami prostymi, czyli tymi, które takich objaśnień nie potrzebują:

function add_simple_error (msg, line) {
    simplerr[srs++] = sprintf("%s[%d,%s()]: %s",
        cfile, line, where, msg \
    );
}

#... w END:

    if(srs > 0) {
        for (sr in simplerr) {
            print simplerr[sr];
        }
    }

Wówczas dopisuję funkcję, która próbuje dopasować komunikat prostego błędu:

function match_simple_error (msg, _symbols) {
    if(match(msg,/-Wincompatible-pointer-types/) > 0) {
        return "wrong return pointer type";
    }

    if(match(msg, /expected (.*) before (.*) token/, _symbols) > 0) {
        return sprintf("expected %s before %s",
                _symbols[1], _symbols[2] \
            );
    }
}

Ostatecznie otrzymuję następujący kod:

BEGIN { mis = 0; uds = 0; srs = 0;}

function add_undef (symbol, line) {
    undefs[symbol] = undefs[symbol] \
        sprintf("%s[%d,%s()] ", cfile, line, where);
    uds++;
}

function add_simple_error (msg, line) {
    simplerr[srs++] = sprintf("%s[%d,%s()]: %s",
        cfile, line, where, msg \
    );
}

function match_error_head (txt, params, _ret) {
    _ret = match(txt,
        /^([^:]+):([0-9]+):([0-9]+): ([^:]+): (.+)\s*$/,
        params \
    );
    if(_ret > 0) {
        params["file"] = params[1];
        params["line"] = params[2];
        params["col"] = params[3];
        params["type"] = params[4];
        params["msg"] = params[5];
        return 1;
    }
}	

function match_missing_ret (msg) {
    return match(msg, /control reaches end of non-void function/) > 0;
}

function match_undeclared (msg, _name) {
    if (match(msg, /‘([a-zA-Z0-9_]+)’ undeclared/, _name) > 0 \
        || match(msg, /unknown .* ‘([a-z0-9_]+)’;/, _name) > 0) {
        return _name[1];
    }
}

function match_simple_error (msg, _symbols) {
    if(match(msg,/-Wincompatible-pointer-types/) > 0) {
        return "wrong return pointer type";
    }

    if(match(msg, /expected (.*) before (.*) token/, _symbols) > 0) {
        return sprintf("expected %s before %s",
                _symbols[1], _symbols[2] \
            );
    }
}

match_error_head($0, desc) {
    if (match_missing_ret(desc["msg"])) {
        missing_ret[mis++] = where;
    } else if (name = match_undeclared(desc["msg"])) {
        add_undef(name, desc["line"]);
    } else if (msg = match_simple_error(desc["msg"])) {
        add_simple_error(msg, desc["line"]);
    } else if (match(desc[5], /each undeclared/) == 0){
        printf ("%s(%d:%d @ %s): %s\n",
            desc["file"], desc["line"], desc["col"], where, desc["msg"] \
        );
        getline x;
        printf( "%s\n\n", x);
    }
}

match($0, /^([^:]+): In function ‘([a-zA-Z0-9_]+)’/, desc) > 0 {
    where = desc[2];
    cfile = desc[1];
}

match($0, /^([^:]+): At top level/, desc) > 0 {	
    where = "n/a";
    cfile = desc[1];
}

END {
    if(mis > 0) {
        printf ("missing returns in: ");
        for (mi in missing_ret)
            printf ("%s() ", missing_ret[mi]);
        printf ("\n\n");
    }

    if(uds > 0) {
        printf ("undefined symbols:\n");
        for (us in undefs) {
            printf ("\t%s: %s\n", us, undefs[us]);
        }
        printf ("\n");
    }

    if(srs > 0) {
        for (sr in simplerr) {
            print simplerr[sr];
        }
    }
}

Co dla naszego pliku testowego daje:

x.c(17:20 @ main): invalid operands to binary + (have ‘double’ and ‘int (*)(
)’)
  float b = a + 2.5 + z;

x.c(19:6 @ main): passing argument 1 of ‘bar’ makes integer from pointer wit
hout a cast [-Wint-conversion]
  bar("zaza");

x.c(11:7 @ main): expected ‘int’ but argument is of type ‘char *’
 char *bar (int f) {

x.c(20:11 @ main): format ‘%f’ expects argument of type ‘double’, but argume
nt 2 has type ‘char *’ [-Wformat=]
  printf("%f %d %lf\n", bar(b), a, b)

missing returns in: foo() z() 

undefined symbols:
    inc: x.c[15,n/a()] 
    doube: x.c[7,n/a()] 
    bar: x.c[4,foo()] x.c[8,z()] 

x.c[12,bar()]: wrong return pointer type
x.c[21,main()]: expected ‘;’ / ‘}’
x.c[21,n/a()]: expected identifier or ‘(’ / ‘}’

Moim zdaniem znacznie lepiej. Zajmuje znacznie mniej miejsca, łatwiej odnaleźć interesujące informacje, nie ma tyle szumu. Jeśli popełnię literówkę, wiem od razu czy w innych funkcjach nie popełniłem podobnego błędu. Poza tym, łatwe błędy na samym końcu, czyli to co prawdopodobnie zobaczę najpierw. Oczywiście mógłbym pójść dalej — połączyć komunikat z linii 19 z następnym, bo opisują to samo. W opisie błędu w formacie printf mógłbym usunąć sam komunikat, a za to wstawić następną linię, która wskazuje, który typ źle działa.

Poza tym, w prawdziwym projekcie zmiany prawdopodobnie poszłyby znacznie dalej, integracja z edytorem/IDE jest kluczowe, znacznie ułatwiałoby pracę gdyby każdy kompilator podawał błędy w takiej samej formie, zamiast zgadywać język i konfigurować format na tej podstawie. Możliwe, że zdecydowałbym się na edycję każdego kawałka interaktywnie i na bieżąco. Możliwe również by było automatyczne naprawianie niektórych błędów (zwłaszcza jeśli edytuję kod dobrze pokryty testami). Myślę jednak, że dla pokazania tego co umie awk tego już wystarczy.

Wnioski

awk to, moim zdaniem, przykład dobrze zaprojektowanego języka szczególnego przeznaczenia. Posiada składnię pozwalającą dobrze wyrazić problem. Daje wystarczające narzędzia by stosunkowo łatwo wyłuskać z tekstu to czego nam potrzeba. Oczywiście, powodzenia życzę, komuś, kto chciałby parsować nim np. JSONy, ale jednak dla wielu zastosowań powinien być wystarczający. Ze względu na to, że jest to język dedykowany małym, prostym rozwiązaniom, jestem w stanie nawet przymknąć oko na brak zmiennych lokalnych i wielu innych udogodnień, jakich spodziewałbym się w każdym współczesnym języku. Chociaż, gdyby nie rozszerzenia gawk, mogłoby być ciężko, bo miejscami zapewniają funkcje, które wydaja się być absolutną podstawą.

Z pewnością czymś ciekawym i użytecznym byłaby uwspółcześniona wersja awk, wspierająca programowanie funkcyjne i obiektowe, mogąca z łatwością poradzić sobie z XML i JSON.

Komentarze? Pytania? Sugestie? Współpraca? Napisz.