Khám phá Nushell

Ai dùng Linux thì cũng phải đụng vào shell, với hình thức đơn giản nhất là chạy một chương trình / lệnh nào đó từ Terminal. Khi làm việc với server không có giao diện đồ họa, mọi tác vụ quản lý phải thực hiện qua dòng lệnh thì cũng là lúc ta sử dụng shell ở mức độ nâng cao hơn. Ta sẽ cần viết một lệnh dài để thực hiện nhiều việc theo chuỗi, hoặc viết thành một file script với điếu kiện if else, với vòng lặp để thi hành nhiều tác vụ phức tạp. Tuy nhiên, thật tình mà nói thì các phần mềm shell phổ biến trên Linux như Bash, Zsh tôi đều không thích cú pháp của chúng, nên nếu cần viết lệnh dài, viết ra file script thì tôi sẽ chuyển qua viết script Python. Mọi việc thường là thế cho đến khi tôi bắt gặp Nushell...

Sau khi chạy thử vài dòng lệnh theo bài giới thiệu nhanh, tôi thấy Nu quyến rũ với tư tưởng khá mới mẻ, đó là thiết kế dữ liệu chảy qua các ống dẫn (pipe) là dữ liệu có cấu trúc, có các field, chứ không đơn giản là văn bản text như các shell truyền thống. Để hình dung được ý tưởng này, xét ví dụ sau, là chạy lệnh ls trong Nu:

❯ ls
╭───┬────────────────┬─────────┬─────────┬────────────────╮
│ # │      name      │  type   │  size   │    modified    │
├───┼────────────────┼─────────┼─────────┼────────────────┤
│ 0 │ config.toml    │ file    │   312 B │ 2 months ago   │
│ 1 │ ignore         │ file    │    10 B │ 7 months ago   │
│ 2 │ languages.toml │ file    │ 1.9 KiB │ 40 minutes ago │
│ 3 │ runtime        │ symlink │    48 B │ 3 months ago   │
│ 4 │ themes         │ dir     │ 4.0 KiB │ 8 months ago   │
╰───┴────────────────┴─────────┴─────────┴────────────────╯

Lệnh ls này là một lệnh có sẵn trong Nushell, không phải chương trình ngoài /usr/bin/ls như trong các shell khác.

Tiếp đến, cho kết quả của ls qua ống dẫn, và lọc lấy field name:

❯ ls | get name
╭───┬────────────────╮
│ 0 │ config.toml    │
│ 1 │ ignore         │
│ 2 │ languages.toml │
│ 3 │ runtime        │
│ 4 │ themes         │
╰───┴────────────────╯

Tại sao ra như vậy, ta có thể hình dung ls cho ra một danh sách nhiều phần tử, với mỗi phần tử là một cấu trúc như sau:

{
    name: 'config.toml',
    type: file,
    size: 312b,
    modifield: 2024-09-23T21:07:07.452248397+07:00
}

Ta có thể kiểm tra bằng cách lấy kết quả đầu tiên của ls:

❯ ls | get 0
╭──────────┬──────────────╮
│ name     │ config.toml  │
│ type     │ file         │
│ size     │ 312 B        │
│ modified │ 2 months ago │
╰──────────┴──────────────╯

Đó là điểm chính khiến Nu đứng riêng so với các shell khác. Nó còn có những tính năng thú vị khác mà tôi dần phát hiện ra khi thử áp dụng nó qua những câu chuyện sau đây.

Câu chuyện 1: Tạo autocomplete cho lệnh nào đó

Autocomplete là tính năng cực kỳ cần thiết khi làm việc trong môi trường dòng lệnh, vì thường ta không nhớ nổi lệnh và các option đầy đủ, và ta cũng không muốn mất công gõ bàn phím nhiều (đặc biệt là với người không thể gõ phím 10 ngón như tôi). Một ví dụ của autocomplete là khi ta gõ "git c" sau đó bấm tab, ta sẽ thấy hiện ra danh sách các lệnh con của git bắt đầu bằng chữ c, bấm tiếp tab thì ta sẽ chọn được một trong những gợi ý đó.

Git autocomplete

Khi làm việc với các dự án Python, tôi thường dùng bộ script virtualenvwrapper để quản lý các môi trường ảo (virtual environment) cho Python. Bộ này có lệnh workon mà khi tôi gọi autocomplete (bấm tab) thì nó sẽ hiện danh sách các thư mục con nằm trong $WORKON_HOME:

workon autocomplete

Để tạo autocomplete trong các shell khác thì code sẽ rườm rà, trong khi với Nushell thì code trông gọn ghẽ. Tôi thử học theo tài liệu "Custom Completions" để implement lệnh workon cho Nu và autocomplete cho nó. Đây là kết quả:

def get-venvs []: nothing -> list {
  if 'WORKON_HOME' not-in $env {
    return []
  }
  glob $'($env.WORKON_HOME)/*/bin/activate.nu' | each { $in | path dirname --num-levels 2 | path basename }
}


def workon [name: string@get-venvs] {
  let act_script = $'($env.WORKON_HOME)/($name)/bin/activate.nu'
  # More to do...
}

Để chỉ định rằng một lệnh này sẽ lấy autocomplete từ một lệnh kia, ta chỉ cần cùng cú pháp @provider khi khai báo lệnh. Trong hàm get-venvs dùng để tạo autocomplete, ngoại trừ dòng kiểm tra biến môi trường WORKON_HOME, ta thấy dòng code chính để lấy về danh sách các thư mục con chứa môi trường ảo là rất ngắn gọn và dễ hiểu:

glob $'($env.WORKON_HOME)/*/bin/activate.nu' | each { $in | path dirname --num-levels 2 | path basename }

Nó nghĩa là:

  • Dùng glob để lấy tất cả các file "bin/activate.nu" (là dấu hiệu của môi trường ảo Python) bên dưới thư mục $env.WORKON_HOME. Dữ liệu được cho chảy qua các ống dẫn (|) để xử lý.
  • Với mỗi file tìm thấy, ta sẽ bóc tách đường dẫn đầy đủ của nó (ví dụ "/home/quan/Works/Envs/cobang/bin/activate.nu"), để lấy ra tên của thư mục bên trên "bin" (ví dụ "cobang"). Glob trả về một danh sách nên ta dùng each để áp công việc bóc tách lên từng phần tử.
  • Mỗi phần tử này được each gán vào biến $in. Biến này lại cho qua hai lần lệnh path để xử lý. Chẳng hạn từ /home/quan/Works/Envs/cobang/bin/activate.nu cho qua path --num-levels 2 ta được /home/quan/Works/Envs/cobang, cho qua tiếp path basename ta được cobang.

Ờ đây ta có thể thấy một số chỗ trong cú pháp của Nu giông giống với Rust, ví dụ cú pháp closure {} đi với each, cách quy hầu như mọi thứ về expression, và expression cuối cùng trong khối sẽ là kết quả expression của toàn bộ khối. Ví dụ ta thấy cuối hàm get-venvs không có return là bởi expression cuối cùng glob ... sinh ra giá trị thì giá trị đó được xem là kết quả của toàn bộ lời gọi hàm (tương đương một expression).

Các lệnh mà ta đang dùng (blob, each, path) đều là lệnh có sẵn của Nu. Nếu ta xem code của virtualenvwrapper, phần tạo autocomplete cho Bash, Zsh, sẽ thấy khá dài dòng.

Nu custom autocomplete

Tuy nhiên, tôi chỉ có thể đi đến bước tạo autocomplete, tôi không thể tái hiện lệnh workon cho Nu, vì một tính chất đặc thù khác của Nu là lệnh source (hay overlay use) chỉ chấp nhận đối số là giá trị tĩnh, ví dụ nó cho phép:

source /home/quan/Works/Envs/cobang/bin/activate.nu

nhưng không cho phép:

source $'($env.WORKON_HOME)/($name)/bin/activate.nu'

Câu chuyện 2: Sắp xếp lại bộ sưu tập hình ảnh

Sau khi chụp ảnh bằng điện thoại và máy ảnh, tôi thường sao chép vào ổ cứng máy tính (xem bài khác "Chuyển ảnh số lượng lớn từ điện thoại vào máy tính") để lưu trữ. Các file ảnh khi được sao chép sẽ được tự động đặt vào các thư mục con theo công thức "Photos/năm/tháng/tên-file.jpg", nhưng đôi khi tôi chọn sai cấu hình nên có những file bị lưu vào "Photos/năm/tháng/ngày/tên-file.jpg" (ví dụ "Photos/2020/11/02/abc.jpg"). Một ngày nọ tôi quyết định sắp xếp lại chúng, tìm những file lưu trong thư mục "ngày", dời lên trên ("tháng") và xóa thư mục ngày đi. Ví dụ nếu tôi có:

2023
└── 10
    └── 30
        ├── IMG_20231030_140827.jpg
        └── IMG_20231030_141132.jpg

Tôi muốn sắp lại thành:

2023
└── 10
    ├── IMG_20231030_140827.jpg
    └── IMG_20231030_141132.jpg

Thay vì viết script Python, lần này tôi muốn thử sức tạo chỉ một dòng lệnh shell cho việc đó, và Nu là ứng cử viên số một. Cuối cùng sau vài lần thử nghiệm, tôi ra được lệnh như sau:

ls [0-9]*/[0-9]*/[0-9]*/*.* | get name | each { {src: $in, dst: ($in | path split | drop nth 2 | path join | str downcase), folder: ($in | path dirname) } } | group-by folder --to-table | each { $in.items | each { |e| print $'($e.src) -> ($e.dst)'; mv $e.src $e.dst }; $in.group } | each { |p| rm $p ; echo $"Remove ($p)" }

Trông dài dòng vì nó phải đảm bảo:

  • Sau khi di dời file hết thì mới được xóa thư mục "ngày".
  • In ra log để người dùng thấy được lệnh sắp làm gì.

Đây là kết quả chạy:

2023/10/30/IMG_20231030_140827.jpg -> 2023/10/img_20231030_140827.jpg
2023/10/30/IMG_20231030_141132.jpg -> 2023/10/img_20231030_141132.jpg
2024/01/27/IMG_20240127_092433.jpg -> 2024/01/img_20240127_092433.jpg
2024/01/27/IMG_20240127_104017.jpg -> 2024/01/img_20240127_104017.jpg
╭───┬───────────────────╮
│ 0 │ Remove 2023/10/30 │
│ 1 │ Remove 2024/01/27 │
╰───┴───────────────────╯

Tôi xin giải thích từng đoạn:

  • Liệt kê các file được lưu theo cấu trúc "năm/tháng/ngày", lấy mỗi thông tin đường dẫn file.

    ❯ ls [0-9]*/[0-9]*/[0-9]*/*.* | get name
    ╭───┬────────────────────────────────────╮
    │ 0 │ 2023/10/30/IMG_20231030_140827.jpg │
    │ 1 │ 2023/10/30/IMG_20231030_141132.jpg │
    │ 2 │ 2024/01/27/IMG_20240127_092433.jpg │
    │ 3 │ 2024/01/27/IMG_20240127_104017.jpg │
    ╰───┴────────────────────────────────────╯
    
  • Với mỗi file đó, tính toán ra đường dẫn mới để di dời tới sau này, đồng thời lưu lại đường dẫn thư mục để sau này xóa thư mục đó đi.

    ❯  | each { {src: $in, dst: ($in | path split | drop nth 2 | path join | str downcase), folder: ($in | path dirname) } }
    ╭───┬────────────────────────────────────┬─────────────────────────────────┬────────────╮
    │ # │                src                 │               dst               │   folder   │
    ├───┼────────────────────────────────────┼─────────────────────────────────┼────────────┤
    │ 0 │ 2023/10/30/IMG_20231030_140827.jpg │ 2023/10/img_20231030_140827.jpg │ 2023/10/30 │
    │ 1 │ 2023/10/30/IMG_20231030_141132.jpg │ 2023/10/img_20231030_141132.jpg │ 2023/10/30 │
    │ 2 │ 2024/01/27/IMG_20240127_092433.jpg │ 2024/01/img_20240127_092433.jpg │ 2024/01/27 │
    │ 3 │ 2024/01/27/IMG_20240127_104017.jpg │ 2024/01/img_20240127_104017.jpg │ 2024/01/27 │
    ╰───┴────────────────────────────────────┴─────────────────────────────────┴────────────╯
    

    Để ý rằng, trong closure {} của lệnh each, tôi đang tạo ra một record {src:..., dst:..., folder:...}. Ngoài ra ta cũng dùng $in, một biến đặc biệt trong Nu dùng để lưu giá trị nhận được từ ống.

  • Chia nhóm các file tìm được, theo tháng.

    ❯  | group-by folder --to-table
    ╭───┬────────────┬───────────────────────────────────────────────────────────────────────────────────────────╮
    │ # │   group    │                                           items                                           │
    ├───┼────────────┼───────────────────────────────────────────────────────────────────────────────────────────┤
    │ 0 │ 2023/10/30 │ ╭───┬────────────────────────────────────┬─────────────────────────────────┬────────────╮ │
    │   │            │ │ # │                src                 │               dst               │   folder   │ │
    │   │            │ ├───┼────────────────────────────────────┼─────────────────────────────────┼────────────┤ │
    │   │            │ │ 0 │ 2023/10/30/IMG_20231030_140827.jpg │ 2023/10/img_20231030_140827.jpg │ 2023/10/30 │ │
    │   │            │ │ 1 │ 2023/10/30/IMG_20231030_141132.jpg │ 2023/10/img_20231030_141132.jpg │ 2023/10/30 │ │
    │   │            │ ╰───┴────────────────────────────────────┴─────────────────────────────────┴────────────╯ │
    │ 1 │ 2024/01/27 │ ╭───┬────────────────────────────────────┬─────────────────────────────────┬────────────╮ │
    │   │            │ │ # │                src                 │               dst               │   folder   │ │
    │   │            │ ├───┼────────────────────────────────────┼─────────────────────────────────┼────────────┤ │
    │   │            │ │ 0 │ 2024/01/27/IMG_20240127_092433.jpg │ 2024/01/img_20240127_092433.jpg │ 2024/01/27 │ │
    │   │            │ │ 1 │ 2024/01/27/IMG_20240127_104017.jpg │ 2024/01/img_20240127_104017.jpg │ 2024/01/27 │ │
    │   │            │ ╰───┴────────────────────────────────────┴─────────────────────────────────┴────────────╯ │
    ╰───┴────────────┴───────────────────────────────────────────────────────────────────────────────────────────╯
    

    Ở đây ta dùng --to-table để yêu cầu group-by trả kết quả về dưới dạng "table", một kiểu dữ liệu khác trong Nu. Nếu không có --to-table thì group-by trả về một record, không phù hợp với bước xử lý tiếp theo.

  • Lặp qua mỗi dòng của bảng. Tại mỗi dòng thì cột items đang chứa một danh sách các file. Lại lặp qua danh sách này để thực hiện việc di dời file, bằng lệnh mv và dựa vào thông tin src, dst đã tính được.

    ❯  | each { $in.items | each { |e| print $'($e.src) -> ($e.dst)'; mv $e.src $e.dst }; $in.group }
    2023/10/30/IMG_20231030_140827.jpg -> 2023/10/img_20231030_140827.jpg
    2023/10/30/IMG_20231030_141132.jpg -> 2023/10/img_20231030_141132.jpg
    2024/01/27/IMG_20240127_092433.jpg -> 2024/01/img_20240127_092433.jpg
    2024/01/27/IMG_20240127_104017.jpg -> 2024/01/img_20240127_104017.jpg
    ╭───┬────────────╮
    │ 0 │ 2023/10/30 │
    │ 1 │ 2024/01/27 │
    ╰───┴────────────╯
    

    Để ý rằng ở lệnh each thứ hai tôi đang dùng một cú pháp khác của closure, { |var| doing_something }. Đó là vì ở đây có hai lệnh each lồng nhau, lệnh each ngoài cùng đã dùng $in rồi thì lệnh each bên trong không thể dùng $in nữa kẻo ta lẫn Nu đều không hiểu $in này là của each nào. Thực ra cú pháp với |var| mới là cú pháp chuẩn, cú pháp dùng $in chỉ là lối tắt cho trường hợp đơn giản.

    Trong mỗi closure này ta đều chạy 2 lệnh, ngăn cách bởi dấu ; (vì tất cả đều viết trên cùng một dòng, nếu ta viết ra thành file script, mỗi lệnh một dòng thì không cần ;). Cuối closure của lệnh each ngoài cùng ta để $in.group là vì ta muốn lệch each này trả về đường dẫn thư mục (lưu trong cột group của bảng), để tiến hành xóa trong bước kế tiếp.

  • Bước cuối cùng, để xóa các thư mục, có lẽ không cần nói nhiều, vì chỉ dùng lại những khái niệm đã biết.

    ❯ | each { |p| rm $p ; echo $"Remove ($p)" }
    ╭───┬───────────────────╮
    │ 0 │ Remove 2023/10/30 │
    │ 1 │ Remove 2024/01/27 │
    ╰───┴───────────────────╯
    

Câu chuyện 3: Theo dõi file sửa đổi và hành động

Ở công ty, tôi đang xây dựng một website đa ngôn ngữ (trong đó thị trường nói tiếng Ả Rập là thị trường lớn). Website này dùng gettext cho việc nạp các văn bản đã dịch, có một thư mục locale chứa các file .po.

locale
├── ar
│   └── LC_MESSAGES
│       ├── django.mo
│       └── django.po
├── django.pot
├── id
│   └── LC_MESSAGES
│       ├── django.mo
│       └── django.po
├── ru
│   └── LC_MESSAGES
│       ├── django.mo
│       └── django.po
├── th
│   └── LC_MESSAGES
│       ├── django.mo
│       └── django.po
├── vi
│   └── LC_MESSAGES
│       ├── django.mo
│       └── django.po
└── zh
    └── LC_MESSAGES
        ├── django.mo
        └── django.po

File .po này chứa các chuỗi cần dịch và bản dịch của chúng. Khi người biên dịch làm việc của họ, file này sẽ được ghi nội dung mới. Tôi cần theo dõi thư mục locale này và khi có file .po nào được sửa đổi, sẽ copy file đó sang một nơi khác.

Có nhiều công cụ để thực hiện việc "watch" này, tuy nhiên không ưng lắm:

  • Watchman thì quá rườm rà, phải chạy hai lệnh riêng biệt.
  • Watchfiles trông đơn giản, nhưng để xử lý sâu hơn thì khó, ví dụ tôi cần tính toán đường dẫn mới trước khi copy file.

Vừa hay, Nu có sẵn lệnh watch mà không cần cài thêm phần mềm ngoài. Đã vậy, Nu vốn là shell nên có thể tận dụng ngôn ngữ của nó để viết các bước xử lý tôi cần.

❯ let SRC = 'locale' | path expand; watch $SRC { |op, path| if $op in ['Create', 'Write'] { let dst_path = ('s3://my-bucket/my-dir' | path join ($path | path relative-to $SRC)); print $'Copy ($path) -> ($dst_path)'; aws s3 cp $path $dst_path } }

Sau đây là giải thích:

  • Lấy đường dẫn tuyệt đối của thư mục cần theo dõi, lưu vào biến SRC:

    let SRC = 'locale' | path expand
    

    Với các shell truyền thống như Bash, Zsh, khi gán biến, phải viết dính lại, như SRC=locale, đó là điều tôi không thích.

  • Gọi watch để bắt đầu theo dõi. Ta diễn tả việc cần làm khi có file thay đổi bằng code trong closure {} của watch.

    watch $SRC { |op, path| if $op in ['Create', 'Write'] {} }
    

    Khi thực thi code trong closure, watch sẽ truyền vào ba tham số nhưng ta chỉ quan tâm hai, là hai biến ta khai báo ở phần capture |op, path|. Ta cũng chỉ phản ứng với sự kiện tạo file (Create), ghi nội dung mới (Write), nên ta kiểm tra $op trước khi làm việc.

  • Nếu file locale/ar/LC_MESSAGES/django.po thay đổi thì ta upload lên một thư mục trên S3, giữ nguyên cấu trúc sắp xếp, tức là file sau khi upload sẽ có địa chỉ s3://my-bucket/my-dir/ar/LC_MESSAGES/django.po, vì vậy ta cần bước tính toán đường dẫn mới:

    let dst_path = ('s3://my-bucket/my-dir' | path join ($path | path relative-to $SRC))
    
  • Cuối cùng thì in ra thông báo và gọi công cụ aws bên ngoài để tiến hành upload.

    print $'Copy ($path) -> ($dst_path)'; aws s3 cp $path $dst_path
    
  • Để lệnh này tự động chạy trên server thì ta chỉ việc tạo thêm một file .service để giao cho systemd lo. Xem thêm bài khác của tôi về systemd: How to automatically deploy Python web application.

Kết bài

Vậy là tôi đã đưa bạn dạo qua vài tính năng của Nu. Với ngôn ngữ trong sáng, dễ đọc, dễ viết, cùng tính năng mạnh mẽ, tôi đã muốn sử dụng Nu làm shell chính thay cho Zsh lắm rồi. Tuy nhiên vì còn vướng mắc với việc sử dụng môi trường ảo Python mà tôi chưa thể hoàn toàn dùng Nu được. Trong khi chờ đợi Nu có giải pháp thuận tiện hơn, thì tôi đang chuyển đổi sang Fish xài đỡ.

Nu syntax highlight