Bismillah

Mukadimah

Pada artikel kali ini, saya akan membahas mengenai Argument Parsing. Yakni adalah bagaimana cara agar setiap argumen yang diberikan pada suatu fungsi/script, bisa diinterpretasi sesuai posisinya (positional argument), atau sesuai ketentuan yang ditentukan nantinya.

Ini akan sangat bermanfaat jika Anda hendak membuat CLI tool dari bash script. Misalnya membuat tool seperti berikut:

./script.sh -u username -h host 
# or
./script.sh --username user --host hostname

Telah dibahas pada artikel yang telah lalu (Belajar Bash Scripting: Arguments) bahwa bash akan menginterpretasi setiap argumen yang diberikan dengan variabel $1, $2 dan seterusnya.

Positional Argument

Yang dimaksud dengan positional argument, secara umum adalah argumen yang ditentukan dari urutannya ($1 .. $n), dan secara khusus, yaitu argumen yang sudah ditetapkan tujuannya sesuai dengan urutan penyebutannya. Misalnya pada tool rsync:

rsync sourcefiles user@remote:/destination/

Bisa didapati bahwa argumen pertama merupakan spesifikasi untuk file sumber yang hendak ditransfer kemudian argumen terakhir adalah tujuannya, entah di lokal atau remote.

Sehingga, argumen pertama akan selalu menjadi sumber file dan argumen kedua akan selalu menjadi tujuannya. Jikalau dibalik, tidak bisa, destinasi tujuan didefinisikan pada argumen pertama dan seterusnya.

Atau pada utilitas mv atau cp, yang ketentuannya adalah jika diberikan 2 nama file, maka yang pertama menjadi sumber file dan yang kedua menjadi tujuan file (hasil salinan, atau hasil pindahan). Namun, jika diberikan lebih dari 2 file, maka argumen paling akhir akan menjadi tujuannya.

cp file1 file2 file3 target/
mv fileA fileB fileXYZ target/

Maka sudah menjadi ketentuan, bahwa file/folder yang disebutkan paling akhir, maka file/folder tersebut akan menjadi targetnya.

Inilah yang dimaksud dengan positional argument.

Optional Argument

Optional argument adalah argument yang tidak wajib untuk diberikan. Boleh diberikan dan boleh juga tidak. Ini berfungsi untuk mengubah sifat suatu tool berdasarkan flag/option yang diberikan. Misalkan pada rysnc:

rsync --dry-run --delete sourcefiles user@remote:/destination/

Lho, katanya argumen pertama itu untuk spesifikasi file/folder sumber dan argumen kedua untuk tujuannya? Bukankah pada contoh ini, argumen pertama ($1) adalah --dry-run? kemudian argumen kedua adalah --delete? dan yang ketiga dan keempat baru sumber dan tujuan?

Jawabannya, benar. Jika diruntut, memang terbaca seperti itu ($1 = '--dry-run' dan seterusnya). Ini karena argument parsing. Tiap argumen yang diberikan akan diproses satu-persatu menyesuaikan dengan pola argumen yang diberikan.

Tool rsync tersebut, diprogram sedemikian rupa sehingga ketika didapati ada argument yang berawalan -- atau - maka akan dianggap sebagai optional parameter, dan akan diproses berdasarkan string setelahnya. Misalnya pada contoh diatas, --dry-run maka rysnc akan berjalan pada mode dry run alias percobaan.

Argument Parsing

Argument parsing sebenarnya bisa dilakukan dengan built-in tool getopts. Namun, pada artikel ini saya hanya akan membahas argument parsing menggunakan while dan for loop yang dikombinasikan dengan kondisional case.

Contoh skenarionya adalah seperti ini:

./script.sh -a -b opt1 --xyz opt2 pos1 pos2

Maka akan didapatkan variabel $@ yang isinya:

-a -b opt1 --xyz opt2 pos1 pos2

dan $# yang bernilai 7.

💡

Variabel $# bernilai sejumlah dengan seluruh argumen yang diberikan.

Dengan demikian maka bisa diproses masing-masing argumennya dengan loop berikut:

#!/usr/bin/env bash

while [[ $# -ne 0 ]]; do
    case "$1" in
        -a)
            option_a="set"            
            shift
            ;;
        -b) 
            option_b="$2"
            shift 2
            ;;
        --xyz)
            option_xyz="$2" 
            shift 2
            ;;
        *)
            positional_arg+=( "$1" )
            shift
            ;;
    esac
done

printf '%s: %s\n' option_a "$option_a" option_b "$option_b" option_xyz "$option_xyz"
echo "Positional args: ${positional_arg[*]}"

Penjelasan

Bagian while [[ $# -ne 0 ]]; do akan melakukan loop selama $# tidak bernilai nol, alias selama masih ada argumen yang bisa diproses, pemrosesan akan terus berlanjut.

Kemudian case "$1" in akan mengecek argumen pertama dan akan mecocokkannya dengan case-case yang didefinisikan setelahnya. Kenapa hanya argumen pertama saja? Akan datang penjelasannya nanti.

Case-case yang dimaksud adalah seperti pada contoh, adalah -a, -b, --xyz dan *. Mari dibahas satu-persatu:

-a)
    option_a="set"            
    shift
    ;;

Nah, ketika $1 bernilai -a, maka kita akan definisikan variabel option_a dengan nilai set. Kemudian ada sintaks shift, yang berfungsi untuk menggser positional parameter yang diberikan. Maksudnya bagaimana?

Seperti yang sudah diketahui sebelumnya, bahwa pada contoh command sebelumnya, argumen-argumen yang diberikan adalah sebagai berikut:

-a -b opt1 --xyz opt2 pos1 pos2
$1 $2  $3    $4   $5   $6   $7

# $# = 7

Jika command shift dipanggil, maka penyematan argumen akan menjadi seperti ini:

-a -b opt1 --xyz opt2 pos1 pos2
   $1  $2    $3   $4   $5   $6

# $# = 6

Kemudian, jika shift dipanggil dan diberikan nilai padanya, maka pergeseran argumen akan dilakukan sebanyak nilai yang diberikan. Misalnya shift 2:

-a -b opt1 --xyz opt2 pos1 pos2
             $1   $2   $3   $4

# $# = 4

Karena positional argument-nya bergeser, maka jumlahnya ($#) juga berubah pula, sebagaimana pada contoh diatas.

Kembali ke pembahasan sebelumnya, karena kondisi $1 saat ini bernilai -a maka kita isikan variabel option_a dengan nilai set dan kita geser positional argumen selanjutnya menggunakan perintah shift. Sehingga kondisi $1 saat ini menjadi argumen setelahnya, tidak lagi -a.

Setelah shift diberlakukan, maka pengkondisian case selesai dan kembali kepada loop untuk melakukan iterasi berikutnya karena $# masih belum bernilai 0. Selanjutnya:

-b) 
    option_b="$2"
    shift 2
    ;;

dengan kondisi $@ yang saat ini adalah seperti berikut ini:

-b opt1 --xyz opt2 pos1 pos2
$1  $2    $3   $4   $5   $6

maka, karena $1 saat ini bernilai -b maka kita deklarasikan variabel option_b dengan memberikannya nilai sesuai dengan nilai variabel $2 yaitu opt1. Setelah itu kita geser kembali argumennya 2 kali. Sehingga, kondisi $@ saat ini menjadi:

--xyz opt2 pos1 pos2
  $1   $2   $3   $4

Untuk bagian dibawah ini, pembahasannya sama dengan -b:

--xyz)
    option_xyz="$2" 
    shift 2
    ;;

Kemudian yang terkahir adalah:

*)
    positional_arg+=( "$1" )
    shift
    ;;

Ini berarti, semua argumen yang bukan berupa option (diawali dengan -), maka akan teranggap sebagai positional argument dan akan dimasukkan ke array positional_arg yang akan diproses tersendiri nantinya.

Dengan demikian, output dari command berikut ini:

./script.sh -a -b opt1 --xyz opt2 pos1 pos2

adalah

option_a: set
option_b: opt1
option_xyz: opt2
Positional args: pos1 pos2

Parsing bentuk –opt=value dan -oValue

Setelah dijelaskan konsep dasar pemanfaatan WHILE loop dan CASE statement untuk argument parsing seperti pada contoh sebelumnya, saya akan jelaskan pula bagaimana cara untuk membuat parsing dengan model seperti ini:

./script.sh --opt1=value1 --flag1=value2 ...

Susunan WHILE loop sama seperti dengan contoh sebelumnya, namun bisa diperingkas seperti berikut agar mengurangi kedalaman indentasi:

#!/usr/bin/env bash

while [[ $# -ne 0 ]]; do case "$1" in
    --flag1=*) var_flag1="${1#*=}";;
    --opt1=*)  var_opt1="${1#*=}" ;;
    *) echo "Opsi tidak dikenali."; exit 1;;
esac; shift; done

printf '%s: %s\n' \
    'Flag1' "$var_flag1" \
    'Opt1' "$var_opt1"
💡

Sintaks shift pada contoh ini tidak lagi diletakkan pada tiap-tiap case yang diberikan karena itu redundan. Sehingga cukup diletakkan sekali saja setelah block case diakhir tiap loop.

Nah, pada contoh diatas, saya memanfaatkan suatu fitur dari Bash yaitu parameter expansion yang akan dibahas lebih detail pada artikel lain nantinya.

Parameter expansion yang dimanfaatkan adalah ${variabel#*PATTERN} yang fungsinya adalah menghapus seluruh karakter dari awal hingga bertemu PATTERN. Pada contoh diatas adalah ${1#*=}.

Anggap saja nilai $1 saat ini adalah --flag1=hehe, fokus pada parameter #*=, maka bisa diartikan dengan “menghapus seluruh karakter dari awal hingga bertemu dengan karakter = (inklusif)”. Sehingga hasilnya adalah hehe.

Kemudian, contoh selanjutnya adalah argument parsing semisal pada command mysql:

mysql -uroot -hhostname -p

Hampir sama dengan contoh sebelumnya, hanya berbeda pada sintaks parameter expansion saja dan pada klausa CASEnya:

#!/usr/bin/env bash

while [[ $# -ne 0 ]]; do case "$1" in
    -u*) username="${1#*-u}";;
    -h*) password="${1#*-h}";;
    -p)  prompt_password=1 ;;
    *) echo "Opsi tidak dikenali."; exit 1;;
esac; shift; done

printf '%s: %s\n' \
    'Username' "$username" \
    'Hostname' "$hostname"

if [[ -n "$prompt_password" ]]; then
    read -r -s -p "Kata sandi: " password
fi

Argument parsing dengan FOR loop

FOR loop juga bisa digunakan untuk melakukan argument parsing. Namun dengan batasan, bahwa argument parsing yang dilakukan tidak bisa berupa opsi/flag yang meminta value. Hanya bisa berupa opsi/flag yang bersifat toggle (alias, on/off) dan juga positional argument saja. Misal:

./script.sh --force --delete
#!/usr/bin/env bash

for arg in "$@"; do case "$arg" in
    --force)   force_enable=true ;;
    --delete) delete_enable=true ;;
    *) echo "Opsi tidak dikenali."; exit 1 ;;
esac; done

...

Pada model ini, kita memanfaatkan variabel $@ dan tidak perlu memanggil shift karena tiap iterasinya, FOR loop akan mengkonsumsi semua ekspansi $@ satu persatu. Bentuknya menjadi lebih ringkas namun kurang fleksibel.

Validasi

Tentunya, setiap pemberian opsi dan/atau positional argument perlu diberlakukan validasi terlebih dahulu. Misalnya script tidak memperbolehkan penggunaan flag/opsi yang sama lebih dari satu, atau jika opsi yang satu sudah diberikan maka opsi yang lain tidak boleh diberikan pula.

Strategi untuk mengatasi kondisi semacam itu beragam. Diantaranya bisa dengan misalnya opsi yang terakhir diberikan maka itu yang akan dipertimbangkan. Atau pemberian prioritas, atau bahkan script akan error jika bertemu kondisi-kondisi tersebut.

Hal ini bisa dilakukan langsung pada bagian kondisional CASE atau setelah argument parsing selesai.

Contohnya pada utilitas mv atau rm pada opsi -i dan -f-nya:

rm -i -f file2

Opsi -i berfungsi agar perintah rm memberikan prompt sebelum penghapusan file dilakukan. Sedangkan -f adalah opsi yang digunakan agar rm menghapus “paksa” suatu file tanpa menanyakan/memberitahukan terkait ada/tidaknya file yang dimaksud. Tentu keduanya bertentangan satu sama lain. Bagaimana rm menangani kondisi ini? Yaitu dengan memprioritaskan opsi yang paling akhir.

Sehingga bisa saja suatu script disusun seperti ini untuk meniru gaya argument parsing pada cp:

...
while [[ $# -ne 0 ]]; do
    case "$1" in
        ...
        -i) option="interactive"; shift ;;
        -f) option="force"; shift ;;
        ...
    esac
done
...

dengan demikian, variabel option akan memiliki nilai sesuai dengan opsi yang ditentukan paling akhir.

Kemudian, suatu script dimana script tersebut memerlukan variabel tertentu agar diset oleh user, namun jika tidak diberikan, variabel tersebut akan terisi dengan nilai default. Misalnya pada tool iptables. Tool tersebut memerlukan ditentukannya nama tabel agar bisa menampilkan semua rules-nya dengan flag -L.

iptables -t nat -L

Jika, opsti -t nat tidak diberikan, maka secara asal, iptables akan menampilkan rules pada tabel filter. Contoh pada script:

...
while [[ $# -ne 0 ]]; do
    case "$1" in
        ...
        -t) table="$2"; shift 2 ;;
        ...
    esac
done
...

if [[ -z "$table" ]]; then
    table=filter
fi

atau lebih sederhana, menggunakan parameter expansion:

...
case "$1" in
    ...
    -t) table="${2:-filter}"; shift 2 ;;
    ...
esac
...
☝️

Yang maknanya, jika variabel $2 itu UNSET atau bernilai kosong (empty string), maka isi variabel table dengan string filter.


Bagaimana dengan bentuk option/flag seperti contoh dibawah ini?

tar xvzf archive.tar.gz
# atau rsync
rsync -avznP source/ user@host:/dest/
# atau ps
ps aux
# atau ss/netstat
ss -ant
netstat -patlun

Saya serahkan pada pembaca untuk mencari tahu.

Semoga bermanfaat, barakallahufiikum.