A simple CLI with Rust

This post will serve my memory for two purposes:

  1. How to write simple CLI parsers (in Rust!!) to further execute some other task.
  2. And most importantly, to document some of the configurations done to Doom Emacs to get a working Rust programming environment (for free!).

Simple CLI parsing with Rust

Let’s say we have to deal with a TestNG Suite that can be executed with the following options:

  1. Which (remote) testing environment will be used to execute a group of tests. Can be any string, and if no value is provided, qa1 should be used.
  2. (Optionally) Which is the xml file specifying the tests that will be executed by TestNG. No provided value means execute all tests”.
  3. Which browser will be used. Valid options are:
    • Chrome (the default)
    • Firefox
    • Edge
    • Safari
  4. Maximum timeouts for the Selenium WebDriver (in seconds). Default is 5 seconds.
  5. Assume that we can accept a pattern expression (note: this is not exactly a regex expression, see Running a Single Test - Maven Failsafe Plugin), and if none is provided, then all tests should be executed.

Installing a rust toolchain

Install rustup for your OS. Answer the questions made by the installer to select a toolchain (example, the nightly toolchain).

Create a skeleton for a Hello-World” application in rust using cargo

In the command line, use cargo to create a simple project:

cargo new test_executor

This should create a structure of files/directories like the following:

.
├── Cargo.toml
└── src
    └── main.rs

This (first) parser will use the clap crate (clap is a Command Line Argument Parser for Rust”) for the main parsing functionality. So, let’s add that dependency to the project:

cargo add clap --features derive

Let’s add one more library that provides a convenient derive macro for the standard library’s std::error::Error trait:

cargo add thiserror

Now our little skeleton should have 3 files:

.
├── Cargo.lock
├── Cargo.toml
└── src
    └── main.rs

And Cargo.toml should declare our dependencies. It might look similar to this:

[package]
name = "test_executor"
version = "0.1.0"
edition = "2021"

[dependencies]
clap = { version = "4.5.18", features = ["derive"] }
thiserror = "1.0.63"

Sample code (for quick reference and modification)

The following code does the job:

use clap::Parser;
use std::{
    fmt::{self, Debug, Display},
    str::FromStr,
};
use thiserror::Error;

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
enum Browser {
    #[default]
    Chrome,
    Firefox,
    Edge,
    Safari,
}

#[derive(Debug, Error)]
#[error("Unknown browser")]
struct UnknownBrowser;

impl FromStr for Browser {
    type Err = UnknownBrowser;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_lowercase().as_str() {
            "chrome" => Ok(Browser::Chrome),
            "firefox" => Ok(Browser::Firefox),
            "edge" => Ok(Browser::Edge),
            "safari" => Ok(Browser::Safari),
            _ => Err(UnknownBrowser),
        }
    }
}

impl Display for Browser {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Chrome => write!(f, "Chrome"),
            Self::Firefox => write!(f, "Firefox"),
            Self::Edge => write!(f, "Edge"),
            Self::Safari => write!(f, "Safari"),
        }
    }
}

/// Simple command-line argument parser
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
    /// Name of the environment (and associated properties)
    #[arg(short, long, default_value = "qa1")]
    env: String,

    /// Test suite to execute in src/test/resources/testsuites
    #[arg(short, long)]
    test_suite: Option<String>,

    /// Browser to use for testing
    #[arg(short, long, default_value_t)]
    browser: Browser,

    /// Selenium WebDriverWait value in seconds
    #[arg(short, long, default_value_t = 5)]
    wait: u8,

    /// Pattern for test cases to execute with maven failsafe plugin
    #[arg(short, long, default_value = ".*")]
    pattern: String,
}

// cargo run -- --browser=Firefox --env=qa2 --wait=30 --test_suite=gui-testsuite.xml
fn main() {
    let args = Args::parse();

    println!("Environment: {}", args.env);
    println!("Test suite: {:?}", args.test_suite);
    println!("Browser: {:?}", args.browser);
    println!("Selenium WebDriverWait: {} seconds", args.wait);
    println!("Pattern for test cases: {:?}", args.pattern);
}

It can be compiled with

cargo build

And executed with something like

cargo run -- --browser=Firefox --env=qa2 --wait=30 --test_suite=gui-testsuite.xml

Once we’re happy with the end result, build with

cargo build --release

And a binary can be copied from src/target/release/test_executor to wherever you need it (in your $PATH).

Rust development environment with Doom Emacs

  1. Enable lsp, rust and tree-sitter as well as debugger in init.el. Some excerpts of init.el:

    :tools
    (debugger +lsp)
    (lsp +peek)
    (tree-sitter)
    
    :lang
    (rust +lsp +tree-sitter)
    
  2. My config.el

    (load! "my-defaults-config")
    (load! "my-banner-config")
    
    (load! "my-emoji-config")
    
    (load! "my-mu4e-config")
    
    (load! "my-company-config")
    (load! "my-yasnippet-config")
    (load! "my-tabnine-config")
    (load! "my-dap-config")
    (load! "my-gptel-config")
    
    ;; TODO: clean up order and setup for different languages in these 2 files
    (load! "my-rust-config")
    (load! "my-lsp-config")
    
    (load! "my-org-config")
    (load! "my-gui-appearance-config")
    (load! "my-spell-config")
    
    (load! "my-tree-sitter-config")
    (load! "my-clojure-config")
    (load! "my-api-testing-config")
    (load! "my-db-config")
    (load! "my-vterm-config")
    
    (load! "my-consult-omni-config")
    
  3. My debugger config (my-dap-config.el). We’re relying on lldb

    (use-package dap-mode
      :config
      (dap-ui-mode)
      (dap-ui-controls-mode 1)
    
      (require 'dap-lldb)
      (require 'dap-gdb-lldb)
      ;; installs .extension/vscode
      (dap-gdb-lldb-setup)
      ;;(setq dap-gdb-lldb-path "/Users/oscarvarto/doom-emacs/.local/etc/dap-extension/vscode/webfreak.debug")
    
      ;;https://users.rust-lang.org/t/debugging-in-emacs-doom/99540/2
      (require 'dap-codelldb)
      (dap-codelldb-setup)
    
      ;; TODO: Find a way to change the :program argument without hardcoding it's value
      (dap-register-debug-template
       "Rust::LLDB Run Configuration"
       (list :type "lldb"
             :request "launch"
             :name "LLDB::Run"
             :gdbpath "rust-lldb"
             :target nil
             :program "/Users/oscarvarto/rustCode/test_executor/target/debug/test_executor"
             :cwd nil)))
    
  4. Contents of my-rust-config.el, considering that I’m using nushell to get the toolchain and rustic for an improved development experience:

    (custom-set-faces
      '(rustic-compilation-column ((t (:inherit compilation-column-number))))
      '(rustic-compilation-line ((t (:foreground "LimeGreen")))))
    
    (defun rustic-mode-auto-save-hook ()
      "Enable auto-saving in rustic-mode buffers."
      (when buffer-file-name
        (setq-local compilation-ask-about-save nil)))
    (add-hook 'rustic-mode-hook 'rustic-mode-auto-save-hook)
    
    (setq rustic-rustfmt-args "+nightly")
    (setq rustic-rustfmt-config-alist '((hard_tabs . t) (skip_children . nil)))
    
    (use-package rust-mode
      :init
      (setq rust-mode-treesitter-derive t)) ;; <== Tree-sitter related!
    
    (use-package rustic
      :custom
      (let ((toolchain (or (getenv "RUST_TOOLCHAIN")
                           (string-trim (shell-command-to-string "nu -c \"rustup show | lines | get 14 | split row ' ' | get 0\"")) ;; e.g. "1.77.2-aarch64-apple-darwin"
                           "nightly")))
        (setq rustic-analyzer-command `("rustup" "run" ,toolchain "rust-analyzer"))))
    
    (with-eval-after-load 'rustic-mode
      (add-hook 'rustic-mode-hook 'lsp-ui-mode)
      (add-hook 'flycheck-mode-hook #'flycheck-rust-setup))
    
    ;; TODO: https://robert.kra.hn/posts/rust-emacs-setup/
    (use-package lsp-mode
      :commands lsp
      :custom
      ;; what to use when checking on-save. "check" is default, I prefer clippy
      (lsp-rust-analyzer-cargo-watch-command "clippy")
      (lsp-eldoc-render-all t)
      (lsp-idle-delay 0.6)
      ;; enable / disable the hints as you prefer:
      (lsp-inlay-hint-enable t)
      ;; These are optional configurations. See https://emacs-lsp.github.io/lsp-mode/page/lsp-rust-analyzer/#lsp-rust-analyzer-display-chaining-hints for a full list
      (lsp-rust-analyzer-display-lifetime-elision-hints-enable "skip_trivial")
      (lsp-rust-analyzer-display-chaining-hints t)
      (lsp-rust-analyzer-display-lifetime-elision-hints-use-parameter-names nil)
      (lsp-rust-analyzer-display-closure-return-type-hints t)
      (lsp-rust-analyzer-display-parameter-hints nil)
      (lsp-rust-analyzer-display-reborrow-hints nil)
      :config
      (add-hook 'lsp-mode-hook 'lsp-ui-mode))
    
    (require 'lsp-ui)
    (setq! lsp-ui-peek-always-show t
           lsp-ui-sideline-show-hover t
           lsp-ui-doc-enable t)
    
  5. Adding rustic to package.el:

    (package! rustic :recipe (:repo "emacs-rustic/rustic"))
    
  6. my-lsp-config.el

    (use-package lsp-mode
      :init
      (setq lsp-keymap-prefix "C-c l")
      :hook ((java-mode . lsp)
             ;; if you want which-key integration
             (lsp-mode . lsp-enable-which-key-integration))
      :commands lsp)
    
    (use-package lsp-ui
      :init
      (setq lsp-ui-doc-popup-enabled t
            lsp-ui-doc-popup-max-width 0.8
            lsp-ui-peek-always-show t)
      :commands lsp-ui-mode)
    
    (use-package lsp-treemacs
      :commands lsp-treemacs-errors-list)
    
    ;; optionally if you want to use debugger
    (use-package dap-mode)
    ;; (use-package dap-LANGUAGE) to load the dap adapter for your language
    
    (load! "my-lsp-booster-config")
    
    (load! "my-lsp-java-config")
    (load! "my-lsp-nu-config")
    (load! "my-lsp-vtsls-config")
    
  7. The (expected) contents for lsp-booster functionality.

  • In my init.el:
(setenv "LSP_USE_PLISTS" "true")
  • my-lsp-booster-config.el

    (after! lsp-mode
      (setq! lsp-file-watch-threshold 20000
            lsp-inlay-hint-enable t))
    
    (defun lsp-booster--advice-json-parse (old-fn &rest args)
      "Try to parse bytecode instead of json."
      (or
       (when (equal (following-char) ?#)
         (let ((bytecode (read (current-buffer))))
           (when (byte-code-function-p bytecode)
             (funcall bytecode))))
       (apply old-fn args)))
    
    (advice-add (if (progn (require 'json)
                           (fboundp 'json-parse-buffer))
                    'json-parse-buffer
                  'json-read)
                :around
                #'lsp-booster--advice-json-parse)
    
    (defun lsp-booster--advice-final-command (old-fn cmd &optional test?)
      "Prepend emacs-lsp-booster command to lsp CMD."
      (let ((orig-result (funcall old-fn cmd test?)))
        (if (and (not test?)                             ;; for check lsp-server-present?
                 (not (file-remote-p default-directory)) ;; see lsp-resolve-final-command, it would add extra shell wrapper
                 lsp-use-plists
                 (not (functionp 'json-rpc-connection))  ;; native json-rpc
                 (executable-find "emacs-lsp-booster"))
            (progn
              (message "Using emacs-lsp-booster for %s!" orig-result)
              (cons "emacs-lsp-booster" orig-result))
          orig-result)))
    
    (advice-add 'lsp-resolve-final-command :around #'lsp-booster--advice-final-command)
    
  1. Tree-sitter useful stuff:

    (use-package! tree-sitter
      :config
      ;;(setq +tree-sitter-hl-enabled-modes '(not web-mode typescript-tsx-mode))
      (setq +tree-sitter-hl-enabled-modes t)
      (add-hook 'tree-sitter-after-on-hook #'tree-sitter-hl-mode))
    
    ;; Configure ts-fold
    (use-package! ts-fold
      :after tree-sitter
      :config
      ;; See also: https://github.com/emacs-tree-sitter/elisp-tree-sitter/issues/286
      ;; (global-ts-fold-indicators-mode 1) ;; <- Breaks Doom initialization
      ;; Set global keybindings for `ts-fold'.
      (map! :leader
          (:prefix-map ("z" . "fold")
           :desc "Fold all" "a" #'ts-fold-close-all
           :desc "Unfold all" "A" #'ts-fold-open-all
           :desc "Fold current" "f" #'ts-fold-close
           :desc "Unfold current" "F" #'ts-fold-open
           :desc "Toggle fold" "t" #'ts-fold-toggle))
      (require 'ts-fold-indicators)
      :hook (prog-mode . ts-fold-indicators-mode))
    
    (require 'line-reminder)
    (add-hook! 'prog-mode-hook #'line-reminder-mode)
    (setq line-reminder-show-option 'indicators)
    

Native Debug (GDB/LLDB)

  1. Check dap-mode page for Rust.

  2. Thanks to this section of Robert Krahn’s post, I learned that lldb-mi could be used to debug Rust code.

    • Clone the lldb-mi repo

    • Build with cmake, as instructed. For me, I had to do:

      LLVM_DIR=/opt/homebrew/Cellar/llvm/18.1.8/lib/cmake cmake .
      LLVM_DIR=/opt/homebrew/Cellar/llvm/18.1.8/lib/cmake cmake --build .
      
  3. Copy the binary lldb-mi binary to /usr/local/bin (a directory in my ~$PATH).

  4. Part of the setup should call (dap-gdb-lldb-setup), that will do important setup for debugging.

  5. To debug your program, execute M-x dap-hydra, and select the Rust::LLDB Run Configuration.


Date
September 21, 2024