Yksinkertainen snipettijärjestelmä ZSH:lle

Tuli tuossa sunnuntaina yks-kaks mieleen, että ZLE:n ansiosta ZSH:lle olisi aika helppoa kirjoittaa kunnollinen snipettijärjestelmä. Snipetithän ovat TextMatesta kaikkialle kopioitu fiksu systeemi, jolla aliakset ja tekstieditoreissa käytetyt "lyhenteet" (abbreviations) saadaan vuorovaikutteisiksi. Snipetit toimivat pääsääntöisesti siten, että tietyn aktivointitekstin kirjoitettuaan painetaan pikanäppäintä – aika usein tabia – ja aktivoiva teksti käännetään laajennettuun muotoonsa. Emacsissa on esimerkiksi paketti nimeltä yasnippets ja vimille on ainakin SnipMate ja modernimpi UltiSnips.

Komentoriveillä on näitä lyhenteitä ja erityisemmin aliaksia jo tarjolla, mutta ne eivät aina tarjoa parasta käytettävyyttä. Joskus aliakset puretaan auki vasta kun rivi on hyväksytty suoritettavaksi. Jos aliaksen laajennusta ei satu muistamaan, tai sitä haluaisi muokata pikkuisen, voi olla hieman haasteellista. (ZSH tosin saattaa tarjota tabista tehtävää laajennusta ns. inline-aliaksille, mutta tähän en jaksanut perehtyä.)

Koska simppeleitä ZLE-funktioita on tullut kirjoiteltua jo useita, oli pohjaratkaisu jo visualisoituna mielessä. Tällä snipettisysteemillä pystyn lisäksi siivoamaan muita interaktiivisia aliaksia pois konffeistani. Turhaa sotkua ja hankala muistaa kaikkia lisäksi. Koska shell-skriptaus on ikävää, ja ZSH-skriptaus vielä erityisen huonoa, päätin ulkoistaa ison työn pythonille. Tuloksena syntyi tämmöinen ulkoisesti funktionaalinen ilmestys:

#!/usr/bin/env python3
import sys

SNIPPETS = {
    'j': ('j ""',
          ('end-of-line', 'backward-char')),
    'ww': ('WatchNext;WatchNext',
           ('end-of-line',)),
    'wd': ('WatchNext -d',)
}

def main(argv):
    if not argv: return
    buffer = argv[-1].split(' ') # spaces intact
    evals = ''
    match = SNIPPETS.get(buffer[-1])
    if match:
        buffer[-1] = match[0]
        try:
            evals = ';'.join(match[1])
        except IndexError:
            pass
    buffer = ' '.join(buffer)
    print (buffer + "\0" + evals)

if __name__ == '__main__':
    main(sys.argv[1:])

Tämä skripti on sellaisenaan testattavissa komentoriviltä. Se odottaa yhtä argumenttia, komentokehotteen koko sisältöä. Simppelin ensivedoksen nimissä oletan, että snipettejä halutaan laajentaa vain rivin lopussa. Jatkoversio voisi ottaa myös kursorin paikan informaation talteen ja laajentaa snipettejä halutusta kohdasta.

Syötteet ovat siis kovakoodattuna tässä kuvauksessa SNIPPETS, ja kukin snipetti sisältää sekä laajennetun muotonsa että mahdollisia lisäkäskyjä komentoriville annettavaksi. Jos esimerkiksi komentoa end-of-line ei anna, kursori säilyy alkuperäisessä paikassaan. Ensimmäisessä esimerkissä käytän lisäksi komentoa backward-char, jotta kursori liikkuu automaagisesti laajennetun tekstin lainausmerkkien sisään. Käytettävyys!

Jatkokehittelyä voisivat olla kutsuttavat (callable) laajennukset, eli snipetin laajennus olisi dynaamista sisältöä funktiokutsun laskemana. Tällä tavalla saisi aikaan erään suosikkisnipettini vimistä ja emacsista: date laajenee tämänpäiväiseen ISO-muotoiltuun päivämäärään. Samalla tavalla voisi kirjoittaa snipetin nfile, joka laajenee hakemiston tuoreimman tiedoston nimeen.

Ja tämä skripti palauttaa kaksi arvoa takaisin: ensimmäinen on mahdollisesti muokattu versio komentokehotteen sisällöstä ja jälkimmäinen arvo on lista ZSH/ZLE-komentoja, jotka shelli saisi suorittaa makunsa mukaan käyttäjäkokemuksen maksimoimiseksi. Erotan nämä palautteet toisistaan erotinmerkeistä parhaimmalla, eli nollatavulla.

Tämä skripti on minulla tallennettuna omaan paikkaani kotihakemistossa. Ja sitten vielä lisätään sopiva koodi ZSH:n konfiguraatioihin:

function _expand_snippet() {
    IFS=$'\0' output=(`/home/progo/pika/__zsh_snippets.py "$BUFFER"`)
    BUFFER="$output[1]"
    if [[ -n "$output[2]" ]] ;then
        # extra commands to eval!
        eval "$output[2]"
    fi
}
zle -N expand_snippet _expand_snippet
bindkey "^E" expand_snippet

Funktio _expand_snippet on nyt ZLE-funktio, jossa toimii kaikki shell-skriptaus sellaisenaan. Erityisesti siellä on käytettävissä BUFFER-niminen muuttuja, jossa on komentokehotteen nykysisältö. Sitä saa vapaasti muokata ja muutokset näkyvät suoraan rivillä. Kutsun oitis pythonia ja otan vastaustavaran taulukkoon. Käytän tyylikkäästi eval-kutsua suorittaakseni lisäkomennot vähällä vaivalla. Nämä lisäkomennot voivat käytännössä olla mitä tahansa shell-koodausta, mutta ZLE-funktioissa saa myös käyttää ZLE-funktioita, kuten edellämainittuja kursorinliikekomentoja end-of-line, forward-char ja niin edespäin. Konsultoi näppäinkarttojasi saadaksesi selville täydet listat mahdollisista komennoista.

Funktio pitää rekisteröidä ZLE:n käytettäväksi ja sitten se mapataan johonkin näppiin. Vi-näppäimiä käyttävillä on kissanpäivät vapaiden yhdistelmien suhteen, ja minä laitoin roskan C-e:n taakse.

Ja ZSH:lla homma toimii. Bashillahan tämä ei onnistu lainkaan tällä keinoin, koska readline ei tarjoa tarpeeksi rajapintoja näin yksilöllistä koodia varten. Toivottavasti tästä on hyötyä ja herättää kiinnostusta ja toivoa komentoriviä kohtaan.


Kommentit, kehitysehdotukset ja keskustelunavaukset ovat tervetulleita sähköpostitse.