Tập 22: Khi `duy` trả lời trên terminal

Có một khoảnh khắc vô cùng nhỏ bé nhưng lại là cửa ải bắt buộc mà mọi dự án ngôn ngữ lập trình đều phải bước qua: lập trình viên gõ tên lệnh của ngôn ngữ vào terminal và nín thở chờ xem máy tính sẽ phản hồi điều gì.

Trước khoảnh khắc ấy, DUY chỉ là tên gọi của một ý tưởng lãng mạn, một thư mục trống và vài trang tài liệu hứa hẹn. Sau khoảnh khắc ấy, nó chính thức bắt đầu có tiếng nói vật lý trên máy tính. Tiếng nói đầu tiên chưa hề uy nghi hay hào nhoáng. Nó tự giới thiệu ngắn gọn là DUY compiler v0, in ra một danh sách các lệnh hỗ trợ và đôi khi văng lỗi vì người dùng quên không truyền đường dẫn file đầu vào. Nhưng ít nhất, máy tính đã phản hồi lại.

File chạy CLI bin/duy bắt đầu bằng một dòng shebang quen thuộc:

#!/usr/bin/env node

Đây là bằng chứng thành thật về trạng thái kỹ thuật thực tế của DUY lúc này. DUY chưa hề tự chạy compiler độc lập của mình. Node.js vẫn đang làm host chạy ngầm phía dưới. JavaScript thực hiện nhiệm vụ đọc các tham số dòng lệnh (arguments), mở tệp nguồn, gọi lõi compiler, ghi tệp đầu ra và spawn tiến trình clang để dịch mã máy. Nếu chỉ nhìn vào tầm nhìn độc lập dài hạn của một ngôn ngữ hệ thống, kiến trúc bootstrap này chưa đẹp mắt. Nhưng nếu nhìn dưới góc độ kỹ thuật thực tế, nó cực kỳ hữu dụng: toàn bộ đường ống biên dịch (pipeline) được nối thông từ rất sớm để có thể liên tục kiểm tra và xác thực.

Bộ lệnh CLI đầu tiên phân rã các nhu cầu thành các command:

duy build <file.duy>       -> Biên dịch ra file thực thi nhị phân gốc
duy run <file.duy>         -> Biên dịch và chạy trực tiếp kết quả
duy check <file.duy>       -> Chỉ chạy phân tích tĩnh kiểm tra lỗi ngữ nghĩa
duy emit-ir <file.duy>     -> Xuất mã nguồn biểu diễn trung gian nội bộ (IR)
duy emit-c <file.duy>      -> Xuất mã nguồn C trung gian để debug
duy emit-llvm <file.duy>   -> Xuất mã nguồn LLVM IR

Mỗi lệnh dòng lệnh ấy là một ô cửa kính nhìn thẳng vào bên trong từng phân đoạn của pipeline biên dịch. Lệnh check dừng ngay sau khi kết thúc phân tích cú pháp và kiểm tra kiểu. Lệnh emit-ir cho thấy compiler thực sự hiểu cấu trúc logic của chương trình thành hình dạng gì. Lệnh emit-cemit-llvm mở toang phần backend ra để soi chi tiết mã nguồn sinh ra. Lệnh build tạo ra file thực thi cuối cùng. Lệnh run đi trọn vẹn hành trình biên dịch rồi spawn tiến trình chạy file thực thi nhị phân vừa sinh ra.

Tôi thích cấu trúc mở này bởi nó thừa nhận sòng phẳng rằng compiler là một chuỗi các quá trình biến đổi logic tuần tự, tuyệt đối không phải là một chiếc hộp đen thần bí hoạt động bằng phép màu. Khi chương trình chạy sai kết quả, ta cần biết chính xác lỗi xảy ra trước hay sau khi hạ cấp sang IR. Khi trình biên dịch Clang báo lỗi dịch, ta cần mở file .c trung gian ra để xem compiler của ta đã emit mã nguồn C như thế nào. Khi LLVM từ chối nhận module, ta cần đọc trực tiếp LLVM IR. Một CLI được thiết kế tốt không chỉ phục vụ người dùng cuối; nó là bộ dụng cụ khám bệnh và chụp X-quang đắc lực cho chính những người viết compiler.

Trong mã nguồn triển khai ban đầu, hàm parseArgs nhận nhiệm vụ bóc tách command, đường dẫn file input, file output, loại backend sử dụng và các cờ cấu hình (flags). Nó kiên quyết từ chối các backend lạ tai, các đối số thừa thãi và các câu lệnh không nằm trong danh mục hỗ trợ. Những nhánh xử lý lỗi CLI này trông có vẻ tẻ nhạt và không có gì hào nhoáng, nhưng lại là nơi công cụ bắt đầu cư xử như một sản phẩm kỹ thuật chuyên nghiệp thay vì một đoạn script cá nhân viết vội.

Sau đó, hàm compileInput thực hiện đọc file nguồn, dựng cấu trúc chương trình và lựa chọn backend tương thích. Hàm buildNative ghi mã nguồn trung gian xuống thư mục tạm build, rồi spawn tiến trình gọi trình biên dịch hệ thống clang. Nếu clang báo lỗi dịch, CLI lập tức chuyển tiếp toàn bộ thông tin lỗi (stdout, stderr và exit status) ra ngoài terminal cho lập trình viên. Nếu clang biên dịch thành công và câu lệnh yêu cầu là run, CLI tự động spawn tiến trình chạy file nhị phân gốc vừa sinh ra, chuyển tiếp toàn bộ luồng output và exit code của chương trình ra terminal.

Lệnh: duy run hello.duy
Hành trình:
  -> CLI đọc file nguồn hello.duy
  -> Compiler parse cú pháp và check kiểu
  -> Backend sinh mã C hoặc LLVM tương ứng
  -> Spawn Clang dịch mã C/LLVM thành file nhị phân gốc hello
  -> Spawn chạy file hello
  -> Trả lại kết quả stdout và mã kết thúc (exit code) ra terminal

Ví dụ kiểm thử đầu tiên của DUY chỉ vỏn vẹn có vài dòng:

fn main() -> i32 {
  let x: i32 = 40 + 2
  print(x)
  return 0
}

Gõ lệnh node bin/duy run examples/hello.duy hiển thị ra kết quả: 42.

Bốn mươi cộng với hai là một phép toán dành cho học sinh tiểu học. Nhưng để đưa được phép toán ấy đi qua bộ phân tích từ vựng, bộ phân tích cú pháp, bộ kiểm tra kiểu, bộ sinh mã trung gian, trình biên dịch hệ thống của OS, và cuối cùng là spawn tiến trình thực thi của hệ điều hành là một chuỗi hành trình kỹ thuật đủ dài để có thể phát sinh lỗi ở bất kỳ vị trí nào. Chính vì kết quả đầu ra đơn giản nên nó vô cùng hữu ích: nếu con số nhả ra ở cuối đường ống không phải là 42, chúng tôi biết chắc chắn hệ thống pipeline đang gặp sự cố logic mà không cần phải mất công tranh luận về các logic nghiệp vụ phức tạp.

Tôi không gọi đây là cột mốc self-hosting. Cũng chưa dám gọi DUY là một compiler độc lập. CLI vẫn chạy trên Node.js, backend chính vẫn dịch bắc cầu qua C, và clang đang gánh phần việc nặng nhọc nhất là tối ưu hóa và sinh mã máy gốc. Việc thừa nhận rõ ràng giới hạn kỹ thuật không làm giảm đi giá trị thực tế của cột mốc. Nó chỉ đơn giản là giúp cột mốc ấy có một chỗ đứng vững chắc và thành thật trên mặt đất.

Hệ thống tests cho CLI giám sát chặt chẽ các hành vi cốt lõi: lệnh check bắt buộc phải pass với file hello hợp lệ và phải báo lỗi đỏ khi gặp file type error; lệnh emit-c bắt buộc phải chứa chuỗi định nghĩa hàm int32_t main(void); lệnh build bắt buộc phải tạo ra tệp thực thi gốc nhị phân nhả ra trên đĩa; và chạy tệp thực thi ấy bắt buộc phải in ra chính xác dòng chữ 42. Lệnh dòng lệnh đã thoát khỏi trạng thái mơ hồ "tôi vừa thấy nó chạy ổn trên máy tôi" để trở thành một hợp đồng kiểm thử tự động có khả năng lặp lại đáng tin cậy.

Terminal đã trả lời đúng kết quả. Nhưng niềm vui của con số 42 kéo dài không lâu. Một trình biên dịch chạy đúng chỉ mới chứng minh được nó biết cách đồng ý khi người dùng viết đúng. Một công cụ thực sự hữu ích bắt buộc phải biết cách giải thích rõ ràng và mạch lạc cho lập trình viên hiểu khi nó không đồng ý.


Bình luận (0)

Đang tải bình luận...