Perkakas dari AST

LSP, formatter, dan linter yang memakai ulang front end compiler sebagai pustaka.

Daftar isi

Topik ini membahas perkakas pengembangan yang memakai ulang front end compiler sebagai pustaka, yaitu language server, formatter, dan linter. Pembaca memperoleh penjelasan bagaimana AST yang sudah dibangun pada modul parsing menjadi fondasi tiga kelompok perkakas, serta demo mini yang memetakan posisi kursor ke node AST untuk mensimulasikan fitur hover dan go-to-definition pada language server. Setelah membaca topik ini, pembaca dapat menjelaskan mengapa formatter dan linter modern memerlukan parser, menyebutkan konsekuensi pemakaian ulang front end compiler bagi perkakas pengembangan, dan membayangkan langkah menambahkan language server sederhana pada compiler proyek kursus.

Language server sebagai pemakaian ulang front end compiler#

Language server adalah program yang menjalankan fitur editor seperti pelengkapan otomatis, go-to-definition, dan diagnostik untuk satu bahasa pemrograman. Language Server Protocol (LSP) adalah protokol yang menetapkan bagaimana editor dan language server berkomunikasi melalui pesan JSON-RPC, sehingga satu language server dapat dipakai oleh banyak editor. Sebelum LSP, setiap editor menulis integrasi sendiri untuk setiap bahasa, sehingga duplikasi pekerjaan menjadi besar. Setelah LSP dirilis oleh Microsoft pada 2016 dan diadopsi luas, ekosistem language server berkembang pesat, termasuk rust-analyzer untuk Rust, clangd untuk C dan C++, serta pyright untuk Python.

Inti language server adalah parser dan AST dari front end compiler bahasa yang ditangani. Saat editor mengirim perintah go-to-definition pada posisi kursor tertentu, language server memproses berkas sumber menjadi AST, lalu mencari node AST pada posisi tersebut. Bila node adalah rujukan ke variabel, language server mencari deklarasi variabel tersebut pada tabel simbol dan mengembalikan posisinya kepada editor. Bila node adalah panggilan fungsi, language server mencari definisi fungsi pada lingkup yang sesuai. Pola yang sama berlaku untuk hover, pelengkapan, dan rename, yaitu parser membangun AST, lalu language server menjalankan operasi pada AST dan tabel simbol. Tanpa parser dan AST yang dapat dipakai ulang sebagai pustaka, language server harus menulis ulang front end bahasa, sehingga biaya pengembangannya menjadi besar.

Demo: pemetaan posisi ke node AST#

Demo berikut memodelkan bagaimana language server bekerja pada tingkat paling dasar, yaitu memetakan posisi kursor ke node AST. AST dibangun manual untuk program sumber tiga baris, lalu tiga kueri dijalankan. Kueri pertama adalah hover pada nama deklarasi, kueri kedua adalah hover disertai go-to-definition pada rujukan variabel, dan kueri ketiga adalah kueri yang sama pada rujukan berbeda. Tidak ada library LSP eksternal yang dipakai, sebab tujuan skrip adalah menunjukkan konsep struktur data internal yang dipakai LSP, bukan mengimplementasikan protokol LSP secara penuh.

python lsp_demo.py
"""Demo LSP sederhana: memetakan posisi (baris, kolom) ke node AST.

Skrip ini memodelkan bagaimana language server memakai parser dan AST
dari front end compiler sebagai pustaka. AST dibangun manual untuk
program sumber kecil tiga baris, lalu tiga kueri dijalankan untuk
mensimulasikan fitur language server, yaitu hover dan go-to-definition.
Tujuan skrip adalah menunjukkan konsep struktur data internal yang
dipakai LSP, bukan mengimplementasikan protokol LSP secara penuh.
Tidak ada library LSP eksternal yang dipakai.

Pemakaian:
    python3 lsp_demo.py
"""

from dataclasses import dataclass, field
from typing import Optional


@dataclass
class Node:
    """Satu node AST dengan rentang posisi (baris, kolom awal, kolom akhir).

    Kolom awal dan kolom akhir bersifat inklusif. Baris dimulai dari 1
    dan kolom dimulai dari 1, sebagaimana konvensi editor teks pada
    umumnya.
    """

    kind: str
    line: int
    col: int                      # kolom awal (inklusif)
    end_col: int                  # kolom akhir (inklusif)
    name: Optional[str] = None     # untuk VarRef, LetDecl
    value: Optional[int] = None    # untuk IntLit
    op: Optional[str] = None       # untuk BinOp
    name_col: Optional[int] = None  # posisi kolom nama untuk LetDecl
    children: list["Node"] = field(default_factory=list)


def build_ast() -> list[Node]:
    """Bangun AST manual dari sumber tiga baris.

    Sumber yang dimodelkan:
        baris 1: let x = 10
        baris 2: let y = x + 20
        baris 3: print x + y
    """
    # baris 1: let x = 10
    lit_10 = Node("IntLit", 1, 9, 10, value=10)
    let_x = Node("LetDecl", 1, 1, 10, name="x", name_col=5, children=[lit_10])
    # baris 2: let y = x + 20
    ref_x_in_y = Node("VarRef", 2, 9, 9, name="x")
    lit_20 = Node("IntLit", 2, 13, 14, value=20)
    binop_add = Node("BinOp", 2, 9, 14, op="+", children=[ref_x_in_y, lit_20])
    let_y = Node("LetDecl", 2, 1, 14, name="y", name_col=5, children=[binop_add])
    # baris 3: print x + y
    ref_x_in_print = Node("VarRef", 3, 7, 7, name="x")
    ref_y_in_print = Node("VarRef", 3, 11, 11, name="y")
    binop_print = Node(
        "BinOp", 3, 7, 11, op="+",
        children=[ref_x_in_print, ref_y_in_print],
    )
    print_stmt = Node("PrintStmt", 3, 1, 11, children=[binop_print])
    return [let_x, let_y, print_stmt]


def find_node(ast: list[Node], line: int, col: int) -> Optional[Node]:
    """Cari node terdalam yang rentangnya memuat (line, col).

    Bila beberapa node memuat posisi yang sama, node dengan rentang
    terkecil dipilih, sebab node terkecil adalah yang paling spesifik
    dan paling dekat ke token yang ditunjuk kursor.
    """
    best: Optional[Node] = None
    best_span = 0

    def walk(n: Node) -> None:
        nonlocal best, best_span
        if n.line == line and n.col <= col <= n.end_col:
            span = n.end_col - n.col
            if best is None or span < best_span:
                best = n
                best_span = span
        for c in n.children:
            walk(c)

    for top in ast:
        walk(top)
    return best


def describe(n: Node) -> str:
    """Deskripsi singkat untuk fitur hover."""
    if n.kind == "LetDecl":
        return f"deklarasi let binding '{n.name}'"
    if n.kind == "VarRef":
        return f"rujukan ke variabel '{n.name}'"
    if n.kind == "IntLit":
        return f"literal integer dengan nilai {n.value}"
    if n.kind == "BinOp":
        return f"ekspresi biner dengan operator '{n.op}'"
    if n.kind == "PrintStmt":
        return "pernyataan print"
    return n.kind


def find_decl(ast: list[Node], name: str) -> Optional[Node]:
    """Cari deklarasi LetDecl dengan nama tertentu pada lingkup atas."""
    for top in ast:
        if top.kind == "LetDecl" and top.name == name:
            return top
    return None


def query(
    ast: list[Node], label: str, line: int, col: int,
    want_def: bool = False,
) -> None:
    """Jalankan satu kueri LSP dan cetak hasilnya."""
    node = find_node(ast, line, col)
    print(f"--- {label} ---")
    print(f"posisi kueri  : baris {line}, kolom {col}")
    if node is None:
        print("node tidak ditemukan pada posisi ini")
        print()
        return
    print(
        f"node ditemukan: {node.kind} pada baris {node.line}, "
        f"kolom {node.col}-{node.end_col}"
    )
    print(f"hover         : {describe(node)}")
    if want_def and node.kind == "VarRef":
        decl = find_decl(ast, node.name)
        if decl is None:
            print(f"go-to-def     : deklarasi '{node.name}' tidak ditemukan")
        else:
            print(
                f"go-to-def     : '{node.name}' dideklarasikan pada "
                f"baris {decl.line}, kolom {decl.name_col}"
            )
    print()


def main() -> None:
    ast = build_ast()
    print("=== Sumber yang dimodelkan ===")
    print("baris 1: let x = 10")
    print("baris 2: let y = x + 20")
    print("baris 3: print x + y")
    print()
    print("=== Kueri LSP ===")
    query(ast, "hover pada nama deklarasi x", 1, 5)
    query(ast, "hover + go-to-def rujukan x di baris 2", 2, 9, want_def=True)
    query(ast, "hover + go-to-def rujukan y di baris 3", 3, 11, want_def=True)


if __name__ == "__main__":
    main()

Keluaran berikut diperoleh dengan menjalankan python3 lsp_demo.py pada sandbox dengan Python 3.12.

text Keluaran
=== Sumber yang dimodelkan ===
baris 1: let x = 10
baris 2: let y = x + 20
baris 3: print x + y

=== Kueri LSP ===
--- hover pada nama deklarasi x ---
posisi kueri  : baris 1, kolom 5
node ditemukan: LetDecl pada baris 1, kolom 1-10
hover         : deklarasi let binding 'x'

--- hover + go-to-def rujukan x di baris 2 ---
posisi kueri  : baris 2, kolom 9
node ditemukan: VarRef pada baris 2, kolom 9-9
hover         : rujukan ke variabel 'x'
go-to-def     : 'x' dideklarasikan pada baris 1, kolom 5

--- hover + go-to-def rujukan y di baris 3 ---
posisi kueri  : baris 3, kolom 11
node ditemukan: VarRef pada baris 3, kolom 11-11
hover         : rujukan ke variabel 'y'
go-to-def     : 'y' dideklarasikan pada baris 2, kolom 5

Kueri pertama memakai posisi (1, 5), yaitu posisi nama x pada deklarasi let x = 10. Fungsi find_node menelusuri AST dan memilih LetDecl sebagai node dengan rentang terkecil yang memuat posisi tersebut, sebab tidak ada node anak yang lebih spesifik pada kolom 5. Kueri kedua memakai posisi (2, 9), yaitu rujukan x pada ekspresi x + 20. Pencarian menemukan VarRef sebagai node terkecil, lalu fitur go-to-definition memakai tabel simbol untuk menemukan deklarasi x pada baris 1 kolom 5, yang persis posisi nama x pada deklarasi. Kueri ketiga memakai posisi (3, 11), yaitu rujukan y pada ekspresi print x + y, dan menemukan deklarasi y pada baris 2 kolom 5. Ketiga kueri menggambarkan pola yang sama, yaitu parser membangun AST, lalu operasi language server berjalan pada AST dan tabel simbol tanpa menyentuh kode generator atau back end compiler.

Formatter dan linter sebagai pemakaian ulang AST#

Formatter adalah perkakas yang memformat ulang kode sumber agar konsisten dengan gaya tertentu, tanpa mengubah makna program. Tiga contoh formatter produksi adalah gofmt untuk Go, rustfmt untuk Rust, dan prettier untuk JavaScript dan TypeScript. Cara kerja ketiganya sama, yaitu memproses sumber menjadi AST, lalu membangkitkan ulang teks dari AST sesuai aturan gaya. AST dipakai sebab formatter harus membedakan antara spasi yang signifikan dan spasi yang kosmetik. Misalnya, spasi di dalam string literal harus dipertahankan, sedangkan spasi di antara operator boleh diatur ulang. Pembedaan ini hanya mungkin bila formatter tahu mana yang string dan mana yang operator, dan pengetahuan tersebut berasal dari parser.

Linter adalah perkakas yang memeriksa kode sumber untuk menemukan pola yang sering menjadi sumber bug atau ketidakkonsistenan. Tiga contoh linter produksi adalah clippy untuk Rust, eslint untuk JavaScript, dan pylint untuk Python. Sebagian aturan linter bersifat sintaksis, misalnya melarang variabel yang tidak terpakai, dan dapat dijalankan langsung pada AST. Aturan lain bersifat semantik, misalnya melarang pemanggilan fungsi yang tidak ada, dan memerlukan tabel simbol yang dibangun pada analisis semantik. Pemakaian ulang front end compiler menjadikan linter dan formatter mengetahui struktur kode dengan presisi yang sama dengan compiler itu sendiri, sehingga aturan yang diperiksa tidak mengandalkan tebak-tebakan berbasis regex.

Kesalahan umum#

Kesalahan konseptual yang paling sering muncul adalah menganggap formatter dan linter bekerja dengan regex pada teks sumber. Pendekatan regex memang dipakai oleh beberapa perkakas lama, tetapi formatter dan linter modern memakai AST sebab teks sumber saja tidak cukup untuk membedakan token yang berbeda makna. Kesalahan kedua adalah menganggap language server adalah compiler utuh. Language server tidak menghasilkan kode native atau bytecode, melainkan hanya memakai ulang front end compiler, sehingga beban memori dan waktu startup dapat dijaga tetap rendah. Kesalahan ketiga adalah menyamakan language server dengan interpreter. Interpreter mengeksekusi program, sedangkan language server hanya membaca dan menganalisis kode tanpa menjalankannya.

Kesalahan keempat adalah menganggap LSP mengikat language server ke satu editor tertentu. Karena LSP adalah protokol yang netral terhadap editor, satu language server dapat dipakai oleh banyak editor, termasuk Visual Studio Code, Neovim, dan Emacs, tanpa perubahan kode. Pemisahan ini menjadi alasan utama adopsi LSP yang cepat sesudah 2016. Kesalahan kelima adalah menganggap setiap fitur editor memerlukan back end compiler. Sebagian besar fitur, termasuk pelengkapan dan hover, hanya memerlukan front end dan analisis semantik, sedangkan fitur rename yang menyentuh banyak berkas memerlukan tabel simbol lintas berkas tetapi tetap tidak memerlukan codegen.

Ringkasan#

  • Language server memakai parser dan AST dari front end compiler sebagai pustaka, sehingga fitur seperti hover dan go-to-definition berjalan pada struktur yang sama dengan compiler.
  • Demo lsp_demo.py menggambarkan pola dasar: pemetaan posisi kursor ke node AST terdalam, lalu pencarian deklarasi pada tabel simbol untuk fitur go-to-definition.
  • Formatter dan linter modern memakai AST, bukan regex, sebab pembedaan token yang berbeda makna hanya mungkin dilakukan setelah parser membangun struktur pohon.
  • LSP adalah protokol netral terhadap editor, sehingga satu language server dapat dipakai oleh banyak editor, dan integrasi baru tidak lagi ditulis per editor per bahasa.

Referensi#