├── terminal.png ├── .github ├── FUNDING.yml └── workflows │ ├── fuzz.yml │ ├── openwrt.yml │ ├── ci.yml │ ├── aur.yml │ └── release.yml ├── .gitignore ├── src ├── modules.rs ├── modules │ ├── utils.rs │ ├── ok.rs │ ├── go.rs │ ├── node.rs │ ├── bun.rs │ ├── fail.rs │ ├── deno.rs │ ├── python.rs │ ├── env.rs │ ├── path.rs │ ├── git.rs │ ├── time.rs │ └── rust.rs ├── lib.rs ├── error.rs ├── module_trait.rs ├── registry.rs ├── memo.rs ├── template.rs ├── detector.rs ├── main.rs ├── executor.rs ├── style.rs └── parser.rs ├── fuzz ├── fuzz_targets │ ├── fuzz_parser.rs │ └── fuzz_renderer.rs └── Cargo.toml ├── LICENSE ├── deny.toml ├── Cargo.toml ├── benches ├── prompt_bench.rs └── comprehensive_bench.rs ├── tests ├── integration_test.rs └── parser_test.rs └── README.md /terminal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3axap4eHko/prmt/HEAD/terminal.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [3axap4eHko] 2 | custom: ['https://paypal.me/3axap4eHko', 'https://ko-fi.com/spizypie'] 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Rust build artifacts 2 | /target/ 3 | **/*.rs.bk 4 | *.pdb 5 | 6 | # IDE 7 | .idea/ 8 | .vscode/ 9 | *.swp 10 | *.swo 11 | *~ 12 | 13 | # OS 14 | .DS_Store 15 | Thumbs.db 16 | 17 | # Debug 18 | *.log 19 | -------------------------------------------------------------------------------- /src/modules.rs: -------------------------------------------------------------------------------- 1 | pub mod bun; 2 | pub mod deno; 3 | pub mod env; 4 | pub mod fail; 5 | pub mod git; 6 | pub mod go; 7 | pub mod node; 8 | pub mod ok; 9 | pub mod path; 10 | pub mod python; 11 | pub mod rust; 12 | pub mod time; 13 | pub mod utils; 14 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/fuzz_parser.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | use libfuzzer_sys::fuzz_target; 3 | use prmt::parse; 4 | 5 | fuzz_target!(|data: &[u8]| { 6 | if let Ok(s) = std::str::from_utf8(data) { 7 | // Fuzz the parser with arbitrary UTF-8 input 8 | let _ = parse(s); 9 | } 10 | }); -------------------------------------------------------------------------------- /fuzz/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "prmt-fuzz" 3 | version = "0.0.0" 4 | publish = false 5 | edition = "2021" 6 | 7 | [package.metadata] 8 | cargo-fuzz = true 9 | 10 | [dependencies] 11 | libfuzzer-sys = "0.4" 12 | 13 | [dependencies.prmt] 14 | path = ".." 15 | 16 | [[bin]] 17 | name = "fuzz_parser" 18 | path = "fuzz_targets/fuzz_parser.rs" 19 | test = false 20 | doc = false 21 | 22 | [[bin]] 23 | name = "fuzz_renderer" 24 | path = "fuzz_targets/fuzz_renderer.rs" 25 | test = false 26 | doc = false -------------------------------------------------------------------------------- /src/modules/utils.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{PromptError, Result}; 2 | 3 | pub fn validate_version_format<'a>(format: &'a str, module_name: &str) -> Result<&'a str> { 4 | match format { 5 | "" | "full" | "f" => Ok("full"), 6 | "short" | "s" => Ok("short"), 7 | "major" | "m" => Ok("major"), 8 | _ => Err(PromptError::InvalidFormat { 9 | module: module_name.to_string(), 10 | format: format.to_string(), 11 | valid_formats: "full, f, short, s, major, m".to_string(), 12 | }), 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod detector; 2 | pub mod error; 3 | pub mod executor; 4 | pub mod memo; 5 | pub mod module_trait; 6 | pub mod modules; 7 | pub mod parser; 8 | pub mod registry; 9 | pub mod style; 10 | pub mod template; 11 | 12 | // Re-export main types and functions 13 | pub use error::{PromptError, Result}; 14 | pub use executor::{execute, execute_with_shell, render_template}; 15 | pub use module_trait::{Module, ModuleContext}; 16 | pub use parser::{Params, Token, parse}; 17 | pub use registry::ModuleRegistry; 18 | pub use style::{AnsiStyle, ModuleStyle}; 19 | pub use template::Template; 20 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Error, Debug)] 4 | pub enum PromptError { 5 | #[error("Unknown module: {0}")] 6 | UnknownModule(String), 7 | 8 | #[error("Style error for module '{module}': {error}")] 9 | StyleError { module: String, error: String }, 10 | 11 | #[error("Invalid format '{format}' for module '{module}'. Valid formats: {valid_formats}")] 12 | InvalidFormat { 13 | module: String, 14 | format: String, 15 | valid_formats: String, 16 | }, 17 | 18 | #[error("I/O error: {0}")] 19 | IoError(#[from] std::io::Error), 20 | 21 | #[error("UTF-8 conversion error")] 22 | Utf8Error(#[from] std::string::FromUtf8Error), 23 | } 24 | 25 | pub type Result = std::result::Result; 26 | -------------------------------------------------------------------------------- /src/module_trait.rs: -------------------------------------------------------------------------------- 1 | use crate::detector::DetectionContext; 2 | use crate::error::Result; 3 | use crate::style::Shell; 4 | use std::path::Path; 5 | use std::sync::Arc; 6 | 7 | #[derive(Debug, Clone, Default)] 8 | pub struct ModuleContext { 9 | pub no_version: bool, 10 | pub exit_code: Option, 11 | pub detection: DetectionContext, 12 | pub shell: Shell, 13 | } 14 | 15 | impl ModuleContext { 16 | pub fn marker_path(&self, marker: &str) -> Option<&Path> { 17 | self.detection.get(marker) 18 | } 19 | } 20 | 21 | pub trait Module: Send + Sync { 22 | fn fs_markers(&self) -> &'static [&'static str] { 23 | &[] 24 | } 25 | 26 | fn render(&self, format: &str, context: &ModuleContext) -> Result>; 27 | } 28 | 29 | pub type ModuleRef = Arc; 30 | -------------------------------------------------------------------------------- /.github/workflows/fuzz.yml: -------------------------------------------------------------------------------- 1 | name: Fuzz Testing 2 | 3 | on: 4 | schedule: 5 | # Run weekly on Sundays at 2 AM UTC 6 | - cron: '0 2 * * 0' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | fuzz: 11 | name: Fuzz Test 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v5 15 | 16 | - name: Install Rust nightly 17 | uses: dtolnay/rust-toolchain@nightly 18 | 19 | - name: Install cargo-fuzz 20 | run: cargo install cargo-fuzz 21 | 22 | - name: Run parser fuzzer 23 | run: | 24 | cd fuzz 25 | cargo fuzz run fuzz_parser -- -max_total_time=300 26 | continue-on-error: true 27 | 28 | - name: Run renderer fuzzer 29 | run: | 30 | cd fuzz 31 | cargo fuzz run fuzz_renderer -- -max_total_time=300 32 | continue-on-error: true -------------------------------------------------------------------------------- /fuzz/fuzz_targets/fuzz_renderer.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | use libfuzzer_sys::fuzz_target; 3 | use prmt::detector::DetectionContext; 4 | use prmt::style::Shell; 5 | use prmt::{ModuleContext, ModuleRegistry, Template}; 6 | use std::sync::Arc; 7 | 8 | fn setup_registry() -> ModuleRegistry { 9 | use prmt::modules::*; 10 | 11 | let mut registry = ModuleRegistry::new(); 12 | registry.register("path", Arc::new(path::PathModule)); 13 | registry.register("git", Arc::new(git::GitModule)); 14 | registry 15 | } 16 | 17 | fuzz_target!(|data: &[u8]| { 18 | if let Ok(s) = std::str::from_utf8(data) { 19 | // Fuzz the template renderer with arbitrary UTF-8 input 20 | let template = Template::new(s); 21 | let registry = setup_registry(); 22 | let context = ModuleContext { 23 | no_version: true, 24 | exit_code: Some(0), 25 | detection: DetectionContext::default(), 26 | shell: Shell::None, 27 | }; 28 | 29 | let _ = template.render(®istry, &context); 30 | } 31 | }); 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Ivan Zakharchanka 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | # cargo-deny configuration 2 | 3 | [licenses] 4 | # List of allowed licenses 5 | allow = [ 6 | "MIT", 7 | "Apache-2.0", 8 | "Apache-2.0 WITH LLVM-exception", 9 | "BSD-2-Clause", 10 | "BSD-3-Clause", 11 | "ISC", 12 | "Unicode-DFS-2016", 13 | "CC0-1.0", 14 | ] 15 | 16 | [bans] 17 | # Lint level for when multiple versions of the same crate are detected 18 | multiple-versions = "warn" 19 | # Skip certain crates when checking for duplicates 20 | skip = [] 21 | 22 | [advisories] 23 | # The advisories section is used to configure how the advisory database is used 24 | db-path = "~/.cargo/advisory-db" 25 | db-urls = ["https://github.com/rustsec/advisory-db"] 26 | # Lint level for security vulnerabilities 27 | vulnerability = "deny" 28 | # Lint level for unmaintained crates 29 | unmaintained = "warn" 30 | # Lint level for crates with security notices 31 | notice = "warn" 32 | 33 | [sources] 34 | # Lint level for what to happen when a crate from a crate registry that is not in the allow list is encountered 35 | unknown-registry = "warn" 36 | # Lint level for what to happen when a crate from a git repository that is not in the allow list is encountered 37 | unknown-git = "warn" 38 | # List of allowed crate registries 39 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] -------------------------------------------------------------------------------- /src/registry.rs: -------------------------------------------------------------------------------- 1 | use crate::module_trait::ModuleRef; 2 | use std::collections::{HashMap, HashSet}; 3 | 4 | struct ModuleEntry { 5 | module: ModuleRef, 6 | markers: &'static [&'static str], 7 | } 8 | 9 | pub struct ModuleRegistry { 10 | modules: HashMap, 11 | } 12 | 13 | impl ModuleRegistry { 14 | pub fn new() -> Self { 15 | Self { 16 | modules: HashMap::new(), 17 | } 18 | } 19 | 20 | pub fn register(&mut self, name: impl Into, module: ModuleRef) { 21 | let markers = module.fs_markers(); 22 | self.modules 23 | .insert(name.into(), ModuleEntry { module, markers }); 24 | } 25 | 26 | pub fn get(&self, name: &str) -> Option { 27 | self.modules.get(name).map(|entry| entry.module.clone()) 28 | } 29 | 30 | pub fn required_markers(&self) -> HashSet<&'static str> { 31 | let estimated = self.modules.values().map(|entry| entry.markers.len()).sum(); 32 | let mut markers = HashSet::with_capacity(estimated); 33 | for entry in self.modules.values() { 34 | for &marker in entry.markers { 35 | markers.insert(marker); 36 | } 37 | } 38 | markers 39 | } 40 | } 41 | 42 | impl Default for ModuleRegistry { 43 | fn default() -> Self { 44 | Self::new() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "prmt" 3 | version = "0.1.8" 4 | edition = "2024" 5 | authors = ["Ivan Zakharchanka <3axap4eHko@gmail.com>"] 6 | description = "Ultra-fast, customizable shell prompt generator with zero-copy parsing" 7 | license = "MIT" 8 | repository = "https://github.com/3axap4eHko/prmt" 9 | homepage = "https://github.com/3axap4eHko/prmt" 10 | readme = "README.md" 11 | keywords = ["prompt", "shell", "terminal", "cli", "performance"] 12 | categories = ["command-line-utilities", "command-line-interface"] 13 | 14 | [lib] 15 | name = "prmt" 16 | path = "src/lib.rs" 17 | 18 | [[bin]] 19 | name = "prmt" 20 | path = "src/main.rs" 21 | 22 | [[bench]] 23 | name = "prompt_bench" 24 | harness = false 25 | 26 | [[bench]] 27 | name = "comprehensive_bench" 28 | harness = false 29 | 30 | [dependencies] 31 | lexopt = "0.3" 32 | gix = { version = "0.74.1", optional = true, default-features = false, features = ["parallel", "revision", "status"] } 33 | rayon = "1" 34 | dirs = "6" 35 | memchr = "2" 36 | is-terminal = "0.4" 37 | once_cell = "1" 38 | thiserror = "2" 39 | unicode-width = "0.2" 40 | bitflags = "2" 41 | libc = "0.2" 42 | toml = { version = "0.9", default-features = false, features = ["parse", "serde"] } 43 | 44 | [features] 45 | default = ["git-gix"] 46 | git-gix = ["dep:gix"] 47 | 48 | [dev-dependencies] 49 | criterion = { version = "0.7", features = ["html_reports"] } 50 | regex = "1" 51 | serial_test = "3" 52 | tempfile = "3" 53 | 54 | [profile.release] 55 | lto = "fat" 56 | codegen-units = 1 57 | opt-level = 3 58 | strip = true 59 | panic = "abort" 60 | -------------------------------------------------------------------------------- /src/modules/ok.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::module_trait::{Module, ModuleContext}; 3 | 4 | pub struct OkModule; 5 | 6 | impl Default for OkModule { 7 | fn default() -> Self { 8 | Self::new() 9 | } 10 | } 11 | 12 | impl OkModule { 13 | pub fn new() -> Self { 14 | Self 15 | } 16 | } 17 | 18 | impl Module for OkModule { 19 | fn render(&self, format: &str, context: &ModuleContext) -> Result> { 20 | if context.exit_code != Some(0) { 21 | return Ok(None); 22 | } 23 | 24 | let symbol = match format { 25 | "" => "❯", 26 | "code" => "0", 27 | custom => custom, 28 | }; 29 | 30 | Ok(Some(symbol.to_string())) 31 | } 32 | } 33 | 34 | #[cfg(test)] 35 | mod tests { 36 | use super::*; 37 | 38 | #[test] 39 | fn test_ok_on_zero_exit_code() { 40 | let module = OkModule::new(); 41 | let context = ModuleContext { 42 | exit_code: Some(0), 43 | ..ModuleContext::default() 44 | }; 45 | let result = module.render("", &context).unwrap(); 46 | assert_eq!(result, Some("❯".to_string())); 47 | } 48 | 49 | #[test] 50 | fn test_ok_hidden_on_error() { 51 | let module = OkModule::new(); 52 | let context = ModuleContext { 53 | exit_code: Some(1), 54 | ..ModuleContext::default() 55 | }; 56 | let result = module.render("", &context).unwrap(); 57 | assert_eq!(result, None); 58 | } 59 | 60 | #[test] 61 | fn test_ok_custom_symbol() { 62 | let module = OkModule::new(); 63 | let context = ModuleContext { 64 | exit_code: Some(0), 65 | ..ModuleContext::default() 66 | }; 67 | let result = module.render("✓", &context).unwrap(); 68 | assert_eq!(result, Some("✓".to_string())); 69 | } 70 | 71 | #[test] 72 | fn test_ok_code_format() { 73 | let module = OkModule::new(); 74 | let context = ModuleContext { 75 | exit_code: Some(0), 76 | ..ModuleContext::default() 77 | }; 78 | let result = module.render("code", &context).unwrap(); 79 | assert_eq!(result, Some("0".to_string())); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/modules/go.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::memo::{GO_VERSION, memoized_version}; 3 | use crate::module_trait::{Module, ModuleContext}; 4 | use crate::modules::utils; 5 | use std::process::Command; 6 | 7 | pub struct GoModule; 8 | 9 | impl Default for GoModule { 10 | fn default() -> Self { 11 | Self::new() 12 | } 13 | } 14 | 15 | impl GoModule { 16 | pub fn new() -> Self { 17 | Self 18 | } 19 | } 20 | 21 | impl Module for GoModule { 22 | fn fs_markers(&self) -> &'static [&'static str] { 23 | &["go.mod"] 24 | } 25 | 26 | fn render(&self, format: &str, context: &ModuleContext) -> Result> { 27 | if context.marker_path("go.mod").is_none() { 28 | return Ok(None); 29 | } 30 | 31 | if context.no_version { 32 | return Ok(Some("go".to_string())); 33 | } 34 | 35 | // Validate and normalize format 36 | let normalized_format = utils::validate_version_format(format, "go")?; 37 | 38 | let version = match memoized_version(&GO_VERSION, get_go_version) { 39 | Some(v) => v, 40 | None => return Ok(None), 41 | }; 42 | let version_str = version.as_ref(); 43 | 44 | match normalized_format { 45 | "full" => Ok(Some(version_str.to_string())), 46 | "short" => { 47 | let parts: Vec<&str> = version_str.split('.').collect(); 48 | if parts.len() >= 2 { 49 | Ok(Some(format!("{}.{}", parts[0], parts[1]))) 50 | } else { 51 | Ok(Some(version_str.to_string())) 52 | } 53 | } 54 | "major" => Ok(version_str.split('.').next().map(|s| s.to_string())), 55 | _ => unreachable!("validate_version_format should have caught this"), 56 | } 57 | } 58 | } 59 | 60 | #[cold] 61 | fn get_go_version() -> Option { 62 | let output = Command::new("go").arg("version").output().ok()?; 63 | if !output.status.success() { 64 | return None; 65 | } 66 | let version_str = String::from_utf8_lossy(&output.stdout); 67 | version_str 68 | .split_whitespace() 69 | .nth(2) 70 | .map(|v| v.trim_start_matches("go").to_string()) 71 | } 72 | -------------------------------------------------------------------------------- /src/modules/node.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::memo::{NODE_VERSION, memoized_version}; 3 | use crate::module_trait::{Module, ModuleContext}; 4 | use crate::modules::utils; 5 | use std::process::Command; 6 | 7 | pub struct NodeModule; 8 | 9 | impl Default for NodeModule { 10 | fn default() -> Self { 11 | Self::new() 12 | } 13 | } 14 | 15 | impl NodeModule { 16 | pub fn new() -> Self { 17 | Self 18 | } 19 | } 20 | 21 | #[cold] 22 | fn get_node_version() -> Option { 23 | let output = Command::new("node").arg("--version").output().ok()?; 24 | 25 | if !output.status.success() { 26 | return None; 27 | } 28 | 29 | let version_str = String::from_utf8_lossy(&output.stdout); 30 | Some(version_str.trim().trim_start_matches('v').to_string()) 31 | } 32 | 33 | impl Module for NodeModule { 34 | fn fs_markers(&self) -> &'static [&'static str] { 35 | &["package.json"] 36 | } 37 | 38 | fn render(&self, format: &str, context: &ModuleContext) -> Result> { 39 | if context.marker_path("package.json").is_none() { 40 | return Ok(None); 41 | } 42 | 43 | if context.no_version { 44 | return Ok(Some("node".to_string())); 45 | } 46 | 47 | // Validate and normalize format 48 | let normalized_format = utils::validate_version_format(format, "node")?; 49 | 50 | // Check memoized value first 51 | let version = match memoized_version(&NODE_VERSION, get_node_version) { 52 | Some(v) => v, 53 | None => return Ok(None), 54 | }; 55 | let version_str = version.as_ref(); 56 | 57 | match normalized_format { 58 | "full" => Ok(Some(version_str.to_string())), 59 | "short" => { 60 | let parts: Vec<&str> = version_str.split('.').collect(); 61 | if parts.len() >= 2 { 62 | Ok(Some(format!("{}.{}", parts[0], parts[1]))) 63 | } else { 64 | Ok(Some(version_str.to_string())) 65 | } 66 | } 67 | "major" => Ok(version_str.split('.').next().map(|s| s.to_string())), 68 | _ => unreachable!("validate_version_format should have caught this"), 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/modules/bun.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::memo::{BUN_VERSION, memoized_version}; 3 | use crate::module_trait::{Module, ModuleContext}; 4 | use crate::modules::utils; 5 | use std::process::Command; 6 | 7 | pub struct BunModule; 8 | 9 | impl Default for BunModule { 10 | fn default() -> Self { 11 | Self::new() 12 | } 13 | } 14 | 15 | impl BunModule { 16 | pub fn new() -> Self { 17 | Self 18 | } 19 | } 20 | 21 | impl Module for BunModule { 22 | fn fs_markers(&self) -> &'static [&'static str] { 23 | &["bun.lockb", "bunfig.toml"] 24 | } 25 | 26 | fn render(&self, format: &str, context: &ModuleContext) -> Result> { 27 | let has_marker = ["bun.lockb", "bunfig.toml"] 28 | .into_iter() 29 | .any(|marker| context.marker_path(marker).is_some()); 30 | if !has_marker { 31 | return Ok(None); 32 | } 33 | 34 | if context.no_version { 35 | return Ok(Some("bun".to_string())); 36 | } 37 | 38 | // Validate and normalize format 39 | let normalized_format = utils::validate_version_format(format, "bun")?; 40 | 41 | let version = match memoized_version(&BUN_VERSION, get_bun_version) { 42 | Some(v) => v, 43 | None => return Ok(None), 44 | }; 45 | let version_str = version.as_ref(); 46 | 47 | match normalized_format { 48 | "full" => Ok(Some(version_str.to_string())), 49 | "short" => { 50 | let parts: Vec<&str> = version_str.split('.').collect(); 51 | if parts.len() >= 2 { 52 | Ok(Some(format!("{}.{}", parts[0], parts[1]))) 53 | } else { 54 | Ok(Some(version_str.to_string())) 55 | } 56 | } 57 | "major" => Ok(version_str.split('.').next().map(|s| s.to_string())), 58 | _ => unreachable!("validate_version_format should have caught this"), 59 | } 60 | } 61 | } 62 | 63 | #[cold] 64 | fn get_bun_version() -> Option { 65 | let output = Command::new("bun").arg("--version").output().ok()?; 66 | if !output.status.success() { 67 | return None; 68 | } 69 | Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) 70 | } 71 | -------------------------------------------------------------------------------- /src/modules/fail.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::module_trait::{Module, ModuleContext}; 3 | 4 | pub struct FailModule; 5 | 6 | impl Default for FailModule { 7 | fn default() -> Self { 8 | Self::new() 9 | } 10 | } 11 | 12 | impl FailModule { 13 | pub fn new() -> Self { 14 | Self 15 | } 16 | } 17 | 18 | impl Module for FailModule { 19 | fn render(&self, format: &str, context: &ModuleContext) -> Result> { 20 | let exit_code = context.exit_code.unwrap_or(0); 21 | if exit_code == 0 { 22 | return Ok(None); 23 | } 24 | 25 | let symbol = match format { 26 | "" | "full" => "❯".to_string(), 27 | "code" => exit_code.to_string(), 28 | custom => custom.to_string(), 29 | }; 30 | 31 | Ok(Some(symbol)) 32 | } 33 | } 34 | 35 | #[cfg(test)] 36 | mod tests { 37 | use super::*; 38 | 39 | #[test] 40 | fn test_fail_on_non_zero_exit_code() { 41 | let module = FailModule::new(); 42 | let context = ModuleContext { 43 | exit_code: Some(127), 44 | ..ModuleContext::default() 45 | }; 46 | let result = module.render("", &context).unwrap(); 47 | assert_eq!(result, Some("❯".to_string())); 48 | } 49 | 50 | #[test] 51 | fn test_fail_hidden_on_success() { 52 | let module = FailModule::new(); 53 | let context = ModuleContext { 54 | exit_code: Some(0), 55 | ..ModuleContext::default() 56 | }; 57 | let result = module.render("", &context).unwrap(); 58 | assert_eq!(result, None); 59 | } 60 | 61 | #[test] 62 | fn test_fail_shows_exit_code() { 63 | let module = FailModule::new(); 64 | let context = ModuleContext { 65 | exit_code: Some(42), 66 | ..ModuleContext::default() 67 | }; 68 | let result = module.render("code", &context).unwrap(); 69 | assert_eq!(result, Some("42".to_string())); 70 | } 71 | 72 | #[test] 73 | fn test_fail_custom_symbol() { 74 | let module = FailModule::new(); 75 | let context = ModuleContext { 76 | exit_code: Some(1), 77 | ..ModuleContext::default() 78 | }; 79 | let result = module.render("✗", &context).unwrap(); 80 | assert_eq!(result, Some("✗".to_string())); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/modules/deno.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::memo::{DENO_VERSION, memoized_version}; 3 | use crate::module_trait::{Module, ModuleContext}; 4 | use crate::modules::utils; 5 | use std::process::Command; 6 | 7 | pub struct DenoModule; 8 | 9 | impl Default for DenoModule { 10 | fn default() -> Self { 11 | Self::new() 12 | } 13 | } 14 | 15 | impl DenoModule { 16 | pub fn new() -> Self { 17 | Self 18 | } 19 | } 20 | 21 | impl Module for DenoModule { 22 | fn fs_markers(&self) -> &'static [&'static str] { 23 | &["deno.json", "deno.jsonc"] 24 | } 25 | 26 | fn render(&self, format: &str, context: &ModuleContext) -> Result> { 27 | let has_marker = ["deno.json", "deno.jsonc"] 28 | .into_iter() 29 | .any(|marker| context.marker_path(marker).is_some()); 30 | if !has_marker { 31 | return Ok(None); 32 | } 33 | 34 | if context.no_version { 35 | return Ok(Some("deno".to_string())); 36 | } 37 | 38 | // Validate and normalize format 39 | let normalized_format = utils::validate_version_format(format, "deno")?; 40 | 41 | let version = match memoized_version(&DENO_VERSION, get_deno_version) { 42 | Some(v) => v, 43 | None => return Ok(None), 44 | }; 45 | let version_str = version.as_ref(); 46 | 47 | match normalized_format { 48 | "full" => Ok(Some(version_str.to_string())), 49 | "short" => { 50 | let parts: Vec<&str> = version_str.split('.').collect(); 51 | if parts.len() >= 2 { 52 | Ok(Some(format!("{}.{}", parts[0], parts[1]))) 53 | } else { 54 | Ok(Some(version_str.to_string())) 55 | } 56 | } 57 | "major" => Ok(version_str.split('.').next().map(|s| s.to_string())), 58 | _ => unreachable!("validate_version_format should have caught this"), 59 | } 60 | } 61 | } 62 | 63 | #[cold] 64 | fn get_deno_version() -> Option { 65 | let output = Command::new("deno").arg("--version").output().ok()?; 66 | if !output.status.success() { 67 | return None; 68 | } 69 | let version_str = String::from_utf8_lossy(&output.stdout); 70 | version_str 71 | .lines() 72 | .next() 73 | .and_then(|l| l.split_whitespace().nth(1)) 74 | .map(|v| v.to_string()) 75 | } 76 | -------------------------------------------------------------------------------- /src/modules/python.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::memo::{PYTHON_VERSION, memoized_version}; 3 | use crate::module_trait::{Module, ModuleContext}; 4 | use crate::modules::utils; 5 | use std::process::Command; 6 | 7 | pub struct PythonModule; 8 | 9 | impl Default for PythonModule { 10 | fn default() -> Self { 11 | Self::new() 12 | } 13 | } 14 | 15 | impl PythonModule { 16 | pub fn new() -> Self { 17 | Self 18 | } 19 | } 20 | 21 | impl Module for PythonModule { 22 | fn fs_markers(&self) -> &'static [&'static str] { 23 | &["requirements.txt", "pyproject.toml", "setup.py"] 24 | } 25 | 26 | fn render(&self, format: &str, context: &ModuleContext) -> Result> { 27 | let has_marker = ["requirements.txt", "pyproject.toml", "setup.py"] 28 | .into_iter() 29 | .any(|marker| context.marker_path(marker).is_some()); 30 | if !has_marker { 31 | return Ok(None); 32 | } 33 | 34 | if context.no_version { 35 | return Ok(Some("python".to_string())); 36 | } 37 | 38 | // Validate and normalize format 39 | let normalized_format = utils::validate_version_format(format, "python")?; 40 | 41 | let version = match memoized_version(&PYTHON_VERSION, get_python_version) { 42 | Some(v) => v, 43 | None => return Ok(None), 44 | }; 45 | let version_str = version.as_ref(); 46 | 47 | match normalized_format { 48 | "full" => Ok(Some(version_str.to_string())), 49 | "short" => { 50 | let parts: Vec<&str> = version_str.split('.').collect(); 51 | if parts.len() >= 2 { 52 | Ok(Some(format!("{}.{}", parts[0], parts[1]))) 53 | } else { 54 | Ok(Some(version_str.to_string())) 55 | } 56 | } 57 | "major" => Ok(version_str.split('.').next().map(|s| s.to_string())), 58 | _ => unreachable!("validate_version_format should have caught this"), 59 | } 60 | } 61 | } 62 | 63 | #[cold] 64 | fn get_python_version() -> Option { 65 | let output = Command::new("python3") 66 | .arg("--version") 67 | .output() 68 | .or_else(|_| Command::new("python").arg("--version").output()) 69 | .ok()?; 70 | if !output.status.success() { 71 | return None; 72 | } 73 | let version_bytes = if output.stdout.is_empty() { 74 | output.stderr.as_slice() 75 | } else { 76 | output.stdout.as_slice() 77 | }; 78 | let version_str = String::from_utf8_lossy(version_bytes); 79 | version_str.split_whitespace().nth(1).map(|v| v.to_string()) 80 | } 81 | -------------------------------------------------------------------------------- /src/memo.rs: -------------------------------------------------------------------------------- 1 | use once_cell::sync::Lazy; 2 | use std::collections::HashMap; 3 | use std::path::{Path, PathBuf}; 4 | use std::sync::{Arc, OnceLock, RwLock}; 5 | 6 | pub type VersionSlot = OnceLock>>; 7 | 8 | pub static NODE_VERSION: VersionSlot = OnceLock::new(); 9 | pub static RUST_VERSION: VersionSlot = OnceLock::new(); 10 | pub static PYTHON_VERSION: VersionSlot = OnceLock::new(); 11 | pub static GO_VERSION: VersionSlot = OnceLock::new(); 12 | pub static DENO_VERSION: VersionSlot = OnceLock::new(); 13 | pub static BUN_VERSION: VersionSlot = OnceLock::new(); 14 | 15 | pub fn memoized_version(slot: &VersionSlot, fetch: F) -> Option> 16 | where 17 | F: FnOnce() -> Option, 18 | { 19 | if let Some(value) = slot.get() { 20 | return value.clone(); 21 | } 22 | 23 | let value = fetch().map(|v| Arc::::from(v.into_boxed_str())); 24 | let _ = slot.set(value.clone()); 25 | value 26 | } 27 | 28 | /// Per-process memoization for Git metadata gathered during a render. 29 | pub struct GitMemo { 30 | entries: RwLock>, 31 | } 32 | 33 | #[derive(Clone)] 34 | pub struct GitInfo { 35 | pub branch: String, 36 | pub has_changes: bool, 37 | pub has_staged: bool, 38 | pub has_untracked: bool, 39 | } 40 | 41 | impl Default for GitMemo { 42 | fn default() -> Self { 43 | Self::new() 44 | } 45 | } 46 | 47 | impl GitMemo { 48 | pub fn new() -> Self { 49 | Self { 50 | entries: RwLock::new(HashMap::new()), 51 | } 52 | } 53 | 54 | pub fn get(&self, path: &Path) -> Option { 55 | let entries = self.entries.read().ok()?; 56 | entries.get(path).cloned() 57 | } 58 | 59 | pub fn insert(&self, path: PathBuf, info: GitInfo) { 60 | if let Ok(mut entries) = self.entries.write() { 61 | entries.insert(path, info); 62 | } 63 | } 64 | } 65 | 66 | pub static GIT_MEMO: Lazy = Lazy::new(GitMemo::new); 67 | 68 | #[cfg(test)] 69 | mod tests { 70 | use super::*; 71 | use std::sync::atomic::{AtomicUsize, Ordering}; 72 | 73 | #[test] 74 | fn memoized_version_caches_successful_fetches() { 75 | let slot: VersionSlot = OnceLock::new(); 76 | let calls = AtomicUsize::new(0); 77 | let value = memoized_version(&slot, || { 78 | calls.fetch_add(1, Ordering::SeqCst); 79 | Some("1.2.3".to_string()) 80 | }) 81 | .expect("expected version"); 82 | assert_eq!(calls.load(Ordering::SeqCst), 1); 83 | assert_eq!(value.as_ref(), "1.2.3"); 84 | 85 | let second = memoized_version(&slot, || { 86 | calls.fetch_add(1, Ordering::SeqCst); 87 | Some("should not run".to_string()) 88 | }) 89 | .expect("expected cached version"); 90 | assert_eq!(calls.load(Ordering::SeqCst), 1); 91 | assert!(Arc::ptr_eq(&value, &second)); 92 | } 93 | 94 | #[test] 95 | fn memoized_version_caches_absence() { 96 | let slot: VersionSlot = OnceLock::new(); 97 | let calls = AtomicUsize::new(0); 98 | let value = memoized_version(&slot, || { 99 | calls.fetch_add(1, Ordering::SeqCst); 100 | None 101 | }); 102 | assert!(value.is_none()); 103 | assert_eq!(calls.load(Ordering::SeqCst), 1); 104 | 105 | let second = memoized_version(&slot, || { 106 | calls.fetch_add(1, Ordering::SeqCst); 107 | Some("unexpected".to_string()) 108 | }); 109 | assert!(second.is_none()); 110 | assert_eq!(calls.load(Ordering::SeqCst), 1); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/template.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::module_trait::ModuleContext; 3 | use crate::parser::{Token, parse}; 4 | use crate::registry::ModuleRegistry; 5 | use crate::style::{AnsiStyle, ModuleStyle, global_no_color}; 6 | use is_terminal::IsTerminal; 7 | 8 | /// A parsed template that can be rendered multiple times efficiently 9 | pub struct Template<'a> { 10 | tokens: Vec>, 11 | estimated_size: usize, 12 | } 13 | 14 | impl<'a> Template<'a> { 15 | /// Parse a template string into a reusable Template 16 | #[inline] 17 | pub fn new(template: &'a str) -> Self { 18 | let tokens = parse(template); 19 | let estimated_size = template.len() + (template.len() / 2) + 128; 20 | Self { 21 | tokens, 22 | estimated_size, 23 | } 24 | } 25 | 26 | /// Render the template with the given registry and context 27 | pub fn render(&self, registry: &ModuleRegistry, context: &ModuleContext) -> Result { 28 | let mut output = String::with_capacity(self.estimated_size); 29 | 30 | let no_color = global_no_color() || !IsTerminal::is_terminal(&std::io::stdout()); 31 | 32 | for token in &self.tokens { 33 | match token { 34 | Token::Text(text) => { 35 | output.push_str(text); 36 | } 37 | Token::Placeholder(params) => { 38 | let module = registry.get(¶ms.module).ok_or_else(|| { 39 | crate::error::PromptError::UnknownModule(params.module.clone()) 40 | })?; 41 | 42 | if let Some(text) = module.render(¶ms.format, context)? 43 | && !text.is_empty() 44 | { 45 | let has_prefix = !params.prefix.is_empty(); 46 | let has_suffix = !params.suffix.is_empty(); 47 | let styled = !params.style.is_empty() && !no_color; 48 | 49 | if styled { 50 | let style = AnsiStyle::parse(¶ms.style).map_err(|error| { 51 | crate::error::PromptError::StyleError { 52 | module: params.module.clone(), 53 | error, 54 | } 55 | })?; 56 | 57 | style.write_start_codes(&mut output, context.shell); 58 | if has_prefix { 59 | output.push_str(¶ms.prefix); 60 | } 61 | output.push_str(&text); 62 | if has_suffix { 63 | output.push_str(¶ms.suffix); 64 | } 65 | style.write_reset(&mut output, context.shell); 66 | } else { 67 | if has_prefix { 68 | output.push_str(¶ms.prefix); 69 | } 70 | output.push_str(&text); 71 | if has_suffix { 72 | output.push_str(¶ms.suffix); 73 | } 74 | } 75 | } 76 | } 77 | } 78 | } 79 | 80 | Ok(output) 81 | } 82 | 83 | /// Get an iterator over the tokens in this template 84 | pub fn tokens(&self) -> impl Iterator> { 85 | self.tokens.iter() 86 | } 87 | 88 | /// Get the number of tokens in this template 89 | pub fn token_count(&self) -> usize { 90 | self.tokens.len() 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /.github/workflows/openwrt.yml: -------------------------------------------------------------------------------- 1 | name: OpenWrt 2 | on: 3 | workflow_run: 4 | workflows: [ "Release" ] 5 | types: [ completed ] 6 | 7 | jobs: 8 | ipk: 9 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 10 | runs-on: ubuntu-24.04 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | include: 15 | - { arch: x86_64, target: x86_64-unknown-linux-musl, asset_os: ubuntu-24.04, sdk: ghcr.io/openwrt/sdk:x86_64-openwrt-23.05 } 16 | - { arch: aarch64_cortex-a53, target: aarch64-unknown-linux-musl, asset_os: ubuntu-24.04, sdk: ghcr.io/openwrt/sdk:armvirt-64-openwrt-23.05 } 17 | - { arch: arm_cortex-a7, target: armv7-unknown-linux-musleabihf, asset_os: ubuntu-24.04, sdk: ghcr.io/openwrt/sdk:arm-cortex-a7_neon-vfpv4-openwrt-23.05 } 18 | container: 19 | image: ${{ matrix.sdk }} 20 | options: --user root 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Resolve release metadata 25 | id: meta 26 | env: 27 | RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} 28 | RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} 29 | run: | 30 | set -euo pipefail 31 | git fetch --force --tags 32 | tag="${RUN_HEAD_BRANCH}" 33 | if [[ -z "$tag" ]]; then 34 | tag="$(git tag --points-at "$RUN_HEAD_SHA" | head -n1)" 35 | fi 36 | if [[ -z "$tag" ]]; then 37 | echo "Unable to determine tag for ${RUN_HEAD_SHA}" >&2 38 | exit 1 39 | fi 40 | echo "tag=$tag" >> "$GITHUB_OUTPUT" 41 | echo "version=${tag#v}" >> "$GITHUB_OUTPUT" 42 | 43 | - name: Download and verify release archive 44 | env: 45 | TAG: ${{ steps.meta.outputs.tag }} 46 | REPO: ${{ github.repository }} 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | run: | 49 | set -euxo pipefail 50 | mkdir -p /work && cd /work 51 | file="prmt-${{ matrix.target }}-${{ matrix.asset_os }}.tar.gz" 52 | curl -sSfL -H "Authorization: Bearer ${GITHUB_TOKEN}" -o "$file" \ 53 | "https://github.com/${REPO}/releases/download/${TAG}/$file" 54 | curl -sSfL -H "Authorization: Bearer ${GITHUB_TOKEN}" -o "${file}.sha256" \ 55 | "https://github.com/${REPO}/releases/download/${TAG}/${file}.sha256" 56 | sha256sum -c "${file}.sha256" 57 | tar -xzf "$file" -C /work 58 | 59 | - name: Build .ipk 60 | env: 61 | TAG: ${{ steps.meta.outputs.tag }} 62 | VERSION: ${{ steps.meta.outputs.version }} 63 | REPO: ${{ github.repository }} 64 | ARCH: ${{ matrix.arch }} 65 | run: | 66 | set -euxo pipefail 67 | cd /work 68 | d=$(find . -maxdepth 1 -type d -name 'prmt-*' | head -n1) 69 | test -n "$d" 70 | install -Dm755 "$d/prmt" ./pkg/usr/bin/prmt 71 | install -Dm644 "$d/LICENSE" ./pkg/usr/share/licenses/prmt/LICENSE 72 | 73 | mkdir -p ./pkg/CONTROL 74 | { 75 | printf 'Package: prmt\n' 76 | printf 'Version: %s\n' "$VERSION" 77 | printf 'Architecture: %s\n' "$ARCH" 78 | printf 'Section: utils\n' 79 | printf 'Priority: optional\n' 80 | printf 'Depends:\n' 81 | printf 'Source: https://github.com/%s\n' "$REPO" 82 | printf 'Description: Ultra-fast customizable shell prompt (static Rust binary)\n' 83 | } > ./pkg/CONTROL/control 84 | 85 | ipkg-build -o root -g root ./pkg 86 | mkdir -p /out 87 | mv *.ipk /out/ 88 | 89 | - uses: actions/upload-artifact@v4 90 | with: 91 | name: openwrt-${{ matrix.arch }} 92 | path: | 93 | /out/*.ipk 94 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | RUST_BACKTRACE: 1 12 | 13 | jobs: 14 | test: 15 | name: Test 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | matrix: 19 | os: [ubuntu-latest, windows-latest, macos-latest] 20 | rust: [stable] 21 | steps: 22 | - uses: actions/checkout@v5 23 | 24 | - name: Install Rust 25 | uses: dtolnay/rust-toolchain@master 26 | with: 27 | toolchain: ${{ matrix.rust }} 28 | components: rustfmt, clippy 29 | 30 | - name: Cache cargo registry 31 | uses: actions/cache@v4 32 | with: 33 | path: ~/.cargo/registry 34 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} 35 | 36 | - name: Cache cargo index 37 | uses: actions/cache@v4 38 | with: 39 | path: ~/.cargo/git 40 | key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} 41 | 42 | - name: Cache cargo build 43 | uses: actions/cache@v4 44 | with: 45 | path: target 46 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} 47 | 48 | - name: Check formatting 49 | run: cargo fmt -- --check 50 | if: matrix.rust == 'stable' 51 | 52 | - name: Run clippy 53 | run: cargo clippy -- -D warnings 54 | if: matrix.rust == 'stable' 55 | 56 | - name: Build 57 | run: cargo build --verbose 58 | 59 | - name: Run tests 60 | run: cargo test --verbose 61 | 62 | - name: Build release 63 | run: cargo build --release --verbose 64 | 65 | msrv: 66 | name: Check MSRV 67 | runs-on: ubuntu-latest 68 | steps: 69 | - uses: actions/checkout@v5 70 | 71 | - name: Install Rust 72 | uses: dtolnay/rust-toolchain@master 73 | with: 74 | toolchain: stable 75 | 76 | - name: Check MSRV 77 | run: cargo check --verbose 78 | 79 | coverage: 80 | name: Code Coverage 81 | runs-on: ubuntu-latest 82 | steps: 83 | - uses: actions/checkout@v5 84 | 85 | - name: Install Rust stable 86 | uses: dtolnay/rust-toolchain@stable 87 | with: 88 | components: llvm-tools-preview 89 | 90 | - name: Install cargo-llvm-cov 91 | uses: taiki-e/install-action@cargo-llvm-cov 92 | 93 | - name: Generate coverage 94 | run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info 95 | 96 | - name: Upload coverage to Codecov 97 | uses: codecov/codecov-action@v5 98 | with: 99 | token: ${{ secrets.CODECOV_TOKEN }} 100 | files: lcov.info 101 | fail_ci_if_error: false 102 | 103 | bench: 104 | name: Benchmarks 105 | runs-on: ubuntu-latest 106 | steps: 107 | - uses: actions/checkout@v5 108 | 109 | - name: Install Rust stable 110 | uses: dtolnay/rust-toolchain@stable 111 | 112 | - name: Run benchmarks 113 | run: cargo bench --no-run 114 | 115 | security-audit: 116 | name: Security Audit 117 | runs-on: ubuntu-latest 118 | steps: 119 | - uses: actions/checkout@v5 120 | 121 | - name: Install cargo-audit 122 | run: cargo install cargo-audit 123 | 124 | - name: Run security audit 125 | run: cargo audit 126 | -------------------------------------------------------------------------------- /src/modules/env.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{PromptError, Result}; 2 | use crate::module_trait::{Module, ModuleContext}; 3 | use std::env; 4 | 5 | pub struct EnvModule; 6 | 7 | impl Default for EnvModule { 8 | fn default() -> Self { 9 | Self::new() 10 | } 11 | } 12 | 13 | impl EnvModule { 14 | pub fn new() -> Self { 15 | Self 16 | } 17 | } 18 | 19 | impl Module for EnvModule { 20 | fn render(&self, format: &str, _context: &ModuleContext) -> Result> { 21 | if format.is_empty() { 22 | return Err(PromptError::InvalidFormat { 23 | module: "env".to_string(), 24 | format: format.to_string(), 25 | valid_formats: "Provide an environment variable name, e.g., {env:blue:USER}" 26 | .to_string(), 27 | }); 28 | } 29 | 30 | match env::var_os(format) { 31 | None => Ok(None), 32 | Some(value) if value.is_empty() => Ok(None), 33 | Some(value) => Ok(Some(value.to_string_lossy().into_owned())), 34 | } 35 | } 36 | } 37 | 38 | #[cfg(test)] 39 | mod tests { 40 | use super::*; 41 | use serial_test::serial; 42 | use std::env; 43 | use std::ffi::OsString; 44 | 45 | struct EnvVarGuard { 46 | key: String, 47 | original: Option, 48 | } 49 | 50 | impl EnvVarGuard { 51 | fn set(key: &str, value: &str) -> Self { 52 | let original = env::var_os(key); 53 | unsafe { 54 | env::set_var(key, value); 55 | } 56 | Self { 57 | key: key.to_string(), 58 | original, 59 | } 60 | } 61 | 62 | fn unset(key: &str) -> Self { 63 | let original = env::var_os(key); 64 | unsafe { 65 | env::remove_var(key); 66 | } 67 | Self { 68 | key: key.to_string(), 69 | original, 70 | } 71 | } 72 | } 73 | 74 | impl Drop for EnvVarGuard { 75 | fn drop(&mut self) { 76 | if let Some(value) = &self.original { 77 | unsafe { 78 | env::set_var(&self.key, value); 79 | } 80 | } else { 81 | unsafe { 82 | env::remove_var(&self.key); 83 | } 84 | } 85 | } 86 | } 87 | 88 | #[test] 89 | #[serial] 90 | fn renders_value_when_variable_is_present() { 91 | let module = EnvModule::new(); 92 | let _guard = EnvVarGuard::set("PRMT_TEST_ENV_PRESENT", "zenpie"); 93 | 94 | let value = module 95 | .render("PRMT_TEST_ENV_PRESENT", &ModuleContext::default()) 96 | .unwrap(); 97 | 98 | assert_eq!(value, Some("zenpie".to_string())); 99 | } 100 | 101 | #[test] 102 | #[serial] 103 | fn returns_none_when_variable_missing() { 104 | let module = EnvModule::new(); 105 | let _guard = EnvVarGuard::unset("PRMT_TEST_ENV_MISSING"); 106 | 107 | let value = module 108 | .render("PRMT_TEST_ENV_MISSING", &ModuleContext::default()) 109 | .unwrap(); 110 | 111 | assert_eq!(value, None); 112 | } 113 | 114 | #[test] 115 | #[serial] 116 | fn returns_none_when_variable_empty() { 117 | let module = EnvModule::new(); 118 | let _guard = EnvVarGuard::set("PRMT_TEST_ENV_EMPTY", ""); 119 | 120 | let value = module 121 | .render("PRMT_TEST_ENV_EMPTY", &ModuleContext::default()) 122 | .unwrap(); 123 | 124 | assert_eq!(value, None); 125 | } 126 | 127 | #[test] 128 | fn errors_when_format_missing() { 129 | let module = EnvModule::new(); 130 | let err = module.render("", &ModuleContext::default()).unwrap_err(); 131 | 132 | match err { 133 | PromptError::InvalidFormat { module, .. } => assert_eq!(module, "env"), 134 | other => panic!("expected invalid format error, got {other:?}"), 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /.github/workflows/aur.yml: -------------------------------------------------------------------------------- 1 | name: AUR 2 | on: 3 | workflow_run: 4 | workflows: [ "Release" ] 5 | types: [ completed ] 6 | 7 | jobs: 8 | pkgbuild: 9 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 10 | runs-on: ubuntu-24.04 11 | container: archlinux:latest 12 | steps: 13 | - name: Prepare Arch toolchain 14 | run: | 15 | pacman -Syu --noconfirm base-devel git fakeroot curl 16 | 17 | - uses: actions/checkout@v4 18 | 19 | - name: Create builder user 20 | run: | 21 | useradd -m builder 22 | chown -R builder:builder "$GITHUB_WORKSPACE" 23 | 24 | - name: Trust workspace ownership 25 | run: git config --global --add safe.directory "$GITHUB_WORKSPACE" 26 | 27 | - name: Resolve release metadata 28 | id: meta 29 | env: 30 | RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} 31 | RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} 32 | run: | 33 | set -euo pipefail 34 | git fetch --force --tags 35 | tag="${RUN_HEAD_BRANCH}" 36 | if [[ -z "$tag" ]]; then 37 | tag="$(git tag --points-at "$RUN_HEAD_SHA" | head -n1)" 38 | fi 39 | if [[ -z "$tag" ]]; then 40 | echo "Unable to determine tag for ${RUN_HEAD_SHA}" >&2 41 | exit 1 42 | fi 43 | echo "tag=$tag" >> "$GITHUB_OUTPUT" 44 | echo "version=${tag#v}" >> "$GITHUB_OUTPUT" 45 | 46 | - name: Resolve release checksums 47 | id: sums 48 | env: 49 | REPO: ${{ github.repository }} 50 | TAG: ${{ steps.meta.outputs.tag }} 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | run: | 53 | set -euo pipefail 54 | specs=( 55 | "x86_64:x86_64-unknown-linux-musl:ubuntu-24.04" 56 | "aarch64:aarch64-unknown-linux-musl:ubuntu-24.04" 57 | "armv7h:armv7-unknown-linux-musleabihf:ubuntu-24.04" 58 | ) 59 | for spec in "${specs[@]}"; do 60 | IFS=':' read -r arch target os_label <<<"$spec" 61 | asset="prmt-${target}-${os_label}.tar.gz" 62 | url="https://github.com/${REPO}/releases/download/${TAG}/${asset}.sha256" 63 | checksum="$(curl -sSfL -H "Authorization: Bearer ${GITHUB_TOKEN}" "$url" | awk '{print $1}')" 64 | echo "sha256_${arch}=${checksum}" >> "$GITHUB_OUTPUT" 65 | echo "asset_${arch}=${asset}" >> "$GITHUB_OUTPUT" 66 | done 67 | 68 | - name: Generate PKGBUILD and .SRCINFO 69 | env: 70 | VERSION: ${{ steps.meta.outputs.version }} 71 | SHA256_X86_64: ${{ steps.sums.outputs.sha256_x86_64 }} 72 | SHA256_AARCH64: ${{ steps.sums.outputs.sha256_aarch64 }} 73 | SHA256_ARMV7H: ${{ steps.sums.outputs.sha256_armv7h }} 74 | ASSET_X86_64: ${{ steps.sums.outputs.asset_x86_64 }} 75 | ASSET_AARCH64: ${{ steps.sums.outputs.asset_aarch64 }} 76 | ASSET_ARMV7H: ${{ steps.sums.outputs.asset_armv7h }} 77 | run: | 78 | set -euo pipefail 79 | cat > PKGBUILD < .SRCINFO 106 | ' 107 | 108 | - uses: actions/upload-artifact@v4 109 | with: 110 | name: aur 111 | path: | 112 | PKGBUILD 113 | .SRCINFO 114 | -------------------------------------------------------------------------------- /src/detector.rs: -------------------------------------------------------------------------------- 1 | use std::collections::hash_map::Entry; 2 | use std::collections::{HashMap, HashSet}; 3 | use std::env; 4 | use std::path::{Path, PathBuf}; 5 | use std::sync::Arc; 6 | 7 | const MAX_TRAVERSAL_DEPTH: usize = 64; 8 | 9 | #[derive(Debug, Clone)] 10 | pub struct DetectionContext { 11 | markers: Arc>, 12 | } 13 | 14 | impl DetectionContext { 15 | pub fn empty() -> Self { 16 | Self { 17 | markers: Arc::new(HashMap::new()), 18 | } 19 | } 20 | 21 | pub fn get(&self, name: &str) -> Option<&Path> { 22 | self.markers.get(name).map(|path| path.as_path()) 23 | } 24 | } 25 | 26 | impl Default for DetectionContext { 27 | fn default() -> Self { 28 | DetectionContext::empty() 29 | } 30 | } 31 | 32 | pub fn detect(required: &HashSet<&'static str>) -> DetectionContext { 33 | if required.is_empty() { 34 | return DetectionContext::empty(); 35 | } 36 | 37 | let Ok(mut current_dir) = env::current_dir() else { 38 | return DetectionContext::empty(); 39 | }; 40 | 41 | let mut found: HashMap<&'static str, PathBuf> = HashMap::with_capacity(required.len()); 42 | let mut depth = 0usize; 43 | let mut candidate = PathBuf::new(); 44 | 45 | loop { 46 | for &marker in required { 47 | match found.entry(marker) { 48 | Entry::Occupied(_) => continue, 49 | Entry::Vacant(slot) => { 50 | candidate.clear(); 51 | candidate.push(¤t_dir); 52 | candidate.push(marker); 53 | if let Ok(true) = candidate.try_exists() { 54 | slot.insert(candidate.clone()); 55 | } 56 | } 57 | } 58 | } 59 | 60 | if found.len() == required.len() { 61 | break; 62 | } 63 | 64 | if depth >= MAX_TRAVERSAL_DEPTH { 65 | break; 66 | } 67 | 68 | if !current_dir.pop() { 69 | break; 70 | } 71 | depth += 1; 72 | } 73 | 74 | DetectionContext { 75 | markers: Arc::new(found), 76 | } 77 | } 78 | 79 | #[cfg(test)] 80 | mod tests { 81 | use super::*; 82 | use serial_test::serial; 83 | use std::env; 84 | use std::fs; 85 | use std::path::Path; 86 | use tempfile::tempdir; 87 | 88 | struct DirGuard { 89 | original: PathBuf, 90 | } 91 | 92 | impl DirGuard { 93 | fn enter(path: &Path) -> Self { 94 | let original = env::current_dir().unwrap(); 95 | env::set_current_dir(path).unwrap(); 96 | Self { original } 97 | } 98 | } 99 | 100 | impl Drop for DirGuard { 101 | fn drop(&mut self) { 102 | let _ = env::set_current_dir(&self.original); 103 | } 104 | } 105 | 106 | #[test] 107 | fn detect_returns_empty_for_no_requirements() { 108 | let ctx = detect(&HashSet::new()); 109 | assert!(ctx.get("Cargo.toml").is_none()); 110 | } 111 | 112 | #[test] 113 | #[serial] 114 | fn detect_finds_markers_up_the_tree() { 115 | let tmp = tempdir().unwrap(); 116 | let project = tmp.path().join("project"); 117 | let nested = project.join("src/bin"); 118 | fs::create_dir_all(&nested).unwrap(); 119 | fs::write(project.join("Cargo.toml"), b"[package]").unwrap(); 120 | fs::create_dir_all(project.join(".git")).unwrap(); 121 | 122 | let _guard = DirGuard::enter(&nested); 123 | 124 | let required: HashSet<&'static str> = [".git", "Cargo.toml"].into_iter().collect(); 125 | let ctx = detect(&required); 126 | 127 | let cargo = ctx 128 | .get("Cargo.toml") 129 | .expect("detector should find Cargo.toml"); 130 | assert!( 131 | cargo.ends_with("Cargo.toml"), 132 | "expected Cargo.toml to be the detected file" 133 | ); 134 | assert_eq!( 135 | cargo.parent().and_then(|p| p.file_name()), 136 | project.file_name() 137 | ); 138 | 139 | let git = ctx.get(".git").expect("detector should find .git"); 140 | assert!(git.ends_with(".git"), "expected .git directory to match"); 141 | assert_eq!( 142 | git.parent().and_then(|p| p.file_name()), 143 | project.file_name() 144 | ); 145 | } 146 | 147 | #[test] 148 | #[serial] 149 | fn detect_handles_missing_markers() { 150 | let tmp = tempdir().unwrap(); 151 | let nested = tmp.path().join("a/b/c"); 152 | fs::create_dir_all(&nested).unwrap(); 153 | 154 | let _guard = DirGuard::enter(&nested); 155 | 156 | let required: HashSet<&'static str> = ["Cargo.toml"].into_iter().collect(); 157 | let ctx = detect(&required); 158 | 159 | assert!(ctx.get("Cargo.toml").is_none()); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /benches/prompt_bench.rs: -------------------------------------------------------------------------------- 1 | use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; 2 | use prmt::detector::{DetectionContext, detect}; 3 | use prmt::style::Shell; 4 | use prmt::{ModuleContext, ModuleRegistry, Template, execute}; 5 | use std::collections::HashSet; 6 | use std::hint::black_box; 7 | 8 | fn setup_registry() -> ModuleRegistry { 9 | use prmt::modules::*; 10 | use std::sync::Arc; 11 | 12 | let mut registry = ModuleRegistry::new(); 13 | registry.register("path", Arc::new(path::PathModule)); 14 | registry.register("git", Arc::new(git::GitModule)); 15 | registry.register("rust", Arc::new(rust::RustModule)); 16 | registry.register("node", Arc::new(node::NodeModule)); 17 | registry.register("ok", Arc::new(ok::OkModule)); 18 | registry.register("fail", Arc::new(fail::FailModule)); 19 | registry 20 | } 21 | 22 | fn detection_for(markers: &[&'static str]) -> DetectionContext { 23 | if markers.is_empty() { 24 | return DetectionContext::default(); 25 | } 26 | 27 | let required: HashSet<&str> = markers.iter().copied().collect(); 28 | 29 | detect(&required) 30 | } 31 | 32 | fn ctx(no_version: bool, exit_code: Option, markers: &[&'static str]) -> ModuleContext { 33 | ModuleContext { 34 | no_version, 35 | exit_code, 36 | detection: detection_for(markers), 37 | shell: Shell::None, 38 | } 39 | } 40 | 41 | fn bench_parser(c: &mut Criterion) { 42 | let mut group = c.benchmark_group("parser"); 43 | 44 | group.bench_function("simple_text", |b| { 45 | b.iter(|| { 46 | prmt::parse(black_box("Hello, World! This is a simple text")); 47 | }); 48 | }); 49 | 50 | group.bench_function("single_placeholder", |b| { 51 | b.iter(|| { 52 | prmt::parse(black_box("{path:cyan:short:[:]")); 53 | }); 54 | }); 55 | 56 | group.bench_function("mixed_content", |b| { 57 | b.iter(|| { 58 | prmt::parse(black_box( 59 | "Welcome {user:yellow} to {path:cyan:short} [{git:purple}]", 60 | )); 61 | }); 62 | }); 63 | 64 | group.bench_function("complex_format", |b| { 65 | let format = "{path:cyan} {rust:red} {node:green} {git:purple}"; 66 | b.iter(|| { 67 | prmt::parse(black_box(format)); 68 | }); 69 | }); 70 | 71 | group.finish(); 72 | } 73 | 74 | fn bench_renderer(c: &mut Criterion) { 75 | let mut group = c.benchmark_group("renderer"); 76 | 77 | let registry = setup_registry(); 78 | let ctx_path = ctx(true, Some(0), &[]); 79 | let ctx_git = ctx(true, Some(0), &[".git"]); 80 | let ctx_full = ctx(true, Some(0), &[".git", "Cargo.toml", "package.json"]); 81 | 82 | group.bench_function("path_only", |b| { 83 | let template = Template::new("{path:cyan}"); 84 | b.iter(|| template.render(black_box(®istry), black_box(&ctx_path))); 85 | }); 86 | 87 | group.bench_function("path_and_git", |b| { 88 | let template = Template::new("{path:cyan} {git:purple}"); 89 | b.iter(|| template.render(black_box(®istry), black_box(&ctx_git))); 90 | }); 91 | 92 | group.bench_function("all_modules", |b| { 93 | let template = Template::new("{path:cyan} {rust:red} {node:green} {git:purple}"); 94 | b.iter(|| template.render(black_box(®istry), black_box(&ctx_full))); 95 | }); 96 | 97 | group.finish(); 98 | } 99 | 100 | fn bench_end_to_end(c: &mut Criterion) { 101 | let mut group = c.benchmark_group("end_to_end"); 102 | 103 | let formats = vec![ 104 | ("minimal", "{path}"), 105 | ("typical", "{path:cyan} {git:purple}"), 106 | ( 107 | "complex", 108 | "{path:cyan:short} {rust:red} {node:green} {git:purple:full}", 109 | ), 110 | ]; 111 | 112 | for (name, format) in formats { 113 | group.bench_with_input(BenchmarkId::from_parameter(name), &format, |b, &format| { 114 | b.iter(|| execute(black_box(format), true, Some(0), false)); 115 | }); 116 | } 117 | 118 | group.finish(); 119 | } 120 | 121 | fn bench_git_module(c: &mut Criterion) { 122 | use prmt::Module; 123 | use prmt::modules::git::GitModule; 124 | 125 | let mut group = c.benchmark_group("git_module"); 126 | 127 | let module = GitModule::new(); 128 | let context = ctx(false, None, &[".git"]); 129 | 130 | group.bench_function("branch_only", |b| { 131 | b.iter(|| module.render(black_box("short"), black_box(&context))); 132 | }); 133 | 134 | group.bench_function("with_status", |b| { 135 | b.iter(|| module.render(black_box("full"), black_box(&context))); 136 | }); 137 | 138 | group.finish(); 139 | } 140 | 141 | fn bench_version_modules(c: &mut Criterion) { 142 | use prmt::Module; 143 | 144 | let mut group = c.benchmark_group("version_modules"); 145 | 146 | let context_no_version = ctx(true, None, &["Cargo.toml"]); 147 | let context_with_version = ctx(false, None, &["Cargo.toml"]); 148 | 149 | // Benchmark Rust module 150 | { 151 | use prmt::modules::rust::RustModule; 152 | let module = RustModule::new(); 153 | 154 | group.bench_function("rust_no_version", |b| { 155 | b.iter(|| module.render(black_box(""), black_box(&context_no_version))); 156 | }); 157 | 158 | group.bench_function("rust_with_version_memoized", |b| { 159 | // Warm up memoized value 160 | let _ = module.render("", &context_with_version); 161 | 162 | b.iter(|| module.render(black_box(""), black_box(&context_with_version))); 163 | }); 164 | } 165 | 166 | group.finish(); 167 | } 168 | 169 | criterion_group!( 170 | benches, 171 | bench_parser, 172 | bench_renderer, 173 | bench_end_to_end, 174 | bench_git_module, 175 | bench_version_modules 176 | ); 177 | criterion_main!(benches); 178 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: [ "v*" ] 5 | 6 | permissions: 7 | contents: write 8 | 9 | jobs: 10 | build: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | include: 16 | - { os: ubuntu-24.04, target: x86_64-unknown-linux-gnu } 17 | - { os: ubuntu-24.04, target: x86_64-unknown-linux-musl } 18 | - { os: ubuntu-24.04, target: aarch64-unknown-linux-musl } 19 | - { os: ubuntu-24.04, target: armv7-unknown-linux-musleabihf } 20 | - { os: macos-15-intel, target: x86_64-apple-darwin } 21 | - { os: macos-14, target: aarch64-apple-darwin } 22 | - { os: macos-15, target: aarch64-apple-darwin } 23 | - { os: windows-2022, target: x86_64-pc-windows-msvc } 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - uses: dtolnay/rust-toolchain@stable 29 | with: 30 | targets: ${{ matrix.target }} 31 | 32 | - uses: Swatinem/rust-cache@v2 33 | 34 | - if: runner.os == 'Linux' && contains(matrix.target, 'musl') 35 | uses: mlugg/setup-zig@v2 36 | with: 37 | version: 0.15.2 38 | 39 | - name: Install cargo-zigbuild 40 | if: runner.os == 'Linux' && contains(matrix.target, 'musl') 41 | run: cargo install cargo-zigbuild --locked 42 | 43 | - name: Build binary 44 | shell: bash 45 | env: 46 | TARGET: ${{ matrix.target }} 47 | USE_ZIGBUILD: ${{ runner.os == 'Linux' && contains(matrix.target, 'musl') }} 48 | run: | 49 | set -euo pipefail 50 | if [[ "$USE_ZIGBUILD" == "true" ]]; then 51 | cargo zigbuild --release --locked --target "$TARGET" 52 | else 53 | cargo build --release --locked --target "$TARGET" 54 | fi 55 | 56 | - name: Package archive (Unix) 57 | if: runner.os != 'Windows' 58 | id: package_unix 59 | shell: bash 60 | env: 61 | TARGET: ${{ matrix.target }} 62 | OS_LABEL: ${{ matrix.os }} 63 | run: | 64 | set -euo pipefail 65 | artifact="prmt-${TARGET}-${OS_LABEL}" 66 | dist="$GITHUB_WORKSPACE/dist" 67 | staging="$dist/$artifact" 68 | rm -rf "$staging" 69 | mkdir -p "$staging" 70 | cp "target/${TARGET}/release/prmt" "$staging/prmt" 71 | cp LICENSE "$staging/LICENSE" 72 | tar -C "$dist" -czf "$dist/${artifact}.tar.gz" "$artifact" 73 | pushd "$dist" >/dev/null 74 | if command -v sha256sum >/dev/null 2>&1; then 75 | sha256sum "${artifact}.tar.gz" > "${artifact}.tar.gz.sha256" 76 | else 77 | shasum -a 256 "${artifact}.tar.gz" > "${artifact}.tar.gz.sha256" 78 | fi 79 | popd >/dev/null 80 | 81 | - name: Package archive (Windows) 82 | if: runner.os == 'Windows' 83 | id: package_windows 84 | shell: pwsh 85 | env: 86 | TARGET: ${{ matrix.target }} 87 | OS_LABEL: ${{ matrix.os }} 88 | run: | 89 | $artifact = "prmt-$env:TARGET-$env:OS_LABEL" 90 | $dist = Join-Path $env:GITHUB_WORKSPACE 'dist' 91 | $binary = Join-Path $env:GITHUB_WORKSPACE "target/$env:TARGET/release/prmt.exe" 92 | if (!(Test-Path $dist)) { 93 | New-Item $dist -ItemType Directory | Out-Null 94 | } 95 | $staging = Join-Path $dist $artifact 96 | if (Test-Path $staging) { 97 | Remove-Item $staging -Recurse -Force 98 | } 99 | New-Item $staging -ItemType Directory -Force | Out-Null 100 | Copy-Item $binary "$staging/prmt.exe" 101 | Copy-Item "LICENSE" "$staging/LICENSE" 102 | Compress-Archive -Path "$staging/*" -DestinationPath "$dist/$artifact.zip" -Force 103 | Push-Location $dist 104 | $hash = Get-FileHash "$artifact.zip" -Algorithm SHA256 105 | $entry = "{0} {1}" -f $hash.Hash.ToLower(), "$artifact.zip" 106 | Set-Content -Path "$artifact.zip.sha256" -Value $entry -Encoding ASCII 107 | Pop-Location 108 | 109 | - name: Upload build artifact (Unix) 110 | if: runner.os != 'Windows' 111 | uses: actions/upload-artifact@v4 112 | with: 113 | name: prmt-${{ matrix.target }}-${{ matrix.os }} 114 | path: | 115 | dist/prmt-${{ matrix.target }}-${{ matrix.os }}.tar.gz 116 | dist/prmt-${{ matrix.target }}-${{ matrix.os }}.tar.gz.sha256 117 | 118 | - name: Upload build artifact (Windows) 119 | if: runner.os == 'Windows' 120 | uses: actions/upload-artifact@v4 121 | with: 122 | name: prmt-${{ matrix.target }}-${{ matrix.os }} 123 | path: | 124 | dist/prmt-${{ matrix.target }}-${{ matrix.os }}.zip 125 | dist/prmt-${{ matrix.target }}-${{ matrix.os }}.zip.sha256 126 | 127 | release: 128 | needs: build 129 | runs-on: ubuntu-24.04 130 | steps: 131 | - uses: actions/download-artifact@v4 132 | with: 133 | path: artifacts 134 | merge-multiple: true 135 | 136 | - name: Publish release 137 | env: 138 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 139 | TAG_NAME: ${{ github.ref_name }} 140 | GH_REPO: ${{ github.repository }} 141 | run: | 142 | set -euo pipefail 143 | tag="$TAG_NAME" 144 | state="$(gh release view "$tag" --json draft -q '.draft' 2>/dev/null || echo 'not_found')" 145 | publish_after_upload=false 146 | if [[ "$state" == "not_found" ]]; then 147 | gh release create "$tag" --draft --title "Release $tag" --generate-notes 148 | publish_after_upload=true 149 | elif [[ "$state" == "true" ]]; then 150 | publish_after_upload=true 151 | fi 152 | 153 | gh release upload "$tag" artifacts/* --clobber 154 | 155 | if [[ "$publish_after_upload" == "true" ]]; then 156 | gh release edit "$tag" --draft=false --latest 157 | fi 158 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use is_terminal::IsTerminal; 2 | use std::env; 3 | use std::process::ExitCode; 4 | use std::str::FromStr; 5 | use std::time::Instant; 6 | 7 | mod detector; 8 | mod error; 9 | mod executor; 10 | mod memo; 11 | mod module_trait; 12 | mod modules; 13 | mod parser; 14 | mod registry; 15 | mod style; 16 | 17 | const VERSION: &str = env!("CARGO_PKG_VERSION"); 18 | const HELP: &str = "\ 19 | prmt - Ultra-fast customizable shell prompt generator 20 | 21 | USAGE: 22 | prmt [OPTIONS] [FORMAT] 23 | 24 | ARGS: 25 | Format string (default from PRMT_FORMAT env var) 26 | 27 | OPTIONS: 28 | -f, --format Format string 29 | -n, --no-version Skip version detection for speed 30 | -d, --debug Show debug information and timing 31 | -b, --bench Run benchmark (100 iterations) 32 | --code Exit code of the last command (for ok/fail modules) 33 | --no-color Disable colored output 34 | --shell Wrap ANSI escapes for the specified shell (bash, zsh, none) 35 | -h, --help Print help 36 | -V, --version Print version 37 | "; 38 | 39 | struct Cli { 40 | format: Option, 41 | no_version: bool, 42 | debug: bool, 43 | bench: bool, 44 | code: Option, 45 | no_color: bool, 46 | shell: Option, 47 | } 48 | 49 | fn parse_args() -> Result { 50 | use lexopt::prelude::*; 51 | 52 | let mut format = None; 53 | let mut no_version = false; 54 | let mut debug = false; 55 | let mut bench = false; 56 | let mut code = None; 57 | let mut no_color = false; 58 | let mut shell = None; 59 | 60 | let mut parser = lexopt::Parser::from_env(); 61 | while let Some(arg) = parser.next()? { 62 | match arg { 63 | Short('h') | Long("help") => { 64 | print!("{}", HELP); 65 | std::process::exit(0); 66 | } 67 | Short('V') | Long("version") => { 68 | println!("prmt {}", VERSION); 69 | std::process::exit(0); 70 | } 71 | Short('f') | Long("format") => { 72 | format = Some(parser.value()?.string()?); 73 | } 74 | Short('n') | Long("no-version") => { 75 | no_version = true; 76 | } 77 | Short('d') | Long("debug") => { 78 | debug = true; 79 | } 80 | Short('b') | Long("bench") => { 81 | bench = true; 82 | } 83 | Long("code") => { 84 | code = Some(parser.value()?.parse()?); 85 | } 86 | Long("no-color") => { 87 | no_color = true; 88 | } 89 | Long("shell") => { 90 | let value = parser.value()?.string()?; 91 | shell = Some(style::Shell::from_str(&value)?); 92 | } 93 | Value(val) => { 94 | if format.is_none() { 95 | format = Some(val.string()?); 96 | } 97 | } 98 | _ => return Err(arg.unexpected()), 99 | } 100 | } 101 | 102 | Ok(Cli { 103 | format, 104 | no_version, 105 | debug, 106 | bench, 107 | code, 108 | no_color, 109 | shell, 110 | }) 111 | } 112 | 113 | fn detect_shell_from_env() -> style::Shell { 114 | if env::var("ZSH_VERSION").is_ok() { 115 | return style::Shell::Zsh; 116 | } 117 | 118 | if let Ok(shell_path) = env::var("SHELL") { 119 | if shell_path.ends_with("zsh") { 120 | return style::Shell::Zsh; 121 | } 122 | if shell_path.ends_with("bash") { 123 | return style::Shell::Bash; 124 | } 125 | } 126 | 127 | style::Shell::None 128 | } 129 | 130 | fn resolve_shell(cli_shell: Option) -> style::Shell { 131 | if let Some(shell) = cli_shell { 132 | return shell; 133 | } 134 | 135 | if std::io::stdout().is_terminal() { 136 | detect_shell_from_env() 137 | } else { 138 | style::Shell::None 139 | } 140 | } 141 | 142 | fn main() -> ExitCode { 143 | let cli = match parse_args() { 144 | Ok(cli) => cli, 145 | Err(e) => { 146 | eprintln!("Error: {}", e); 147 | eprintln!("Try 'prmt --help' for more information."); 148 | return ExitCode::FAILURE; 149 | } 150 | }; 151 | 152 | let format = cli 153 | .format 154 | .or_else(|| env::var("PRMT_FORMAT").ok()) 155 | .unwrap_or_else(|| "{path:cyan} {node:green} {git:purple}".to_string()); 156 | 157 | let shell = resolve_shell(cli.shell); 158 | 159 | let result = if cli.bench { 160 | handle_bench(&format, cli.no_version, cli.code, cli.no_color, shell) 161 | } else { 162 | handle_format( 163 | &format, 164 | cli.no_version, 165 | cli.debug, 166 | cli.code, 167 | cli.no_color, 168 | shell, 169 | ) 170 | }; 171 | 172 | match result { 173 | Ok(output) => { 174 | print!("{}", output); 175 | ExitCode::SUCCESS 176 | } 177 | Err(e) => { 178 | eprintln!("Error: {}", e); 179 | ExitCode::FAILURE 180 | } 181 | } 182 | } 183 | 184 | fn handle_format( 185 | format: &str, 186 | no_version: bool, 187 | debug: bool, 188 | exit_code: Option, 189 | no_color: bool, 190 | shell: style::Shell, 191 | ) -> error::Result { 192 | if debug { 193 | let start = Instant::now(); 194 | let output = executor::execute_with_shell(format, no_version, exit_code, no_color, shell)?; 195 | let elapsed = start.elapsed(); 196 | 197 | eprintln!("Format: {}", format); 198 | eprintln!("Execution time: {:.2}ms", elapsed.as_secs_f64() * 1000.0); 199 | 200 | Ok(output) 201 | } else { 202 | executor::execute_with_shell(format, no_version, exit_code, no_color, shell) 203 | } 204 | } 205 | 206 | fn handle_bench( 207 | format: &str, 208 | no_version: bool, 209 | exit_code: Option, 210 | no_color: bool, 211 | shell: style::Shell, 212 | ) -> error::Result { 213 | let mut times = Vec::new(); 214 | 215 | for _ in 0..100 { 216 | let start = Instant::now(); 217 | let _ = executor::execute_with_shell(format, no_version, exit_code, no_color, shell)?; 218 | times.push(start.elapsed()); 219 | } 220 | 221 | times.sort(); 222 | let min = times[0]; 223 | let max = times[99]; 224 | let avg: std::time::Duration = times.iter().sum::() / 100; 225 | let p99 = times[98]; 226 | 227 | Ok(format!( 228 | "100 runs: min={:.2}ms avg={:.2}ms max={:.2}ms p99={:.2}ms\n", 229 | min.as_secs_f64() * 1000.0, 230 | avg.as_secs_f64() * 1000.0, 231 | max.as_secs_f64() * 1000.0, 232 | p99.as_secs_f64() * 1000.0 233 | )) 234 | } 235 | -------------------------------------------------------------------------------- /src/modules/path.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{PromptError, Result}; 2 | use crate::module_trait::{Module, ModuleContext}; 3 | use std::env; 4 | use std::path::Path; 5 | use unicode_width::UnicodeWidthStr; 6 | 7 | pub struct PathModule; 8 | 9 | impl Default for PathModule { 10 | fn default() -> Self { 11 | Self::new() 12 | } 13 | } 14 | 15 | impl PathModule { 16 | pub fn new() -> Self { 17 | Self 18 | } 19 | } 20 | 21 | #[cfg(target_os = "windows")] 22 | fn normalize_separators(value: String) -> String { 23 | value.replace('\\', "/") 24 | } 25 | 26 | #[cfg(not(target_os = "windows"))] 27 | fn normalize_separators(value: String) -> String { 28 | value 29 | } 30 | 31 | fn normalize_relative_path(current_dir: &Path) -> String { 32 | let current_canon = current_dir 33 | .canonicalize() 34 | .unwrap_or_else(|_| current_dir.to_path_buf()); 35 | 36 | if let Some(home) = dirs::home_dir() { 37 | let home_canon = home.canonicalize().unwrap_or(home); 38 | if let Ok(stripped) = current_canon.strip_prefix(&home_canon) { 39 | if stripped.as_os_str().is_empty() { 40 | return "~".to_string(); 41 | } 42 | 43 | let mut result = String::from("~"); 44 | result.push(std::path::MAIN_SEPARATOR); 45 | result.push_str(&stripped.to_string_lossy()); 46 | return normalize_separators(result); 47 | } 48 | } 49 | 50 | normalize_separators(current_dir.to_string_lossy().to_string()) 51 | } 52 | 53 | impl Module for PathModule { 54 | fn render(&self, format: &str, _context: &ModuleContext) -> Result> { 55 | let current_dir = match env::current_dir() { 56 | Ok(d) => d, 57 | Err(_) => return Ok(None), 58 | }; 59 | 60 | match format { 61 | "" | "relative" | "r" => Ok(Some(normalize_relative_path(¤t_dir))), 62 | "absolute" | "a" | "f" => Ok(Some(current_dir.to_string_lossy().to_string())), 63 | "short" | "s" => Ok(current_dir 64 | .file_name() 65 | .and_then(|n| n.to_str()) 66 | .map(|s| s.to_string()) 67 | .or_else(|| Some(".".to_string()))), 68 | format if format.starts_with("truncate:") => { 69 | let max_width: usize = format 70 | .strip_prefix("truncate:") 71 | .and_then(|s| s.parse().ok()) 72 | .unwrap_or(30); 73 | 74 | let path = normalize_relative_path(¤t_dir); 75 | 76 | // Use unicode width for proper truncation 77 | let width = UnicodeWidthStr::width(path.as_str()); 78 | if width <= max_width { 79 | Ok(Some(path)) 80 | } else { 81 | // Truncate with ellipsis 82 | let ellipsis = "..."; 83 | let ellipsis_width = 3; 84 | let target_width = max_width.saturating_sub(ellipsis_width); 85 | 86 | let mut truncated = String::new(); 87 | let mut current_width = 0; 88 | 89 | for ch in path.chars() { 90 | let ch_width = UnicodeWidthStr::width(ch.to_string().as_str()); 91 | if current_width + ch_width > target_width { 92 | break; 93 | } 94 | truncated.push(ch); 95 | current_width += ch_width; 96 | } 97 | 98 | truncated.push_str(ellipsis); 99 | Ok(Some(truncated)) 100 | } 101 | } 102 | _ => Err(PromptError::InvalidFormat { 103 | module: "path".to_string(), 104 | format: format.to_string(), 105 | valid_formats: "relative, r, absolute, a, f, short, s, truncate:N".to_string(), 106 | }), 107 | } 108 | } 109 | } 110 | 111 | #[cfg(test)] 112 | mod tests { 113 | use super::*; 114 | use serial_test::serial; 115 | use std::fs; 116 | use std::time::{SystemTime, UNIX_EPOCH}; 117 | 118 | struct DirGuard { 119 | original: std::path::PathBuf, 120 | } 121 | 122 | impl DirGuard { 123 | fn change_to(path: &Path) -> Self { 124 | let original = env::current_dir().expect("current dir"); 125 | env::set_current_dir(path).expect("change current dir"); 126 | Self { original } 127 | } 128 | } 129 | 130 | impl Drop for DirGuard { 131 | fn drop(&mut self) { 132 | let _ = env::set_current_dir(&self.original); 133 | } 134 | } 135 | 136 | fn unique_name() -> String { 137 | SystemTime::now() 138 | .duration_since(UNIX_EPOCH) 139 | .expect("system time") 140 | .as_nanos() 141 | .to_string() 142 | } 143 | 144 | #[test] 145 | #[serial] 146 | fn relative_path_inside_home_renders_tilde() { 147 | let module = PathModule::new(); 148 | let home = dirs::home_dir().expect("home dir should exist"); 149 | let project = home.join(format!("prmt_test_project_{}", unique_name())); 150 | match fs::create_dir_all(&project) { 151 | Ok(_) => {} 152 | Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { 153 | eprintln!("Skipping test: {}", err); 154 | return; 155 | } 156 | Err(err) => panic!("create project dir: {}", err), 157 | } 158 | 159 | let _dir_guard = DirGuard::change_to(&project); 160 | 161 | let value = module 162 | .render("", &ModuleContext::default()) 163 | .expect("render") 164 | .expect("some"); 165 | 166 | assert!( 167 | value.starts_with("~/prmt_test_project_"), 168 | "Expected path to start with ~/prmt_test_project_, got: {}", 169 | value 170 | ); 171 | 172 | let _ = fs::remove_dir_all(&project); 173 | } 174 | 175 | #[test] 176 | #[serial] 177 | fn relative_path_with_shared_prefix_is_not_tilde() { 178 | let module = PathModule::new(); 179 | let home = dirs::home_dir().expect("home dir should exist"); 180 | 181 | let unique = unique_name(); 182 | let base = home.join(format!("prmt_test_base_{}", unique)); 183 | let home_like = base.join("al"); 184 | let similar = base.join("alpine"); 185 | 186 | match fs::create_dir_all(&home_like) { 187 | Ok(_) => {} 188 | Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { 189 | eprintln!("Skipping test: {}", err); 190 | return; 191 | } 192 | Err(err) => panic!("create home_like: {}", err), 193 | } 194 | match fs::create_dir_all(&similar) { 195 | Ok(_) => {} 196 | Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { 197 | eprintln!("Skipping test: {}", err); 198 | return; 199 | } 200 | Err(err) => panic!("create similar: {}", err), 201 | } 202 | 203 | let _dir_guard = DirGuard::change_to(&similar); 204 | 205 | let value = module 206 | .render("", &ModuleContext::default()) 207 | .expect("render") 208 | .expect("some"); 209 | 210 | assert!( 211 | value.starts_with("~/prmt_test_base_"), 212 | "Expected path to start with ~/prmt_test_base_, got: {}", 213 | value 214 | ); 215 | assert!( 216 | value.ends_with("/alpine"), 217 | "Expected path to end with /alpine, got: {}", 218 | value 219 | ); 220 | 221 | let _ = fs::remove_dir_all(&base); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /tests/integration_test.rs: -------------------------------------------------------------------------------- 1 | use prmt::{Token, execute, parse}; 2 | use std::env; 3 | 4 | #[test] 5 | fn test_basic_format() { 6 | let result = execute("{path}", true, None, false).expect("Failed to execute"); 7 | assert!(!result.is_empty()); 8 | // Should contain current directory name 9 | let current_dir = env::current_dir().unwrap(); 10 | let dir_name = current_dir.file_name().unwrap().to_str().unwrap(); 11 | assert!(result.contains(dir_name) || result.contains("~")); 12 | } 13 | 14 | #[test] 15 | fn test_git_module() { 16 | let result = execute("{git}", true, None, false).expect("Failed to execute"); 17 | // In a git repo, should show branch name (any non-empty string is valid) 18 | // The result might have a * suffix if there are uncommitted changes 19 | assert!(!result.is_empty(), "Git module should return a branch name"); 20 | } 21 | 22 | #[test] 23 | fn test_ok_fail_modules() { 24 | // Test with exit code 0 (ok) - use : to disable default styles 25 | let result = execute("{ok:}{fail:}", true, Some(0), false).expect("Failed to execute"); 26 | assert_eq!(result, "❯"); // Only ok should show 27 | 28 | // Test with exit code 1 (fail) - use : to disable default styles 29 | let result = execute("{ok:}{fail:}", true, Some(1), false).expect("Failed to execute"); 30 | assert_eq!(result, "❯"); // Only fail should show 31 | } 32 | 33 | #[test] 34 | fn test_new_format_types() { 35 | // Test short version format 36 | let result = execute("{path::short}", true, None, false).expect("Failed to execute"); 37 | let current_dir = env::current_dir().unwrap(); 38 | let basename = current_dir.file_name().unwrap().to_str().unwrap(); 39 | assert_eq!(result, basename); 40 | } 41 | 42 | #[test] 43 | fn test_escape_sequences() { 44 | let result = execute("Line1\\nLine2\\tTab", true, None, false).expect("Failed to execute"); 45 | assert_eq!(result, "Line1\nLine2\tTab"); 46 | } 47 | 48 | #[test] 49 | fn test_multiple_modules() { 50 | let result = execute("{path} {git}", true, None, false).expect("Failed to execute"); 51 | assert!(!result.is_empty()); 52 | // Should contain both path and git info if available 53 | let current_dir = env::current_dir().unwrap(); 54 | let dir_name = current_dir.file_name().unwrap().to_str().unwrap(); 55 | assert!(result.contains(dir_name) || result.contains("~")); 56 | } 57 | 58 | #[test] 59 | fn test_styles() { 60 | // Test with styles - this should work without errors, but we won't check ANSI codes 61 | let formats = vec![ 62 | "{path:cyan}", 63 | "{git:purple}", 64 | "{path:blue.bold}", 65 | "{rust:red}", 66 | ]; 67 | 68 | for format in formats { 69 | let result = execute(format, true, None, false); 70 | assert!(result.is_ok(), "Failed to parse: {}", format); 71 | } 72 | } 73 | 74 | #[test] 75 | fn test_prefix_suffix() { 76 | // Test prefix and suffix 77 | let formats = vec!["{path:::before:after}", "{git:::>>>:<<<}", "{ok:::[:]}"]; 78 | 79 | for format in formats { 80 | let result = execute(format, true, None, false); 81 | assert!(result.is_ok(), "Failed to parse: {}", format); 82 | } 83 | } 84 | 85 | #[test] 86 | fn test_rust_module() { 87 | let result = execute("{rust}", false, None, false).expect("Failed to execute"); 88 | // If in a Rust project, should contain version number 89 | if !result.is_empty() { 90 | assert!(result.contains(".") || result == "rust"); // Either version or "rust" if no-version 91 | } 92 | } 93 | 94 | #[test] 95 | fn test_custom_symbols() { 96 | // Test ok with custom symbol - use : to disable default styles 97 | let result = execute("{ok::✓}", true, Some(0), false).expect("Failed to execute"); 98 | assert_eq!(result, "✓"); 99 | 100 | // Test fail with custom symbol - use : to disable default styles 101 | let result = execute("{fail::✗}", true, Some(1), false).expect("Failed to execute"); 102 | assert_eq!(result, "✗"); 103 | 104 | // Test fail with code format 105 | let result = execute("{fail::code}", true, Some(42), false).expect("Failed to execute"); 106 | assert_eq!(result, "42"); 107 | } 108 | 109 | #[test] 110 | fn test_path_formats() { 111 | // Test all path formats 112 | let result_relative = 113 | execute("{path::relative}", true, None, false).expect("Failed to execute"); 114 | let result_relative_r = execute("{path::r}", true, None, false).expect("Failed to execute"); 115 | let result_absolute = 116 | execute("{path::absolute}", true, None, false).expect("Failed to execute"); 117 | let result_absolute_a = execute("{path::a}", true, None, false).expect("Failed to execute"); 118 | let result_short = execute("{path::short}", true, None, false).expect("Failed to execute"); 119 | let result_short_s = execute("{path::s}", true, None, false).expect("Failed to execute"); 120 | let result_default = execute("{path}", true, None, false).expect("Failed to execute"); 121 | 122 | // Short should be basename only 123 | let current_dir = env::current_dir().unwrap(); 124 | let basename = current_dir.file_name().unwrap().to_str().unwrap(); 125 | assert_eq!(result_short, basename); 126 | assert_eq!(result_short_s, basename); 127 | assert_eq!(result_short, result_short_s); // Short and alias should match 128 | 129 | // Relative formats should contain ~ if in home directory, or the basename 130 | assert!(result_relative.contains(basename) || result_relative.contains("~")); 131 | assert_eq!(result_relative, result_relative_r); // Short and long forms should match 132 | assert_eq!(result_relative, result_default); // Default should be relative 133 | 134 | // Absolute formats should never contain ~ and should always contain the basename 135 | assert!(!result_absolute.contains("~")); 136 | assert!(result_absolute.contains(basename)); 137 | assert_eq!(result_absolute, result_absolute_a); // Short and long forms should match 138 | 139 | // Absolute should be different from relative if in home directory 140 | if result_relative.contains("~") { 141 | assert_ne!(result_absolute, result_relative); 142 | } 143 | } 144 | 145 | #[test] 146 | fn test_parser_tokens() { 147 | // Test that parser produces correct tokens 148 | let tokens = parse("{path:cyan:short:[:]}"); 149 | 150 | match &tokens[0] { 151 | Token::Placeholder(params) => { 152 | assert_eq!(params.module, "path"); 153 | assert_eq!(params.style, "cyan"); 154 | assert_eq!(params.format, "short"); 155 | assert_eq!(params.prefix, "["); 156 | assert_eq!(params.suffix, "]"); 157 | } 158 | _ => panic!("Expected placeholder token"), 159 | } 160 | } 161 | 162 | #[test] 163 | fn test_parser_escapes() { 164 | let tokens = parse("\\{not\\:placeholder\\}"); 165 | // The parser may produce multiple text tokens due to escape processing 166 | let combined: String = tokens 167 | .iter() 168 | .map(|t| match t { 169 | Token::Text(text) => text.clone(), 170 | _ => panic!("Expected only text tokens"), 171 | }) 172 | .collect(); 173 | assert_eq!(combined, "{not:placeholder}"); 174 | } 175 | 176 | #[test] 177 | fn test_mixed_text_placeholders() { 178 | let tokens = parse("text {path} more {git} end"); 179 | assert_eq!(tokens.len(), 5); 180 | 181 | match &tokens[0] { 182 | Token::Text(text) => assert_eq!(text, "text "), 183 | _ => panic!("Expected text token"), 184 | } 185 | 186 | match &tokens[1] { 187 | Token::Placeholder(params) => assert_eq!(params.module, "path"), 188 | _ => panic!("Expected placeholder token"), 189 | } 190 | 191 | match &tokens[2] { 192 | Token::Text(text) => assert_eq!(text, " more "), 193 | _ => panic!("Expected text token"), 194 | } 195 | 196 | match &tokens[3] { 197 | Token::Placeholder(params) => assert_eq!(params.module, "git"), 198 | _ => panic!("Expected placeholder token"), 199 | } 200 | 201 | match &tokens[4] { 202 | Token::Text(text) => assert_eq!(text, " end"), 203 | _ => panic!("Expected text token"), 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/executor.rs: -------------------------------------------------------------------------------- 1 | use crate::detector::{DetectionContext, detect}; 2 | use crate::error::{PromptError, Result}; 3 | use crate::module_trait::{ModuleContext, ModuleRef}; 4 | use crate::parser::{Params, Token, parse}; 5 | use crate::registry::ModuleRegistry; 6 | use crate::style::{AnsiStyle, ModuleStyle, Shell, global_no_color}; 7 | use rayon::prelude::*; 8 | use std::borrow::Cow; 9 | use std::collections::HashSet; 10 | use std::sync::{Arc, OnceLock}; 11 | 12 | #[inline] 13 | fn estimate_output_size(template_len: usize) -> usize { 14 | template_len + (template_len / 2) + 128 15 | } 16 | 17 | enum RenderSlot<'a> { 18 | Static(Cow<'a, str>), 19 | Dynamic { 20 | params: Params, 21 | module: ModuleRef, 22 | output: OnceLock>, 23 | }, 24 | } 25 | 26 | impl<'a> RenderSlot<'a> { 27 | fn len(&self) -> usize { 28 | match self { 29 | RenderSlot::Static(text) => text.len(), 30 | RenderSlot::Dynamic { output, .. } => output 31 | .get() 32 | .and_then(|value| value.as_ref()) 33 | .map(|text| text.len()) 34 | .unwrap_or(0), 35 | } 36 | } 37 | } 38 | 39 | #[allow(dead_code)] 40 | pub fn render_template( 41 | template: &str, 42 | registry: &ModuleRegistry, 43 | context: &ModuleContext, 44 | no_color: bool, 45 | ) -> Result { 46 | let tokens = parse(template); 47 | let placeholder_count = count_placeholders(&tokens); 48 | render_tokens( 49 | tokens, 50 | registry, 51 | context, 52 | no_color, 53 | template.len(), 54 | placeholder_count, 55 | ) 56 | } 57 | 58 | fn render_tokens<'a>( 59 | tokens: Vec>, 60 | registry: &ModuleRegistry, 61 | context: &ModuleContext, 62 | no_color: bool, 63 | template_len: usize, 64 | placeholder_count: usize, 65 | ) -> Result { 66 | if placeholder_count <= 1 { 67 | return render_tokens_sequential(tokens, registry, context, no_color, template_len); 68 | } 69 | 70 | render_tokens_parallel(tokens, registry, context, no_color) 71 | } 72 | 73 | fn render_tokens_sequential<'a>( 74 | tokens: Vec>, 75 | registry: &ModuleRegistry, 76 | context: &ModuleContext, 77 | no_color: bool, 78 | template_len: usize, 79 | ) -> Result { 80 | let mut output = String::with_capacity(estimate_output_size(template_len)); 81 | 82 | for token in tokens { 83 | match token { 84 | Token::Text(text) => output.push_str(&text), 85 | Token::Placeholder(params) => { 86 | let module = registry 87 | .get(¶ms.module) 88 | .ok_or_else(|| PromptError::UnknownModule(params.module.clone()))?; 89 | 90 | if let Some(value) = render_placeholder(&module, ¶ms, context, no_color)? { 91 | output.push_str(&value); 92 | } 93 | } 94 | } 95 | } 96 | 97 | Ok(output) 98 | } 99 | 100 | fn render_tokens_parallel<'a>( 101 | tokens: Vec>, 102 | registry: &ModuleRegistry, 103 | context: &ModuleContext, 104 | no_color: bool, 105 | ) -> Result { 106 | let mut slots = Vec::with_capacity(tokens.len()); 107 | let mut dynamic_indices = Vec::new(); 108 | 109 | for token in tokens.into_iter() { 110 | match token { 111 | Token::Text(text) => { 112 | slots.push(RenderSlot::Static(text)); 113 | } 114 | Token::Placeholder(params) => { 115 | let module = registry 116 | .get(¶ms.module) 117 | .ok_or_else(|| PromptError::UnknownModule(params.module.clone()))?; 118 | 119 | let index = slots.len(); 120 | slots.push(RenderSlot::Dynamic { 121 | params, 122 | module, 123 | output: OnceLock::new(), 124 | }); 125 | dynamic_indices.push(index); 126 | } 127 | } 128 | } 129 | 130 | if dynamic_indices.len() <= 1 { 131 | for &index in &dynamic_indices { 132 | compute_slot(&slots[index], context, no_color)?; 133 | } 134 | } else { 135 | ensure_thread_pool(); 136 | dynamic_indices 137 | .par_iter() 138 | .try_for_each(|&index| compute_slot(&slots[index], context, no_color))?; 139 | } 140 | 141 | let total_len: usize = slots.iter().map(RenderSlot::len).sum(); 142 | let mut output = String::with_capacity(total_len); 143 | 144 | for slot in slots.into_iter() { 145 | match slot { 146 | RenderSlot::Static(text) => output.push_str(&text), 147 | RenderSlot::Dynamic { 148 | output: slot_output, 149 | .. 150 | } => { 151 | if let Some(Some(text)) = slot_output.into_inner() { 152 | output.push_str(&text); 153 | } 154 | } 155 | } 156 | } 157 | 158 | Ok(output) 159 | } 160 | 161 | #[allow(dead_code)] 162 | pub fn execute( 163 | format_str: &str, 164 | no_version: bool, 165 | exit_code: Option, 166 | no_color: bool, 167 | ) -> Result { 168 | execute_with_shell(format_str, no_version, exit_code, no_color, Shell::None) 169 | } 170 | 171 | pub fn execute_with_shell( 172 | format_str: &str, 173 | no_version: bool, 174 | exit_code: Option, 175 | no_color: bool, 176 | shell: Shell, 177 | ) -> Result { 178 | let tokens = parse(format_str); 179 | let (registry, placeholder_count) = build_registry(&tokens)?; 180 | let required_markers = registry.required_markers(); 181 | let detection = if required_markers.is_empty() { 182 | DetectionContext::default() 183 | } else { 184 | detect(&required_markers) 185 | }; 186 | let context = ModuleContext { 187 | no_version, 188 | exit_code, 189 | detection, 190 | shell, 191 | }; 192 | let resolved_no_color = no_color || global_no_color(); 193 | render_tokens( 194 | tokens, 195 | ®istry, 196 | &context, 197 | resolved_no_color, 198 | format_str.len(), 199 | placeholder_count, 200 | ) 201 | } 202 | 203 | fn render_placeholder( 204 | module: &ModuleRef, 205 | params: &Params, 206 | context: &ModuleContext, 207 | no_color: bool, 208 | ) -> Result> { 209 | let Some(text) = module.render(¶ms.format, context)? else { 210 | return Ok(None); 211 | }; 212 | 213 | if text.is_empty() { 214 | return Ok(None); 215 | } 216 | 217 | // Build the complete segment (prefix + text + suffix) 218 | let estimated_len = params.prefix.len() + text.len() + params.suffix.len(); 219 | let mut segment = String::with_capacity(estimated_len); 220 | 221 | if !params.prefix.is_empty() { 222 | segment.push_str(¶ms.prefix); 223 | } 224 | segment.push_str(&text); 225 | if !params.suffix.is_empty() { 226 | segment.push_str(¶ms.suffix); 227 | } 228 | 229 | // Apply style to the entire segment 230 | if params.style.is_empty() || no_color { 231 | return Ok(Some(segment)); 232 | } 233 | 234 | let style = AnsiStyle::parse(¶ms.style).map_err(|error| PromptError::StyleError { 235 | module: params.module.clone(), 236 | error, 237 | })?; 238 | let styled = style.apply_with_shell(&segment, context.shell); 239 | Ok(Some(styled)) 240 | } 241 | 242 | fn compute_slot(slot: &RenderSlot<'_>, context: &ModuleContext, no_color: bool) -> Result<()> { 243 | let RenderSlot::Dynamic { 244 | params, 245 | module, 246 | output, 247 | } = slot 248 | else { 249 | return Ok(()); 250 | }; 251 | 252 | let value = render_placeholder(module, params, context, no_color)?; 253 | output 254 | .set(value) 255 | .expect("placeholder result should only be computed once"); 256 | Ok(()) 257 | } 258 | 259 | fn count_placeholders(tokens: &[Token<'_>]) -> usize { 260 | tokens 261 | .iter() 262 | .filter(|token| matches!(token, Token::Placeholder(_))) 263 | .count() 264 | } 265 | 266 | fn build_registry(tokens: &[Token<'_>]) -> Result<(ModuleRegistry, usize)> { 267 | let mut registry = ModuleRegistry::new(); 268 | let mut required: HashSet<&str> = HashSet::new(); 269 | let mut placeholder_count = 0usize; 270 | 271 | for token in tokens { 272 | if let Token::Placeholder(params) = token { 273 | placeholder_count += 1; 274 | let name = params.module.as_str(); 275 | if required.insert(name) { 276 | let module = instantiate_module(name) 277 | .ok_or_else(|| PromptError::UnknownModule(name.to_string()))?; 278 | registry.register(name.to_string(), module); 279 | } 280 | } 281 | } 282 | 283 | Ok((registry, placeholder_count)) 284 | } 285 | 286 | fn instantiate_module(name: &str) -> Option { 287 | use crate::modules::*; 288 | Some(match name { 289 | "path" => Arc::new(path::PathModule::new()), 290 | "git" => Arc::new(git::GitModule::new()), 291 | "env" => Arc::new(env::EnvModule::new()), 292 | "ok" => Arc::new(ok::OkModule::new()), 293 | "fail" => Arc::new(fail::FailModule::new()), 294 | "rust" => Arc::new(rust::RustModule::new()), 295 | "node" => Arc::new(node::NodeModule::new()), 296 | "python" => Arc::new(python::PythonModule::new()), 297 | "go" => Arc::new(go::GoModule::new()), 298 | "deno" => Arc::new(deno::DenoModule::new()), 299 | "bun" => Arc::new(bun::BunModule::new()), 300 | "time" => Arc::new(time::TimeModule), 301 | _ => return None, 302 | }) 303 | } 304 | 305 | fn ensure_thread_pool() { 306 | static THREAD_POOL_INIT: OnceLock<()> = OnceLock::new(); 307 | THREAD_POOL_INIT.get_or_init(|| { 308 | let max_threads = std::thread::available_parallelism() 309 | .map(|parallelism| parallelism.get()) 310 | .unwrap_or(1) 311 | .clamp(1, 4); 312 | let _ = rayon::ThreadPoolBuilder::new() 313 | .num_threads(max_threads) 314 | .build_global(); 315 | }); 316 | } 317 | -------------------------------------------------------------------------------- /src/modules/git.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{PromptError, Result}; 2 | use crate::memo::{GIT_MEMO, GitInfo}; 3 | use crate::module_trait::{Module, ModuleContext}; 4 | use bitflags::bitflags; 5 | #[cfg(feature = "git-gix")] 6 | use gix::bstr::BString; 7 | #[cfg(feature = "git-gix")] 8 | use gix::progress::Discard; 9 | #[cfg(feature = "git-gix")] 10 | use gix::status::Item as StatusItem; 11 | #[cfg(feature = "git-gix")] 12 | use gix::status::index_worktree::iter::Summary as WorktreeSummary; 13 | use rayon::join; 14 | use std::path::Path; 15 | use std::process::Command; 16 | #[cfg(feature = "git-gix")] 17 | use std::sync::Arc; 18 | 19 | bitflags! { 20 | #[derive(Debug, Clone, Copy)] 21 | struct GitStatus: u8 { 22 | const MODIFIED = 0b001; 23 | const STAGED = 0b010; 24 | const UNTRACKED = 0b100; 25 | } 26 | } 27 | 28 | pub struct GitModule; 29 | 30 | impl Default for GitModule { 31 | fn default() -> Self { 32 | Self::new() 33 | } 34 | } 35 | 36 | impl GitModule { 37 | pub fn new() -> Self { 38 | Self 39 | } 40 | } 41 | 42 | #[cold] 43 | fn get_git_status_slow(repo_root: &Path) -> GitStatus { 44 | let mut status = GitStatus::empty(); 45 | 46 | // Only run git status if not memoized 47 | if let Ok(output) = std::process::Command::new("git") 48 | .arg("status") 49 | .arg("--porcelain=v1") 50 | .arg("--untracked-files=normal") 51 | .current_dir(repo_root) 52 | .output() 53 | && output.status.success() 54 | { 55 | let status_text = String::from_utf8_lossy(&output.stdout); 56 | 57 | for line in status_text.lines() { 58 | if line.starts_with("??") { 59 | status |= GitStatus::UNTRACKED; 60 | } else if !line.is_empty() { 61 | let chars: Vec = line.chars().take(2).collect(); 62 | if chars.len() >= 2 { 63 | if chars[0] != ' ' && chars[0] != '?' { 64 | status |= GitStatus::STAGED; 65 | } 66 | if chars[1] != ' ' && chars[1] != '?' { 67 | status |= GitStatus::MODIFIED; 68 | } 69 | } 70 | } 71 | } 72 | } 73 | status 74 | } 75 | 76 | #[cfg(feature = "git-gix")] 77 | fn collect_git_status_fast(repo: &gix::Repository) -> Option { 78 | let mut status = GitStatus::empty(); 79 | 80 | let platform = repo.status(Discard).ok()?; 81 | let iter = platform.into_iter(Vec::::new()).ok()?; 82 | 83 | for item in iter { 84 | let item = item.ok()?; 85 | match item { 86 | StatusItem::IndexWorktree(change) => { 87 | if let Some(summary) = change.summary() { 88 | match summary { 89 | WorktreeSummary::Added => status |= GitStatus::UNTRACKED, 90 | WorktreeSummary::IntentToAdd => status |= GitStatus::STAGED, 91 | WorktreeSummary::Conflict 92 | | WorktreeSummary::Copied 93 | | WorktreeSummary::Modified 94 | | WorktreeSummary::Removed 95 | | WorktreeSummary::Renamed 96 | | WorktreeSummary::TypeChange => status |= GitStatus::MODIFIED, 97 | } 98 | } 99 | } 100 | StatusItem::TreeIndex(_) => { 101 | status |= GitStatus::STAGED; 102 | } 103 | } 104 | 105 | if status.contains(GitStatus::MODIFIED) 106 | && status.contains(GitStatus::STAGED) 107 | && status.contains(GitStatus::UNTRACKED) 108 | { 109 | break; 110 | } 111 | } 112 | 113 | Some(status) 114 | } 115 | 116 | #[cfg(feature = "git-gix")] 117 | fn current_branch_from_repo(repo: &gix::Repository) -> String { 118 | if let Ok(Some(head_ref)) = repo.head_ref() { 119 | String::from_utf8(head_ref.name().shorten().to_vec()).unwrap_or_else(|_| "HEAD".to_string()) 120 | } else if let Ok(Some(head_name)) = repo.head_name() { 121 | String::from_utf8(head_name.shorten().to_vec()).unwrap_or_else(|_| "HEAD".to_string()) 122 | } else if let Ok(head) = repo.head() { 123 | head.id() 124 | .map(|id| id.shorten_or_id().to_string()) 125 | .unwrap_or_else(|| "HEAD".to_string()) 126 | } else { 127 | "HEAD".to_string() 128 | } 129 | } 130 | 131 | fn current_branch_from_cli(repo_root: &Path) -> Option { 132 | run_git(&["symbolic-ref", "--quiet", "--short", "HEAD"], repo_root) 133 | .or_else(|| run_git(&["rev-parse", "--short", "HEAD"], repo_root)) 134 | } 135 | 136 | fn branch_and_status_cli(repo_root: &Path, need_status: bool) -> (String, GitStatus) { 137 | if need_status { 138 | join( 139 | || current_branch_from_cli(repo_root).unwrap_or_else(|| "HEAD".to_string()), 140 | || get_git_status_slow(repo_root), 141 | ) 142 | } else { 143 | ( 144 | current_branch_from_cli(repo_root).unwrap_or_else(|| "HEAD".to_string()), 145 | GitStatus::empty(), 146 | ) 147 | } 148 | } 149 | 150 | #[cfg(feature = "git-gix")] 151 | fn branch_and_status(repo_root: &Path, need_status: bool) -> (String, GitStatus) { 152 | match gix::ThreadSafeRepository::open(repo_root) { 153 | Ok(repo) => { 154 | let repo = Arc::new(repo); 155 | if need_status { 156 | let repo_for_branch = Arc::clone(&repo); 157 | let repo_for_status = Arc::clone(&repo); 158 | let repo_root_for_status = repo_root; 159 | join( 160 | || { 161 | let local = repo_for_branch.to_thread_local(); 162 | current_branch_from_repo(&local) 163 | }, 164 | || { 165 | let local = repo_for_status.to_thread_local(); 166 | collect_git_status_fast(&local) 167 | .unwrap_or_else(|| get_git_status_slow(repo_root_for_status)) 168 | }, 169 | ) 170 | } else { 171 | let local = repo.to_thread_local(); 172 | (current_branch_from_repo(&local), GitStatus::empty()) 173 | } 174 | } 175 | Err(_) => branch_and_status_cli(repo_root, need_status), 176 | } 177 | } 178 | 179 | #[cfg(not(feature = "git-gix"))] 180 | fn branch_and_status(repo_root: &Path, need_status: bool) -> (String, GitStatus) { 181 | branch_and_status_cli(repo_root, need_status) 182 | } 183 | 184 | fn run_git(args: &[&str], repo_root: &Path) -> Option { 185 | let output = Command::new("git") 186 | .args(args) 187 | .current_dir(repo_root) 188 | .output() 189 | .ok()?; 190 | if !output.status.success() { 191 | return None; 192 | } 193 | let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); 194 | if value.is_empty() { None } else { Some(value) } 195 | } 196 | 197 | fn validate_git_format(format: &str) -> Result<&str> { 198 | match format { 199 | "" | "full" | "f" => Ok("full"), 200 | "short" | "s" => Ok("short"), 201 | _ => Err(PromptError::InvalidFormat { 202 | module: "git".to_string(), 203 | format: format.to_string(), 204 | valid_formats: "full, f, short, s".to_string(), 205 | }), 206 | } 207 | } 208 | 209 | impl Module for GitModule { 210 | fn fs_markers(&self) -> &'static [&'static str] { 211 | &[".git"] 212 | } 213 | 214 | fn render(&self, format: &str, context: &ModuleContext) -> Result> { 215 | // Validate format first 216 | let normalized_format = validate_git_format(format)?; 217 | 218 | // Fast path: find git directory 219 | let git_dir = match context.marker_path(".git") { 220 | Some(path) => path, 221 | None => return Ok(None), 222 | }; 223 | let repo_root = match git_dir.parent() { 224 | Some(p) => p, 225 | None => return Ok(None), 226 | }; 227 | 228 | // Check memoized info first 229 | if let Some(memoized) = GIT_MEMO.get(repo_root) { 230 | return Ok(match normalized_format { 231 | "full" => { 232 | let mut result = memoized.branch.clone(); 233 | if memoized.has_changes { 234 | result.push('*'); 235 | } 236 | if memoized.has_staged { 237 | result.push('+'); 238 | } 239 | if memoized.has_untracked { 240 | result.push('?'); 241 | } 242 | Some(result) 243 | } 244 | "short" => Some(memoized.branch), 245 | _ => unreachable!("validate_git_format should have caught this"), 246 | }); 247 | } 248 | 249 | let need_status = normalized_format == "full"; 250 | let (branch_name, status) = branch_and_status(repo_root, need_status); 251 | 252 | // Memoize the result for other placeholders during this render 253 | let info = GitInfo { 254 | branch: branch_name.clone(), 255 | has_changes: status.contains(GitStatus::MODIFIED), 256 | has_staged: status.contains(GitStatus::STAGED), 257 | has_untracked: status.contains(GitStatus::UNTRACKED), 258 | }; 259 | GIT_MEMO.insert(repo_root.to_path_buf(), info); 260 | 261 | // Build result 262 | Ok(match normalized_format { 263 | "full" => { 264 | let mut result = branch_name; 265 | if status.contains(GitStatus::MODIFIED) { 266 | result.push('*'); 267 | } 268 | if status.contains(GitStatus::STAGED) { 269 | result.push('+'); 270 | } 271 | if status.contains(GitStatus::UNTRACKED) { 272 | result.push('?'); 273 | } 274 | Some(result) 275 | } 276 | "short" => Some(branch_name), 277 | _ => unreachable!("validate_git_format should have caught this"), 278 | }) 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /src/modules/time.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{PromptError, Result}; 2 | use crate::module_trait::{Module, ModuleContext}; 3 | use libc::c_int; 4 | use std::convert::TryInto; 5 | use std::io; 6 | use std::time::{SystemTime, UNIX_EPOCH}; 7 | 8 | pub struct TimeModule; 9 | 10 | impl Default for TimeModule { 11 | fn default() -> Self { 12 | Self 13 | } 14 | } 15 | 16 | enum FormatSpec { 17 | Hm24, 18 | Hms24, 19 | Hm12, 20 | Hms12, 21 | } 22 | 23 | impl FormatSpec { 24 | fn render(&self, parts: &TimeParts) -> String { 25 | match self { 26 | FormatSpec::Hm24 => format!("{:02}:{:02}", parts.hour24, parts.minute), 27 | FormatSpec::Hms24 => format!( 28 | "{:02}:{:02}:{:02}", 29 | parts.hour24, parts.minute, parts.second 30 | ), 31 | FormatSpec::Hm12 => { 32 | let (hour, suffix) = parts.hour12(); 33 | format!("{:02}:{:02}{suffix}", hour, parts.minute) 34 | } 35 | FormatSpec::Hms12 => { 36 | let (hour, suffix) = parts.hour12(); 37 | format!( 38 | "{:02}:{:02}:{:02}{suffix}", 39 | hour, parts.minute, parts.second 40 | ) 41 | } 42 | } 43 | } 44 | } 45 | 46 | impl Module for TimeModule { 47 | fn render(&self, format: &str, _context: &ModuleContext) -> Result> { 48 | let spec = match format { 49 | "" | "24h" => FormatSpec::Hm24, 50 | "24hs" | "24HS" => FormatSpec::Hms24, 51 | "12h" | "12H" => FormatSpec::Hm12, 52 | "12hs" | "12HS" => FormatSpec::Hms12, 53 | _ => { 54 | return Err(PromptError::InvalidFormat { 55 | module: "time".to_string(), 56 | format: format.to_string(), 57 | valid_formats: "24h (default), 12h, 12H, 12hs, 12HS, 24hs, 24HS".to_string(), 58 | }); 59 | } 60 | }; 61 | 62 | let parts = current_local_time()?; 63 | Ok(Some(spec.render(&parts))) 64 | } 65 | } 66 | 67 | #[derive(Clone, Copy)] 68 | struct TimeParts { 69 | hour24: u8, 70 | minute: u8, 71 | second: u8, 72 | } 73 | 74 | impl TimeParts { 75 | fn hour12(&self) -> (u8, &'static str) { 76 | let suffix = if self.hour24 >= 12 { "PM" } else { "AM" }; 77 | let mut hour = self.hour24 % 12; 78 | if hour == 0 { 79 | hour = 12; 80 | } 81 | (hour, suffix) 82 | } 83 | } 84 | 85 | fn current_local_time() -> Result { 86 | let timestamp = system_time_to_time_t()?; 87 | let tm = platform_local_tm(timestamp)?; 88 | Ok(TimeParts { 89 | hour24: clamp_component(tm.tm_hour, 23), 90 | minute: clamp_component(tm.tm_min, 59), 91 | second: clamp_component(tm.tm_sec, 60), 92 | }) 93 | } 94 | 95 | fn system_time_to_time_t() -> Result { 96 | let duration = SystemTime::now() 97 | .duration_since(UNIX_EPOCH) 98 | .map_err(|err| PromptError::IoError(io::Error::other(err)))?; 99 | 100 | duration 101 | .as_secs() 102 | .try_into() 103 | .map_err(|err| PromptError::IoError(io::Error::other(err))) 104 | } 105 | 106 | fn clamp_component(value: c_int, max: u8) -> u8 { 107 | value.clamp(0, max as c_int) as u8 108 | } 109 | 110 | #[cfg(unix)] 111 | fn platform_local_tm(timestamp: libc::time_t) -> Result { 112 | use std::mem::MaybeUninit; 113 | 114 | unsafe { 115 | let mut tm = MaybeUninit::::uninit(); 116 | if libc::localtime_r(×tamp as *const _, tm.as_mut_ptr()).is_null() { 117 | return Err(PromptError::IoError(io::Error::last_os_error())); 118 | } 119 | Ok(tm.assume_init()) 120 | } 121 | } 122 | 123 | #[cfg(windows)] 124 | fn platform_local_tm(timestamp: libc::time_t) -> Result { 125 | use std::mem::MaybeUninit; 126 | 127 | unsafe { 128 | let mut tm = MaybeUninit::::uninit(); 129 | let err = libc::localtime_s(tm.as_mut_ptr(), ×tamp as *const _); 130 | if err != 0 { 131 | return Err(PromptError::IoError(io::Error::from_raw_os_error(err))); 132 | } 133 | Ok(tm.assume_init()) 134 | } 135 | } 136 | 137 | #[cfg(not(any(unix, windows)))] 138 | fn platform_local_tm(_timestamp: libc::time_t) -> Result { 139 | Err(PromptError::IoError(io::Error::new( 140 | io::ErrorKind::Other, 141 | "time module is not supported on this platform", 142 | ))) 143 | } 144 | 145 | #[cfg(test)] 146 | mod tests { 147 | use super::*; 148 | use regex::Regex; 149 | 150 | #[test] 151 | fn test_time_module_default_format() { 152 | let module = TimeModule; 153 | let context = ModuleContext::default(); 154 | 155 | let result = module.render("", &context).unwrap(); 156 | assert!(result.is_some()); 157 | let time = result.unwrap(); 158 | assert_eq!(time.len(), 5); 159 | assert!(time.contains(':')); 160 | 161 | let re = Regex::new(r"^\d{2}:\d{2}$").unwrap(); 162 | assert!(re.is_match(&time), "Expected HH:MM format, got: {}", time); 163 | } 164 | 165 | #[test] 166 | fn test_time_module_24h_format() { 167 | let module = TimeModule; 168 | let context = ModuleContext::default(); 169 | 170 | let result = module.render("24h", &context).unwrap(); 171 | assert!(result.is_some()); 172 | let time = result.unwrap(); 173 | assert_eq!(time.len(), 5); 174 | 175 | let re = Regex::new(r"^\d{2}:\d{2}$").unwrap(); 176 | assert!(re.is_match(&time), "Expected HH:MM format, got: {}", time); 177 | } 178 | 179 | #[test] 180 | fn test_time_module_24hs_format() { 181 | let module = TimeModule; 182 | let context = ModuleContext::default(); 183 | 184 | let re = Regex::new(r"^\d{2}:\d{2}:\d{2}$").unwrap(); 185 | for format in &["24hs", "24HS"] { 186 | let result = module.render(format, &context).unwrap(); 187 | assert!(result.is_some()); 188 | let time = result.unwrap(); 189 | assert_eq!(time.len(), 8); 190 | 191 | assert!( 192 | re.is_match(&time), 193 | "Expected HH:MM:SS format for {}, got: {}", 194 | format, 195 | time 196 | ); 197 | } 198 | } 199 | 200 | #[test] 201 | fn test_time_module_12h_format() { 202 | let module = TimeModule; 203 | let context = ModuleContext::default(); 204 | 205 | let re = Regex::new(r"^\d{2}:\d{2}(AM|PM)$").unwrap(); 206 | for format in &["12h", "12H"] { 207 | let result = module.render(format, &context).unwrap(); 208 | assert!(result.is_some()); 209 | let time = result.unwrap(); 210 | 211 | assert!( 212 | re.is_match(&time), 213 | "Expected hh:MMAM/PM format for {}, got: {}", 214 | format, 215 | time 216 | ); 217 | 218 | assert!(time.ends_with("AM") || time.ends_with("PM")); 219 | } 220 | } 221 | 222 | #[test] 223 | fn test_time_module_12hs_format() { 224 | let module = TimeModule; 225 | let context = ModuleContext::default(); 226 | 227 | let re = Regex::new(r"^\d{2}:\d{2}:\d{2}(AM|PM)$").unwrap(); 228 | for format in &["12hs", "12HS"] { 229 | let result = module.render(format, &context).unwrap(); 230 | assert!(result.is_some()); 231 | let time = result.unwrap(); 232 | 233 | assert!( 234 | re.is_match(&time), 235 | "Expected hh:MM:SSAM/PM format for {}, got: {}", 236 | format, 237 | time 238 | ); 239 | 240 | assert!(time.ends_with("AM") || time.ends_with("PM")); 241 | } 242 | } 243 | 244 | #[test] 245 | fn test_time_module_unknown_format_returns_error() { 246 | let module = TimeModule; 247 | let context = ModuleContext::default(); 248 | 249 | let unknown_formats = vec!["invalid", "xyz", "13h", "25h", "random"]; 250 | 251 | for format in unknown_formats { 252 | let result = module.render(format, &context); 253 | assert!( 254 | result.is_err(), 255 | "Unknown format '{}' should return error", 256 | format 257 | ); 258 | } 259 | } 260 | 261 | #[test] 262 | fn test_time_module_valid_and_invalid_formats() { 263 | let module = TimeModule; 264 | let context = ModuleContext::default(); 265 | 266 | let valid_formats = vec!["", "24h", "24hs", "24HS", "12h", "12H", "12hs", "12HS"]; 267 | let invalid_formats = vec!["invalid", "test", "13h", "random"]; 268 | 269 | for format in valid_formats { 270 | let result = module.render(format, &context); 271 | assert!(result.is_ok(), "Valid format '{}' should succeed", format); 272 | let value = result.unwrap(); 273 | assert!( 274 | value.is_some(), 275 | "Time module should return Some for valid format: {}", 276 | format 277 | ); 278 | } 279 | 280 | for format in invalid_formats { 281 | let result = module.render(format, &context); 282 | assert!( 283 | result.is_err(), 284 | "Invalid format '{}' should return error", 285 | format 286 | ); 287 | } 288 | } 289 | 290 | #[test] 291 | fn test_time_module_hour_range() { 292 | let module = TimeModule; 293 | let context = ModuleContext::default(); 294 | 295 | let result_24h = module.render("24h", &context).unwrap(); 296 | assert!(result_24h.is_some()); 297 | let time_24h = result_24h.unwrap(); 298 | let hour = &time_24h[0..2].parse::().unwrap(); 299 | assert!(*hour <= 23, "24h format hour should be 0-23, got: {}", hour); 300 | 301 | let result_12h = module.render("12h", &context).unwrap(); 302 | assert!(result_12h.is_some()); 303 | let time_12h = result_12h.unwrap(); 304 | let hour = &time_12h[0..2].parse::().unwrap(); 305 | assert!( 306 | *hour >= 1 && *hour <= 12, 307 | "12h format hour should be 1-12, got: {}", 308 | hour 309 | ); 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /src/style.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Write; 2 | use std::str::FromStr; 3 | use std::sync::atomic::{AtomicU8, Ordering}; 4 | 5 | const COLOR_UNKNOWN: u8 = 0; 6 | const COLOR_FALSE: u8 = 1; 7 | const COLOR_TRUE: u8 = 2; 8 | 9 | static NO_COLOR_STATE: AtomicU8 = AtomicU8::new(COLOR_UNKNOWN); 10 | 11 | pub fn global_no_color() -> bool { 12 | match NO_COLOR_STATE.load(Ordering::Relaxed) { 13 | COLOR_TRUE => true, 14 | COLOR_FALSE => false, 15 | _ => { 16 | let detected = std::env::var_os("NO_COLOR").is_some(); 17 | NO_COLOR_STATE.store( 18 | if detected { COLOR_TRUE } else { COLOR_FALSE }, 19 | Ordering::Relaxed, 20 | ); 21 | detected 22 | } 23 | } 24 | } 25 | 26 | #[cfg(test)] 27 | pub fn reset_global_no_color_for_tests() { 28 | NO_COLOR_STATE.store(COLOR_UNKNOWN, Ordering::Relaxed); 29 | } 30 | 31 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] 32 | pub enum Shell { 33 | #[default] 34 | None, 35 | Zsh, 36 | Bash, 37 | } 38 | 39 | impl Shell { 40 | fn delimiters(self) -> (&'static str, &'static str) { 41 | match self { 42 | Shell::Zsh => ("%{", "%}"), 43 | Shell::Bash => ("\\[", "\\]"), 44 | Shell::None => ("", ""), 45 | } 46 | } 47 | } 48 | 49 | impl FromStr for Shell { 50 | type Err = String; 51 | 52 | fn from_str(value: &str) -> Result { 53 | match value.trim().to_ascii_lowercase().as_str() { 54 | "zsh" => Ok(Shell::Zsh), 55 | "bash" => Ok(Shell::Bash), 56 | "none" | "" => Ok(Shell::None), 57 | other => Err(format!( 58 | "Unknown shell: {} (supported values: bash, zsh, none)", 59 | other 60 | )), 61 | } 62 | } 63 | } 64 | 65 | pub trait ModuleStyle: Sized { 66 | fn parse(style_str: &str) -> Result; 67 | fn apply(&self, text: &str) -> String; 68 | 69 | fn apply_with_shell(&self, text: &str, shell: Shell) -> String { 70 | let _ = shell; 71 | self.apply(text) 72 | } 73 | } 74 | 75 | #[derive(Debug, Clone, PartialEq)] 76 | pub enum Color { 77 | Black, 78 | Red, 79 | Green, 80 | Yellow, 81 | Blue, 82 | Purple, 83 | Cyan, 84 | White, 85 | Hex(String), 86 | } 87 | 88 | impl Color { 89 | fn push_ansi_code(&self, buf: &mut String) { 90 | match self { 91 | Color::Black => buf.push_str("\x1b[30m"), 92 | Color::Red => buf.push_str("\x1b[31m"), 93 | Color::Green => buf.push_str("\x1b[32m"), 94 | Color::Yellow => buf.push_str("\x1b[33m"), 95 | Color::Blue => buf.push_str("\x1b[34m"), 96 | Color::Purple => buf.push_str("\x1b[35m"), 97 | Color::Cyan => buf.push_str("\x1b[36m"), 98 | Color::White => buf.push_str("\x1b[37m"), 99 | Color::Hex(hex) => { 100 | if let Ok((r, g, b)) = parse_hex_color(hex) { 101 | let _ = write!(buf, "\x1b[38;2;{};{};{}m", r, g, b); 102 | } 103 | } 104 | } 105 | } 106 | } 107 | 108 | #[derive(Debug, Clone, Default, PartialEq)] 109 | pub struct AnsiStyle { 110 | pub color: Option, 111 | pub bold: bool, 112 | pub italic: bool, 113 | pub underline: bool, 114 | pub dim: bool, 115 | pub reverse: bool, 116 | pub strikethrough: bool, 117 | } 118 | 119 | impl ModuleStyle for AnsiStyle { 120 | fn parse(style_str: &str) -> Result { 121 | let mut style = AnsiStyle::default(); 122 | 123 | if style_str.is_empty() { 124 | return Ok(style); 125 | } 126 | 127 | for part in style_str.split('.') { 128 | match part { 129 | "bold" => style.bold = true, 130 | "italic" => style.italic = true, 131 | "underline" => style.underline = true, 132 | "dim" => style.dim = true, 133 | "reverse" => style.reverse = true, 134 | "strikethrough" => style.strikethrough = true, 135 | "black" => style.color = Some(Color::Black), 136 | "red" => style.color = Some(Color::Red), 137 | "green" => style.color = Some(Color::Green), 138 | "yellow" => style.color = Some(Color::Yellow), 139 | "blue" => style.color = Some(Color::Blue), 140 | "purple" | "magenta" => style.color = Some(Color::Purple), 141 | "cyan" => style.color = Some(Color::Cyan), 142 | "white" => style.color = Some(Color::White), 143 | hex if hex.starts_with('#') => { 144 | style.color = Some(Color::Hex(hex.to_string())); 145 | } 146 | _ => return Err(format!("Unknown style component: {}", part)), 147 | } 148 | } 149 | 150 | Ok(style) 151 | } 152 | 153 | fn apply(&self, text: &str) -> String { 154 | self.apply_with_shell(text, Shell::None) 155 | } 156 | 157 | fn apply_with_shell(&self, text: &str, shell: Shell) -> String { 158 | if !self.has_style() { 159 | return text.to_string(); 160 | } 161 | 162 | let mut output = String::with_capacity(text.len() + 16); 163 | self.write_start_codes(&mut output, shell); 164 | output.push_str(text); 165 | self.write_reset(&mut output, shell); 166 | output 167 | } 168 | } 169 | 170 | fn parse_hex_color(hex: &str) -> Result<(u8, u8, u8), String> { 171 | let hex = hex.trim_start_matches('#'); 172 | 173 | if hex.len() != 6 { 174 | return Err(format!("Invalid hex color: {}", hex)); 175 | } 176 | 177 | let r = 178 | u8::from_str_radix(&hex[0..2], 16).map_err(|_| format!("Invalid hex color: {}", hex))?; 179 | let g = 180 | u8::from_str_radix(&hex[2..4], 16).map_err(|_| format!("Invalid hex color: {}", hex))?; 181 | let b = 182 | u8::from_str_radix(&hex[4..6], 16).map_err(|_| format!("Invalid hex color: {}", hex))?; 183 | 184 | Ok((r, g, b)) 185 | } 186 | 187 | impl AnsiStyle { 188 | fn has_style(&self) -> bool { 189 | self.color.is_some() 190 | || self.bold 191 | || self.italic 192 | || self.underline 193 | || self.dim 194 | || self.reverse 195 | || self.strikethrough 196 | } 197 | 198 | fn write_raw_codes(&self, buf: &mut String) { 199 | if let Some(ref color) = self.color { 200 | color.push_ansi_code(buf); 201 | } 202 | if self.bold { 203 | buf.push_str("\x1b[1m"); 204 | } 205 | if self.dim { 206 | buf.push_str("\x1b[2m"); 207 | } 208 | if self.italic { 209 | buf.push_str("\x1b[3m"); 210 | } 211 | if self.underline { 212 | buf.push_str("\x1b[4m"); 213 | } 214 | if self.reverse { 215 | buf.push_str("\x1b[7m"); 216 | } 217 | if self.strikethrough { 218 | buf.push_str("\x1b[9m"); 219 | } 220 | } 221 | 222 | pub fn write_start_codes(&self, buf: &mut String, shell: Shell) { 223 | if !self.has_style() { 224 | return; 225 | } 226 | 227 | if shell == Shell::None { 228 | self.write_raw_codes(buf); 229 | } else { 230 | let (start, end) = shell.delimiters(); 231 | buf.push_str(start); 232 | self.write_raw_codes(buf); 233 | buf.push_str(end); 234 | } 235 | } 236 | 237 | pub fn write_reset(&self, buf: &mut String, shell: Shell) { 238 | if !self.has_style() { 239 | return; 240 | } 241 | 242 | if shell == Shell::None { 243 | buf.push_str("\x1b[0m"); 244 | } else { 245 | let (start, end) = shell.delimiters(); 246 | buf.push_str(start); 247 | buf.push_str("\x1b[0m"); 248 | buf.push_str(end); 249 | } 250 | } 251 | } 252 | 253 | #[cfg(test)] 254 | mod tests { 255 | use super::*; 256 | use serial_test::serial; 257 | use std::env; 258 | 259 | fn unset_no_color() { 260 | unsafe { 261 | env::remove_var("NO_COLOR"); 262 | } 263 | } 264 | 265 | fn set_no_color() { 266 | unsafe { 267 | env::set_var("NO_COLOR", "1"); 268 | } 269 | } 270 | 271 | #[test] 272 | fn test_parse_simple_color() { 273 | let style = AnsiStyle::parse("red").unwrap(); 274 | assert_eq!(style.color, Some(Color::Red)); 275 | assert!(!style.bold); 276 | } 277 | 278 | #[test] 279 | fn test_parse_color_with_modifiers() { 280 | let style = AnsiStyle::parse("cyan.bold.italic").unwrap(); 281 | assert_eq!(style.color, Some(Color::Cyan)); 282 | assert!(style.bold); 283 | assert!(style.italic); 284 | } 285 | 286 | #[test] 287 | fn test_parse_hex_color() { 288 | let style = AnsiStyle::parse("#00ff00").unwrap(); 289 | assert!(matches!(style.color, Some(Color::Hex(_)))); 290 | } 291 | 292 | #[test] 293 | fn test_apply_style() { 294 | let style = AnsiStyle::parse("red.bold").unwrap(); 295 | let result = style.apply("test"); 296 | assert!(result.starts_with("\x1b[31m")); 297 | assert!(result.contains("\x1b[1m")); 298 | assert!(result.ends_with("test\x1b[0m")); 299 | } 300 | 301 | #[test] 302 | fn test_empty_style() { 303 | let style = AnsiStyle::parse("").unwrap(); 304 | let result = style.apply("test"); 305 | assert_eq!(result, "test"); 306 | } 307 | 308 | #[test] 309 | fn test_apply_with_shell_wraps_bash_sequences() { 310 | let style = AnsiStyle::parse("red.bold").unwrap(); 311 | let result = style.apply_with_shell("ok", Shell::Bash); 312 | assert!(result.starts_with("\\[\x1b[31m\x1b[1m\\]")); 313 | assert!(result.ends_with("ok\\[\x1b[0m\\]")); 314 | } 315 | 316 | #[test] 317 | fn test_shell_from_str() { 318 | assert_eq!(Shell::from_str("bash").unwrap(), Shell::Bash); 319 | assert_eq!(Shell::from_str("ZSH").unwrap(), Shell::Zsh); 320 | assert_eq!(Shell::from_str("none").unwrap(), Shell::None); 321 | assert!(Shell::from_str("fish").is_err()); 322 | } 323 | 324 | #[test] 325 | #[serial] 326 | fn global_no_color_respects_env() { 327 | unset_no_color(); 328 | reset_global_no_color_for_tests(); 329 | assert!(!global_no_color()); 330 | 331 | set_no_color(); 332 | reset_global_no_color_for_tests(); 333 | assert!(global_no_color()); 334 | 335 | unset_no_color(); 336 | reset_global_no_color_for_tests(); 337 | } 338 | 339 | #[test] 340 | #[serial] 341 | fn global_no_color_caches_until_reset() { 342 | unset_no_color(); 343 | reset_global_no_color_for_tests(); 344 | assert!(!global_no_color()); 345 | 346 | set_no_color(); 347 | // Without reset we still expect false due to caching 348 | assert!(!global_no_color()); 349 | 350 | reset_global_no_color_for_tests(); 351 | assert!(global_no_color()); 352 | 353 | unset_no_color(); 354 | reset_global_no_color_for_tests(); 355 | } 356 | } 357 | -------------------------------------------------------------------------------- /src/modules/rust.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::memo::{RUST_VERSION, memoized_version}; 3 | use crate::module_trait::{Module, ModuleContext}; 4 | use crate::modules::utils; 5 | use dirs::home_dir; 6 | use std::collections::HashMap; 7 | use std::env; 8 | use std::fs; 9 | use std::path::{Path, PathBuf}; 10 | use std::process::Command; 11 | use std::sync::OnceLock; 12 | use toml::Value; 13 | 14 | pub struct RustModule; 15 | 16 | impl Default for RustModule { 17 | fn default() -> Self { 18 | Self::new() 19 | } 20 | } 21 | 22 | impl RustModule { 23 | pub fn new() -> Self { 24 | Self 25 | } 26 | } 27 | 28 | impl Module for RustModule { 29 | fn fs_markers(&self) -> &'static [&'static str] { 30 | &["Cargo.toml"] 31 | } 32 | 33 | fn render(&self, format: &str, context: &ModuleContext) -> Result> { 34 | if context.marker_path("Cargo.toml").is_none() { 35 | return Ok(None); 36 | } 37 | 38 | if context.no_version { 39 | return Ok(Some("rust".to_string())); 40 | } 41 | 42 | let normalized_format = utils::validate_version_format(format, "rust")?; 43 | 44 | let version = match memoized_version(&RUST_VERSION, get_rust_version) { 45 | Some(v) => v, 46 | None => return Ok(None), 47 | }; 48 | let version_str = version.as_ref(); 49 | 50 | match normalized_format { 51 | "full" => Ok(Some(version_str.to_string())), 52 | "short" => { 53 | let parts: Vec<&str> = version_str.split('.').collect(); 54 | if parts.len() >= 2 { 55 | Ok(Some(format!("{}.{}", parts[0], parts[1]))) 56 | } else { 57 | Ok(Some(version_str.to_string())) 58 | } 59 | } 60 | "major" => Ok(version_str.split('.').next().map(|s| s.to_string())), 61 | _ => unreachable!("validate_version_format should have caught this"), 62 | } 63 | } 64 | } 65 | 66 | #[derive(Default)] 67 | struct RustupSettings { 68 | default_toolchain: Option, 69 | default_host_triple: Option, 70 | overrides: HashMap, 71 | } 72 | 73 | impl RustupSettings { 74 | fn default_toolchain(&self) -> Option<&str> { 75 | self.default_toolchain.as_deref() 76 | } 77 | 78 | fn default_host_triple(&self) -> Option<&str> { 79 | self.default_host_triple.as_deref() 80 | } 81 | 82 | fn lookup_override(&self, cwd: &Path) -> Option { 83 | let mut best: Option<(usize, &String)> = None; 84 | 85 | for (path, toolchain) in &self.overrides { 86 | if cwd.starts_with(path) { 87 | let depth = path.components().count(); 88 | match best { 89 | Some((best_depth, _)) if best_depth >= depth => {} 90 | _ => best = Some((depth, toolchain)), 91 | } 92 | } 93 | } 94 | 95 | best.map(|(_, toolchain)| toolchain.clone()) 96 | } 97 | } 98 | 99 | static RUSTUP_SETTINGS: OnceLock = OnceLock::new(); 100 | static TOOLCHAIN_OVERRIDE: OnceLock> = OnceLock::new(); 101 | 102 | fn rustup_settings() -> &'static RustupSettings { 103 | RUSTUP_SETTINGS.get_or_init(load_rustup_settings) 104 | } 105 | 106 | fn toolchain_override() -> Option { 107 | TOOLCHAIN_OVERRIDE 108 | .get_or_init(compute_toolchain_override) 109 | .clone() 110 | } 111 | 112 | fn get_rust_version() -> Option { 113 | let settings = rustup_settings(); 114 | 115 | if let Some(toolchain) = toolchain_override() 116 | && let Some(version) = run_rustc_for_toolchain(&toolchain, settings) 117 | { 118 | return Some(version); 119 | } 120 | 121 | run_plain_rustc() 122 | } 123 | 124 | fn run_rustc_for_toolchain(toolchain: &str, settings: &RustupSettings) -> Option { 125 | if let Some(path) = resolve_rustc_path(toolchain, settings) { 126 | let mut cmd = Command::new(path); 127 | cmd.arg("--version"); 128 | if let Some(output) = run_command(cmd) { 129 | return parse_rustc_version(&output); 130 | } 131 | } 132 | 133 | let mut cmd = Command::new("rustup"); 134 | cmd.args(["run", toolchain, "rustc", "--version"]); 135 | run_command(cmd).and_then(|out| parse_rustc_version(&out)) 136 | } 137 | 138 | fn run_plain_rustc() -> Option { 139 | let mut cmd = Command::new("rustc"); 140 | cmd.arg("--version"); 141 | run_command(cmd).and_then(|out| parse_rustc_version(&out)) 142 | } 143 | 144 | fn run_command(mut command: Command) -> Option { 145 | let output = command.output().ok()?; 146 | if !output.status.success() { 147 | return None; 148 | } 149 | Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) 150 | } 151 | 152 | fn parse_rustc_version(stdout: &str) -> Option { 153 | stdout.split_whitespace().nth(1).map(|s| s.to_string()) 154 | } 155 | 156 | fn compute_toolchain_override() -> Option { 157 | if let Ok(toolchain) = env::var("RUSTUP_TOOLCHAIN") { 158 | let trimmed = toolchain.trim(); 159 | if !trimmed.is_empty() { 160 | return Some(trimmed.to_string()); 161 | } 162 | } 163 | 164 | let settings = rustup_settings(); 165 | let cwd = env::current_dir().ok(); 166 | 167 | if let Some(ref dir) = cwd { 168 | if let Some(toolchain) = settings.lookup_override(dir) { 169 | return Some(toolchain); 170 | } 171 | if let Some(toolchain) = find_toolchain_file(dir) { 172 | return Some(toolchain); 173 | } 174 | } 175 | 176 | settings.default_toolchain().map(|s| s.to_string()) 177 | } 178 | 179 | fn load_rustup_settings() -> RustupSettings { 180 | let mut settings = RustupSettings::default(); 181 | 182 | let Some(home) = rustup_home() else { 183 | return settings; 184 | }; 185 | 186 | let path = home.join("settings.toml"); 187 | let Ok(contents) = fs::read_to_string(path) else { 188 | return settings; 189 | }; 190 | 191 | let Ok(value) = toml::from_str::(&contents) else { 192 | return settings; 193 | }; 194 | 195 | if value 196 | .get("version") 197 | .and_then(Value::as_str) 198 | .map(|v| v != "12") 199 | .unwrap_or(false) 200 | { 201 | return settings; 202 | } 203 | 204 | if let Some(default_toolchain) = value.get("default_toolchain").and_then(Value::as_str) { 205 | let toolchain = default_toolchain.trim(); 206 | if !toolchain.is_empty() { 207 | settings.default_toolchain = Some(toolchain.to_string()); 208 | } 209 | } 210 | 211 | if let Some(host) = value.get("default_host_triple").and_then(Value::as_str) { 212 | let host = host.trim(); 213 | if !host.is_empty() { 214 | settings.default_host_triple = Some(host.to_string()); 215 | } 216 | } 217 | 218 | if let Some(overrides) = value.get("overrides").and_then(Value::as_table) { 219 | for (path, toolchain) in overrides { 220 | if let Some(toolchain) = toolchain.as_str() { 221 | let trimmed = toolchain.trim(); 222 | if trimmed.is_empty() { 223 | continue; 224 | } 225 | let pathbuf = PathBuf::from(path); 226 | let canonical = fs::canonicalize(&pathbuf).unwrap_or(pathbuf); 227 | settings.overrides.insert(canonical, trimmed.to_string()); 228 | } 229 | } 230 | } 231 | 232 | settings 233 | } 234 | 235 | fn resolve_rustc_path(toolchain: &str, settings: &RustupSettings) -> Option { 236 | let home = rustup_home()?; 237 | let bin = rustc_binary_name(); 238 | 239 | let base = home.join("toolchains"); 240 | let direct = base.join(toolchain).join("bin").join(bin); 241 | if direct.exists() { 242 | return Some(direct); 243 | } 244 | 245 | if toolchain.contains('-') { 246 | return None; 247 | } 248 | 249 | let host_candidate = settings 250 | .default_host_triple() 251 | .map(|s| s.to_string()) 252 | .or_else(|| env::var("HOST").ok()); 253 | 254 | if let Some(host) = host_candidate { 255 | let alt = base 256 | .join(format!("{toolchain}-{host}")) 257 | .join("bin") 258 | .join(bin); 259 | if alt.exists() { 260 | return Some(alt); 261 | } 262 | } 263 | 264 | None 265 | } 266 | 267 | fn find_toolchain_file(start: &Path) -> Option { 268 | let mut dir = Some(start); 269 | while let Some(current) = dir { 270 | if let Some(toolchain) = read_toolchain_file(¤t.join("rust-toolchain"), false) { 271 | return Some(toolchain); 272 | } 273 | if let Some(toolchain) = read_toolchain_file(¤t.join("rust-toolchain.toml"), true) { 274 | return Some(toolchain); 275 | } 276 | dir = current.parent(); 277 | } 278 | None 279 | } 280 | 281 | fn read_toolchain_file(path: &Path, toml_only: bool) -> Option { 282 | let contents = fs::read_to_string(path).ok()?; 283 | let trimmed = contents.trim(); 284 | if trimmed.is_empty() { 285 | return None; 286 | } 287 | 288 | if !toml_only && !trimmed.contains('\n') { 289 | return Some(trimmed.to_string()); 290 | } 291 | 292 | let value: Value = toml::from_str(&contents).ok()?; 293 | match value.get("toolchain") { 294 | Some(Value::Table(table)) => table 295 | .get("channel") 296 | .and_then(Value::as_str) 297 | .map(|s| s.trim().to_string()), 298 | Some(Value::String(channel)) => { 299 | let channel = channel.trim(); 300 | if channel.is_empty() { 301 | None 302 | } else { 303 | Some(channel.to_string()) 304 | } 305 | } 306 | _ => None, 307 | } 308 | } 309 | 310 | fn rustup_home() -> Option { 311 | if let Ok(path) = env::var("RUSTUP_HOME") { 312 | let trimmed = path.trim(); 313 | if !trimmed.is_empty() { 314 | return Some(PathBuf::from(trimmed)); 315 | } 316 | } 317 | 318 | home_dir().map(|dir| dir.join(".rustup")) 319 | } 320 | 321 | fn rustc_binary_name() -> &'static str { 322 | if cfg!(windows) { "rustc.exe" } else { "rustc" } 323 | } 324 | 325 | #[cfg(test)] 326 | mod tests { 327 | use super::*; 328 | use std::io::Write; 329 | use tempfile::tempdir; 330 | 331 | #[test] 332 | fn parses_rustc_version() { 333 | let input = "rustc 1.76.0 (a58dcd2a3 2024-01-17)"; 334 | assert_eq!(parse_rustc_version(input), Some("1.76.0".to_string())); 335 | } 336 | 337 | #[test] 338 | fn read_plain_toolchain_file() { 339 | let dir = tempdir().unwrap(); 340 | let path = dir.path().join("rust-toolchain"); 341 | fs::write(&path, "stable\n").unwrap(); 342 | assert_eq!( 343 | read_toolchain_file(&path, false), 344 | Some("stable".to_string()) 345 | ); 346 | } 347 | 348 | #[test] 349 | fn read_toml_toolchain_file() { 350 | let dir = tempdir().unwrap(); 351 | let path = dir.path().join("rust-toolchain.toml"); 352 | let mut file = fs::File::create(&path).unwrap(); 353 | writeln!( 354 | file, 355 | "[toolchain]\nchannel = \"nightly-x86_64-unknown-linux-gnu\"" 356 | ) 357 | .unwrap(); 358 | assert_eq!( 359 | read_toolchain_file(&path, true), 360 | Some("nightly-x86_64-unknown-linux-gnu".to_string()) 361 | ); 362 | } 363 | } 364 | -------------------------------------------------------------------------------- /benches/comprehensive_bench.rs: -------------------------------------------------------------------------------- 1 | use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; 2 | use prmt::detector::{DetectionContext, detect}; 3 | use prmt::style::Shell; 4 | use prmt::{ModuleContext, ModuleRegistry, Template, execute, parse}; 5 | use std::collections::HashSet; 6 | use std::hint::black_box; 7 | use std::time::Duration; 8 | 9 | fn setup_registry() -> ModuleRegistry { 10 | use prmt::modules::*; 11 | use std::sync::Arc; 12 | 13 | let mut registry = ModuleRegistry::new(); 14 | registry.register("path", Arc::new(path::PathModule)); 15 | registry.register("git", Arc::new(git::GitModule)); 16 | registry.register("rust", Arc::new(rust::RustModule)); 17 | registry.register("node", Arc::new(node::NodeModule)); 18 | registry.register("python", Arc::new(python::PythonModule)); 19 | registry.register("go", Arc::new(go::GoModule)); 20 | registry.register("deno", Arc::new(deno::DenoModule)); 21 | registry.register("bun", Arc::new(bun::BunModule)); 22 | registry.register("ok", Arc::new(ok::OkModule)); 23 | registry.register("fail", Arc::new(fail::FailModule)); 24 | registry 25 | } 26 | 27 | fn detection_for(markers: &[&'static str]) -> DetectionContext { 28 | if markers.is_empty() { 29 | return DetectionContext::default(); 30 | } 31 | 32 | let required: HashSet<&str> = markers.iter().copied().collect(); 33 | 34 | detect(&required) 35 | } 36 | 37 | fn ctx(no_version: bool, exit_code: Option, markers: &[&'static str]) -> ModuleContext { 38 | ModuleContext { 39 | no_version, 40 | exit_code, 41 | detection: detection_for(markers), 42 | shell: Shell::None, 43 | } 44 | } 45 | 46 | fn bench_parser_scenarios(c: &mut Criterion) { 47 | let mut group = c.benchmark_group("parser_scenarios"); 48 | 49 | // Different input sizes 50 | let scenarios = vec![ 51 | ("empty", ""), 52 | ("tiny", "{path}"), 53 | ("small", "{path:cyan} {git:purple}"), 54 | ( 55 | "medium", 56 | "{path:cyan:short:[:]} {rust:red} {node:green} {git:purple:full}", 57 | ), 58 | ( 59 | "large", 60 | "{path:cyan:truncate:30:>>:<<} {rust:red:full} {node:green:major} {python:yellow:short} {go:blue} {deno:magenta} {bun:white} {git:purple:full:🌿:} {ok:green:✓} {fail:red:✗}", 61 | ), 62 | ( 63 | "escaped_heavy", 64 | "\\{escaped\\} {real} \\n\\t\\: {another:with\\:colon} \\\\backslash", 65 | ), 66 | ( 67 | "text_heavy", 68 | "This is a long text prefix before {path} and then more text {git} and even more text at the end", 69 | ), 70 | ("placeholder_only", "{a}{b}{c}{d}{e}{f}{g}{h}{i}{j}"), 71 | ]; 72 | 73 | for (name, template) in scenarios { 74 | group.throughput(Throughput::Bytes(template.len() as u64)); 75 | group.bench_with_input( 76 | BenchmarkId::from_parameter(name), 77 | &template, 78 | |b, &template| { 79 | b.iter(|| parse(black_box(template))); 80 | }, 81 | ); 82 | } 83 | 84 | group.finish(); 85 | } 86 | 87 | fn bench_template_rendering(c: &mut Criterion) { 88 | let mut group = c.benchmark_group("template_rendering"); 89 | 90 | let registry = setup_registry(); 91 | let ctx_minimal = ctx(true, Some(0), &[]); 92 | let ctx_git = ctx(true, Some(0), &[".git"]); 93 | let ctx_error = ctx(true, Some(1), &[".git"]); 94 | let ctx_with_versions = ctx(false, Some(0), &[".git", "Cargo.toml", "package.json"]); 95 | let ctx_all = ctx( 96 | true, 97 | Some(0), 98 | &[ 99 | ".git", 100 | "Cargo.toml", 101 | "package.json", 102 | "requirements.txt", 103 | "go.mod", 104 | "deno.json", 105 | "bun.lockb", 106 | ], 107 | ); 108 | 109 | let scenarios = vec![ 110 | ("minimal", "{path}", ctx_minimal.clone()), 111 | ( 112 | "typical_success", 113 | "{path:cyan} {git:purple} {ok:green:✓}", 114 | ctx_git.clone(), 115 | ), 116 | ( 117 | "typical_error", 118 | "{path:cyan} {git:purple} {fail:red:✗}", 119 | ctx_error.clone(), 120 | ), 121 | ( 122 | "with_versions", 123 | "{path} {rust} {node} {git}", 124 | ctx_with_versions.clone(), 125 | ), 126 | ( 127 | "complex_styled", 128 | "{path:cyan.bold:short:[:]} {git:purple.italic::on :}", 129 | ctx_git.clone(), 130 | ), 131 | ( 132 | "all_modules", 133 | "{path} {rust} {node} {python} {go} {deno} {bun} {git} {ok}", 134 | ctx_all.clone(), 135 | ), 136 | ]; 137 | 138 | for (name, template_str, context) in scenarios { 139 | let template = Template::new(template_str); 140 | group.bench_with_input( 141 | BenchmarkId::from_parameter(name), 142 | &(&template, ®istry, &context), 143 | |b, &(template, registry, context)| { 144 | b.iter(|| template.render(black_box(registry), black_box(context))); 145 | }, 146 | ); 147 | } 148 | 149 | group.finish(); 150 | } 151 | 152 | fn bench_end_to_end_scenarios(c: &mut Criterion) { 153 | let mut group = c.benchmark_group("end_to_end"); 154 | group.measurement_time(Duration::from_secs(10)); 155 | 156 | let scenarios = vec![ 157 | ("minimal", "{path}"), 158 | ("shell_bash", "\\u{250c}[{path:cyan}]\\n\\u{2514}> "), 159 | ("shell_fish", "{path:cyan} {git:purple}❯ "), 160 | ( 161 | "shell_zsh", 162 | "{path:blue:short} on {git:yellow::🌿:} {rust} ", 163 | ), 164 | ( 165 | "powerline", 166 | "{path:cyan::: }{git:purple.bold::: }{ok:green:❯:}{fail:red:❯:}", 167 | ), 168 | ( 169 | "verbose", 170 | "{path:cyan:absolute} ({rust:red:full} {node:green:full}) [{git:purple:full}] ", 171 | ), 172 | ("corporate", "[{path}] <{git:short}> {ok:$:}{fail:$:} "), 173 | ]; 174 | 175 | for (name, format) in scenarios { 176 | group.bench_with_input(BenchmarkId::from_parameter(name), &format, |b, &format| { 177 | b.iter(|| execute(black_box(format), true, Some(0), false)); 178 | }); 179 | } 180 | 181 | group.finish(); 182 | } 183 | 184 | fn bench_memo_effectiveness(c: &mut Criterion) { 185 | let mut group = c.benchmark_group("memo_effectiveness"); 186 | 187 | // First call (nothing memoized) 188 | group.bench_function("git_cold_memo", |b| { 189 | use prmt::Module; 190 | use prmt::modules::git::GitModule; 191 | 192 | let module = GitModule; 193 | let context = ctx(false, None, &[".git"]); 194 | 195 | b.iter(|| { 196 | // Clear memo would go here if we had a method for it 197 | module.render(black_box("full"), black_box(&context)) 198 | }); 199 | }); 200 | 201 | // Warm memo 202 | group.bench_function("git_warm_memo", |b| { 203 | use prmt::Module; 204 | use prmt::modules::git::GitModule; 205 | 206 | let module = GitModule; 207 | let context = ctx(false, None, &[".git"]); 208 | 209 | // Warm the memoized value 210 | let _ = module.render("full", &context); 211 | 212 | b.iter(|| module.render(black_box("full"), black_box(&context))); 213 | }); 214 | 215 | // Version module cold 216 | group.bench_function("rust_version_cold", |b| { 217 | use prmt::Module; 218 | use prmt::modules::rust::RustModule; 219 | 220 | let module = RustModule; 221 | let context = ctx(false, None, &["Cargo.toml"]); 222 | 223 | b.iter(|| module.render(black_box("full"), black_box(&context))); 224 | }); 225 | 226 | // Version module with no_version flag 227 | group.bench_function("rust_no_version_flag", |b| { 228 | use prmt::Module; 229 | use prmt::modules::rust::RustModule; 230 | 231 | let module = RustModule; 232 | let context = ctx(true, None, &["Cargo.toml"]); 233 | 234 | b.iter(|| module.render(black_box("full"), black_box(&context))); 235 | }); 236 | 237 | group.finish(); 238 | } 239 | 240 | fn bench_string_operations(c: &mut Criterion) { 241 | let mut group = c.benchmark_group("string_operations"); 242 | 243 | // Benchmark different string building strategies 244 | group.bench_function("string_push_str", |b| { 245 | b.iter(|| { 246 | let mut s = String::with_capacity(100); 247 | for _ in 0..10 { 248 | s.push_str("hello "); 249 | s.push_str("world "); 250 | } 251 | black_box(s) 252 | }); 253 | }); 254 | 255 | group.bench_function("string_format", |b| { 256 | b.iter(|| { 257 | let mut s = String::new(); 258 | for _ in 0..10 { 259 | s = format!("{} hello world ", s); 260 | } 261 | black_box(s) 262 | }); 263 | }); 264 | 265 | // Benchmark Cow operations 266 | group.bench_function("cow_borrowed", |b| { 267 | use std::borrow::Cow; 268 | b.iter(|| { 269 | let text = "hello world"; 270 | let cow: Cow = Cow::Borrowed(text); 271 | black_box(cow) 272 | }); 273 | }); 274 | 275 | group.bench_function("cow_owned", |b| { 276 | use std::borrow::Cow; 277 | b.iter(|| { 278 | let text = "hello world".to_string(); 279 | let cow: Cow = Cow::Owned(text); 280 | black_box(cow) 281 | }); 282 | }); 283 | 284 | group.finish(); 285 | } 286 | 287 | fn bench_unicode_operations(c: &mut Criterion) { 288 | let mut group = c.benchmark_group("unicode"); 289 | 290 | use unicode_width::UnicodeWidthStr; 291 | 292 | let strings = vec![ 293 | ("ascii", "hello world"), 294 | ("emoji", "👋 🌍 Hello World! 🎉"), 295 | ("cjk", "你好世界 こんにちは世界"), 296 | ("mixed", "Hello 世界 🌍 мир"), 297 | ]; 298 | 299 | for (name, text) in strings { 300 | group.bench_with_input( 301 | BenchmarkId::new("width_calculation", name), 302 | &text, 303 | |b, &text| { 304 | b.iter(|| UnicodeWidthStr::width(black_box(text))); 305 | }, 306 | ); 307 | } 308 | 309 | group.finish(); 310 | } 311 | 312 | fn bench_style_parsing(c: &mut Criterion) { 313 | use prmt::style::{AnsiStyle, ModuleStyle}; 314 | 315 | let mut group = c.benchmark_group("style_parsing"); 316 | 317 | let styles = vec![ 318 | ("simple", "red"), 319 | ("with_modifiers", "cyan.bold.italic"), 320 | ("hex_color", "#00ff00"), 321 | ("complex", "yellow.bold.italic.underline.dim"), 322 | ]; 323 | 324 | for (name, style_str) in styles { 325 | group.bench_with_input( 326 | BenchmarkId::from_parameter(name), 327 | &style_str, 328 | |b, &style_str| { 329 | b.iter(|| AnsiStyle::parse(black_box(style_str))); 330 | }, 331 | ); 332 | } 333 | 334 | group.finish(); 335 | } 336 | 337 | fn bench_worst_case_scenarios(c: &mut Criterion) { 338 | let mut group = c.benchmark_group("worst_case"); 339 | 340 | // Deeply nested escapes 341 | let nested_escapes = "\\\\\\{\\\\\\}\\\\\\{\\\\\\}\\\\\\n\\\\\\t"; 342 | group.bench_function("deeply_nested_escapes", |b| { 343 | b.iter(|| parse(black_box(nested_escapes))); 344 | }); 345 | 346 | // Many small placeholders 347 | let many_placeholders = (0..50).map(|i| format!("{{p{}}}", i)).collect::(); 348 | group.bench_function("many_placeholders", |b| { 349 | b.iter(|| parse(black_box(&many_placeholders))); 350 | }); 351 | 352 | // Very long single placeholder 353 | let long_placeholder = format!( 354 | "{{{}:{}:{}:{}:{}}}", 355 | "m".repeat(100), 356 | "s".repeat(100), 357 | "f".repeat(100), 358 | "p".repeat(100), 359 | "x".repeat(100) 360 | ); 361 | group.bench_function("long_placeholder", |b| { 362 | b.iter(|| parse(black_box(&long_placeholder))); 363 | }); 364 | 365 | group.finish(); 366 | } 367 | 368 | criterion_group!( 369 | benches, 370 | bench_parser_scenarios, 371 | bench_template_rendering, 372 | bench_end_to_end_scenarios, 373 | bench_memo_effectiveness, 374 | bench_string_operations, 375 | bench_unicode_operations, 376 | bench_style_parsing, 377 | bench_worst_case_scenarios 378 | ); 379 | criterion_main!(benches); 380 | -------------------------------------------------------------------------------- /tests/parser_test.rs: -------------------------------------------------------------------------------- 1 | use prmt::{Token, parse}; 2 | 3 | #[test] 4 | fn test_empty_string() { 5 | let tokens = parse(""); 6 | assert_eq!(tokens.len(), 0); 7 | } 8 | 9 | #[test] 10 | fn test_plain_text() { 11 | let tokens = parse("plain text without placeholders"); 12 | assert_eq!(tokens.len(), 1); 13 | match &tokens[0] { 14 | Token::Text(text) => assert_eq!(text, "plain text without placeholders"), 15 | _ => panic!("Expected text token"), 16 | } 17 | } 18 | 19 | #[test] 20 | fn test_single_placeholder_minimal() { 21 | let tokens = parse("{module}"); 22 | assert_eq!(tokens.len(), 1); 23 | match &tokens[0] { 24 | Token::Placeholder(params) => { 25 | assert_eq!(params.module, "module"); 26 | assert_eq!(params.style, ""); 27 | assert_eq!(params.format, ""); 28 | assert_eq!(params.prefix, ""); 29 | assert_eq!(params.suffix, ""); 30 | } 31 | _ => panic!("Expected placeholder token"), 32 | } 33 | } 34 | 35 | #[test] 36 | fn test_single_placeholder_with_style() { 37 | let tokens = parse("{module:cyan}"); 38 | assert_eq!(tokens.len(), 1); 39 | match &tokens[0] { 40 | Token::Placeholder(params) => { 41 | assert_eq!(params.module, "module"); 42 | assert_eq!(params.style, "cyan"); 43 | assert_eq!(params.format, ""); 44 | assert_eq!(params.prefix, ""); 45 | assert_eq!(params.suffix, ""); 46 | } 47 | _ => panic!("Expected placeholder token"), 48 | } 49 | } 50 | 51 | #[test] 52 | fn test_single_placeholder_with_format() { 53 | let tokens = parse("{module::short}"); 54 | assert_eq!(tokens.len(), 1); 55 | match &tokens[0] { 56 | Token::Placeholder(params) => { 57 | assert_eq!(params.module, "module"); 58 | assert_eq!(params.style, ""); 59 | assert_eq!(params.format, "short"); 60 | assert_eq!(params.prefix, ""); 61 | assert_eq!(params.suffix, ""); 62 | } 63 | _ => panic!("Expected placeholder token"), 64 | } 65 | } 66 | 67 | #[test] 68 | fn test_single_placeholder_with_prefix_suffix() { 69 | let tokens = parse("{module:::before:after}"); 70 | assert_eq!(tokens.len(), 1); 71 | match &tokens[0] { 72 | Token::Placeholder(params) => { 73 | assert_eq!(params.module, "module"); 74 | assert_eq!(params.style, ""); 75 | assert_eq!(params.format, ""); 76 | assert_eq!(params.prefix, "before"); 77 | assert_eq!(params.suffix, "after"); 78 | } 79 | _ => panic!("Expected placeholder token"), 80 | } 81 | } 82 | 83 | #[test] 84 | fn test_placeholder_all_fields() { 85 | let tokens = parse("{module:red.bold:short:[:]}"); 86 | assert_eq!(tokens.len(), 1); 87 | match &tokens[0] { 88 | Token::Placeholder(params) => { 89 | assert_eq!(params.module, "module"); 90 | assert_eq!(params.style, "red.bold"); 91 | assert_eq!(params.format, "short"); 92 | assert_eq!(params.prefix, "["); 93 | assert_eq!(params.suffix, "]"); 94 | } 95 | _ => panic!("Expected placeholder token"), 96 | } 97 | } 98 | 99 | #[test] 100 | fn test_multiple_placeholders() { 101 | let tokens = parse("{path}{git}{rust}"); 102 | assert_eq!(tokens.len(), 3); 103 | 104 | match &tokens[0] { 105 | Token::Placeholder(params) => assert_eq!(params.module, "path"), 106 | _ => panic!("Expected placeholder token"), 107 | } 108 | 109 | match &tokens[1] { 110 | Token::Placeholder(params) => assert_eq!(params.module, "git"), 111 | _ => panic!("Expected placeholder token"), 112 | } 113 | 114 | match &tokens[2] { 115 | Token::Placeholder(params) => assert_eq!(params.module, "rust"), 116 | _ => panic!("Expected placeholder token"), 117 | } 118 | } 119 | 120 | #[test] 121 | fn test_mixed_text_and_placeholders() { 122 | let tokens = parse("start {path} middle {git} end"); 123 | assert_eq!(tokens.len(), 5); 124 | 125 | match &tokens[0] { 126 | Token::Text(text) => assert_eq!(text, "start "), 127 | _ => panic!("Expected text token"), 128 | } 129 | 130 | match &tokens[1] { 131 | Token::Placeholder(params) => assert_eq!(params.module, "path"), 132 | _ => panic!("Expected placeholder token"), 133 | } 134 | 135 | match &tokens[2] { 136 | Token::Text(text) => assert_eq!(text, " middle "), 137 | _ => panic!("Expected text token"), 138 | } 139 | 140 | match &tokens[3] { 141 | Token::Placeholder(params) => assert_eq!(params.module, "git"), 142 | _ => panic!("Expected placeholder token"), 143 | } 144 | 145 | match &tokens[4] { 146 | Token::Text(text) => assert_eq!(text, " end"), 147 | _ => panic!("Expected text token"), 148 | } 149 | } 150 | 151 | #[test] 152 | fn test_escape_sequences() { 153 | // Test escaped braces 154 | let tokens = parse("\\{not a placeholder\\}"); 155 | let text = tokens 156 | .iter() 157 | .map(|t| match t { 158 | Token::Text(s) => s.as_ref(), 159 | _ => panic!("Expected text token"), 160 | }) 161 | .collect::(); 162 | assert_eq!(text, "{not a placeholder}"); 163 | 164 | // Test escaped colon 165 | let tokens = parse("time\\: 12\\:30"); 166 | let text = tokens 167 | .iter() 168 | .map(|t| match t { 169 | Token::Text(s) => s.as_ref(), 170 | _ => panic!("Expected text token"), 171 | }) 172 | .collect::(); 173 | assert_eq!(text, "time: 12:30"); 174 | 175 | // Test escaped backslash 176 | let tokens = parse("path\\\\to\\\\file"); 177 | let text = tokens 178 | .iter() 179 | .map(|t| match t { 180 | Token::Text(s) => s.as_ref(), 181 | _ => panic!("Expected text token"), 182 | }) 183 | .collect::(); 184 | assert_eq!(text, "path\\to\\file"); 185 | 186 | // Test newline and tab 187 | let tokens = parse("line1\\nline2\\ttab"); 188 | let text = tokens 189 | .iter() 190 | .map(|t| match t { 191 | Token::Text(s) => s.as_ref(), 192 | _ => panic!("Expected text token"), 193 | }) 194 | .collect::(); 195 | assert_eq!(text, "line1\nline2\ttab"); 196 | } 197 | 198 | #[test] 199 | fn test_special_characters_in_fields() { 200 | // Test special chars in prefix/suffix 201 | let tokens = parse("{module:::>>>:<<<}"); 202 | match &tokens[0] { 203 | Token::Placeholder(params) => { 204 | assert_eq!(params.prefix, ">>>"); 205 | assert_eq!(params.suffix, "<<<"); 206 | } 207 | _ => panic!("Expected placeholder token"), 208 | } 209 | 210 | // Test spaces in prefix/suffix 211 | let tokens = parse("{module::: on : }"); 212 | match &tokens[0] { 213 | Token::Placeholder(params) => { 214 | assert_eq!(params.prefix, " on "); 215 | assert_eq!(params.suffix, " "); 216 | } 217 | _ => panic!("Expected placeholder token"), 218 | } 219 | 220 | // Test symbols as format (for ok/fail modules) 221 | let tokens = parse("{ok::✓}"); 222 | match &tokens[0] { 223 | Token::Placeholder(params) => { 224 | assert_eq!(params.module, "ok"); 225 | assert_eq!(params.format, "✓"); 226 | } 227 | _ => panic!("Expected placeholder token"), 228 | } 229 | } 230 | 231 | #[test] 232 | fn test_complex_styles() { 233 | let tokens = parse("{module:cyan.bold.italic}"); 234 | match &tokens[0] { 235 | Token::Placeholder(params) => { 236 | assert_eq!(params.style, "cyan.bold.italic"); 237 | } 238 | _ => panic!("Expected placeholder token"), 239 | } 240 | 241 | let tokens = parse("{module:#ff0000.bold}"); 242 | match &tokens[0] { 243 | Token::Placeholder(params) => { 244 | assert_eq!(params.style, "#ff0000.bold"); 245 | } 246 | _ => panic!("Expected placeholder token"), 247 | } 248 | } 249 | 250 | #[test] 251 | fn test_empty_fields() { 252 | // All fields empty except module 253 | let tokens = parse("{module::::}"); 254 | match &tokens[0] { 255 | Token::Placeholder(params) => { 256 | assert_eq!(params.module, "module"); 257 | assert_eq!(params.style, ""); 258 | assert_eq!(params.format, ""); 259 | assert_eq!(params.prefix, ""); 260 | assert_eq!(params.suffix, ""); 261 | } 262 | _ => panic!("Expected placeholder token"), 263 | } 264 | 265 | // Style and format empty, but prefix/suffix present 266 | let tokens = parse("{module:::A:B}"); 267 | match &tokens[0] { 268 | Token::Placeholder(params) => { 269 | assert_eq!(params.module, "module"); 270 | assert_eq!(params.style, ""); 271 | assert_eq!(params.format, ""); 272 | assert_eq!(params.prefix, "A"); 273 | assert_eq!(params.suffix, "B"); 274 | } 275 | _ => panic!("Expected placeholder token"), 276 | } 277 | } 278 | 279 | #[test] 280 | fn test_nested_braces_not_allowed() { 281 | // Parser should handle nested braces as text 282 | let tokens = parse("{module:{nested}}"); 283 | // This should parse as placeholder with module "module" and style "{nested" 284 | match &tokens[0] { 285 | Token::Placeholder(params) => { 286 | assert_eq!(params.module, "module"); 287 | assert_eq!(params.style, "{nested"); 288 | } 289 | _ => panic!("Expected placeholder token"), 290 | } 291 | } 292 | 293 | #[test] 294 | fn test_unclosed_placeholder() { 295 | // Unclosed placeholder should be treated as text 296 | let tokens = parse("{unclosed"); 297 | let text = tokens 298 | .iter() 299 | .map(|t| match t { 300 | Token::Text(s) => s.as_ref(), 301 | _ => panic!("Expected text token"), 302 | }) 303 | .collect::(); 304 | assert_eq!(text, "{unclosed"); 305 | } 306 | 307 | #[test] 308 | fn test_consecutive_placeholders() { 309 | let tokens = parse("{a}{b}{c}"); 310 | assert_eq!(tokens.len(), 3); 311 | 312 | match &tokens[0] { 313 | Token::Placeholder(params) => assert_eq!(params.module, "a"), 314 | _ => panic!("Expected placeholder token"), 315 | } 316 | 317 | match &tokens[1] { 318 | Token::Placeholder(params) => assert_eq!(params.module, "b"), 319 | _ => panic!("Expected placeholder token"), 320 | } 321 | 322 | match &tokens[2] { 323 | Token::Placeholder(params) => assert_eq!(params.module, "c"), 324 | _ => panic!("Expected placeholder token"), 325 | } 326 | } 327 | 328 | #[test] 329 | fn test_real_world_formats() { 330 | // Common prompt format 331 | let tokens = parse("{path:cyan} {git:purple} {rust:red}"); 332 | assert_eq!(tokens.len(), 5); 333 | 334 | // With prefix/suffix 335 | let tokens = parse("{ok:green:✓:[:]} {fail:red:✗:[:]}"); 336 | assert_eq!(tokens.len(), 3); 337 | 338 | // Complex format 339 | let tokens = parse("{path:blue.bold:tilde} on {git:yellow::🌿 :} {rust:::v:}"); 340 | assert!(!tokens.is_empty()); 341 | } 342 | 343 | #[test] 344 | fn test_whitespace_preservation() { 345 | let tokens = parse(" spaces {module} more spaces "); 346 | 347 | match &tokens[0] { 348 | Token::Text(text) => assert_eq!(text, " spaces "), 349 | _ => panic!("Expected text token"), 350 | } 351 | 352 | match &tokens[2] { 353 | Token::Text(text) => assert_eq!(text, " more spaces "), 354 | _ => panic!("Expected text token"), 355 | } 356 | } 357 | 358 | #[test] 359 | fn test_colon_in_text() { 360 | // Colons outside placeholders should be preserved 361 | let tokens = parse("time: {module} at: location"); 362 | 363 | match &tokens[0] { 364 | Token::Text(text) => assert_eq!(text, "time: "), 365 | _ => panic!("Expected text token"), 366 | } 367 | 368 | match &tokens[2] { 369 | Token::Text(text) => assert_eq!(text, " at: location"), 370 | _ => panic!("Expected text token"), 371 | } 372 | } 373 | 374 | #[test] 375 | fn test_escape_in_placeholder_fields() { 376 | // Escapes should work within placeholder fields 377 | let tokens = parse("{module:::a\\:b:c\\:d}"); 378 | match &tokens[0] { 379 | Token::Placeholder(params) => { 380 | // The parser should handle escapes in prefix/suffix 381 | assert_eq!(params.prefix, "a:b"); 382 | assert_eq!(params.suffix, "c:d"); 383 | } 384 | _ => panic!("Expected placeholder token"), 385 | } 386 | } 387 | -------------------------------------------------------------------------------- /src/parser.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | #[derive(Debug, Clone, PartialEq)] 4 | pub struct Params { 5 | pub module: String, 6 | pub style: String, 7 | pub format: String, 8 | pub prefix: String, 9 | pub suffix: String, 10 | } 11 | 12 | #[derive(Debug, Clone, PartialEq)] 13 | pub enum Token<'a> { 14 | Text(Cow<'a, str>), 15 | Placeholder(Params), 16 | } 17 | 18 | pub struct Parser<'a> { 19 | bytes: &'a [u8], 20 | pos: usize, 21 | } 22 | 23 | impl<'a> Parser<'a> { 24 | pub fn new(input: &'a str) -> Self { 25 | Self { 26 | bytes: input.as_bytes(), 27 | pos: 0, 28 | } 29 | } 30 | 31 | fn skip_to(&mut self, pos: usize) { 32 | self.pos = pos.min(self.bytes.len()); 33 | } 34 | 35 | /// # Safety 36 | /// `start` must be less than or equal to `self.pos`, and the range 37 | /// `start..self.pos` must lie on UTF-8 character boundaries within `self.bytes`. 38 | unsafe fn current_slice(&self, start: usize) -> &'a str { 39 | unsafe { std::str::from_utf8_unchecked(&self.bytes[start..self.pos]) } 40 | } 41 | 42 | fn remaining(&self) -> &'a [u8] { 43 | &self.bytes[self.pos..] 44 | } 45 | 46 | pub fn parse(mut self) -> Vec> { 47 | // Pre-allocate capacity based on open brace count 48 | let open_count = memchr::memchr_iter(b'{', self.bytes).count(); 49 | 50 | let capacity = if open_count == 0 { 51 | 1 // Pure text, single token 52 | } else if self.bytes.first() != Some(&b'{') { 53 | 1 + (open_count * 2) // Has leading text 54 | } else { 55 | open_count * 2 // Starts with placeholder 56 | }; 57 | 58 | let mut tokens = Vec::with_capacity(capacity); 59 | while let Some(token) = self.next_token() { 60 | tokens.push(token); 61 | } 62 | tokens 63 | } 64 | 65 | #[inline] 66 | fn next_token(&mut self) -> Option> { 67 | if self.pos >= self.bytes.len() { 68 | return None; 69 | } 70 | 71 | let start = self.pos; 72 | 73 | if let Some(offset) = memchr::memchr3(b'{', b'\\', b'}', self.remaining()) { 74 | let abs_pos = self.pos + offset; 75 | match self.bytes[abs_pos] { 76 | b'\\' => { 77 | if abs_pos + 1 < self.bytes.len() { 78 | match self.bytes[abs_pos + 1] { 79 | b'{' | b'}' | b'\\' | b'n' | b't' | b':' => { 80 | if abs_pos > start { 81 | self.skip_to(abs_pos); 82 | return Some(Token::Text(Cow::Borrowed(unsafe { 83 | self.current_slice(start) 84 | }))); 85 | } 86 | 87 | let escaped = match self.bytes[abs_pos + 1] { 88 | b'n' => "\n", 89 | b't' => "\t", 90 | b'\\' => "\\", 91 | b'{' => "{", 92 | b'}' => "}", 93 | b':' => ":", 94 | _ => unreachable!(), 95 | }; 96 | self.skip_to(abs_pos + 2); 97 | return Some(Token::Text(Cow::Borrowed(escaped))); 98 | } 99 | _ => { 100 | self.skip_to(abs_pos + 2); 101 | return self.next_token(); 102 | } 103 | } 104 | } else { 105 | self.skip_to(self.bytes.len()); 106 | if start < self.bytes.len() { 107 | return Some(Token::Text(Cow::Borrowed(unsafe { 108 | self.current_slice(start) 109 | }))); 110 | } 111 | return None; 112 | } 113 | } 114 | b'{' => { 115 | if abs_pos > start { 116 | self.skip_to(abs_pos); 117 | return Some(Token::Text(Cow::Borrowed(unsafe { 118 | self.current_slice(start) 119 | }))); 120 | } 121 | 122 | if let Some(end_offset) = memchr::memchr(b'}', &self.bytes[abs_pos + 1..]) { 123 | let end_pos = abs_pos + 1 + end_offset; 124 | let content = &self.bytes[abs_pos + 1..end_pos]; 125 | 126 | if let Some(params) = 127 | parse_placeholder(unsafe { std::str::from_utf8_unchecked(content) }) 128 | { 129 | self.skip_to(end_pos + 1); 130 | return Some(Token::Placeholder(params)); 131 | } 132 | } 133 | 134 | self.skip_to(abs_pos + 1); 135 | return Some(Token::Text(Cow::Borrowed("{"))); 136 | } 137 | b'}' => { 138 | if abs_pos > start { 139 | self.skip_to(abs_pos); 140 | return Some(Token::Text(Cow::Borrowed(unsafe { 141 | self.current_slice(start) 142 | }))); 143 | } 144 | self.skip_to(abs_pos + 1); 145 | return Some(Token::Text(Cow::Borrowed("}"))); 146 | } 147 | _ => unreachable!(), 148 | } 149 | } else { 150 | self.skip_to(self.bytes.len()); 151 | if start < self.bytes.len() { 152 | return Some(Token::Text(Cow::Borrowed(unsafe { 153 | self.current_slice(start) 154 | }))); 155 | } 156 | } 157 | 158 | None 159 | } 160 | } 161 | 162 | fn parse_placeholder(content: &str) -> Option { 163 | let fields = split_fields(content); 164 | 165 | if fields[0].is_empty() { 166 | return None; 167 | } 168 | 169 | Some(Params { 170 | module: unescape_if_needed(fields[0]).into_owned(), 171 | style: unescape_if_needed(fields[1]).into_owned(), 172 | format: unescape_if_needed(fields[2]).into_owned(), 173 | prefix: unescape_if_needed(fields[3]).into_owned(), 174 | suffix: unescape_if_needed(fields[4]).into_owned(), 175 | }) 176 | } 177 | 178 | fn split_fields(s: &str) -> [&str; 5] { 179 | let mut fields = [""; 5]; 180 | let mut field_idx = 0; 181 | let mut start = 0; 182 | let bytes = s.as_bytes(); 183 | let mut i = 0; 184 | 185 | while i < bytes.len() && field_idx < 4 { 186 | if bytes[i] == b'\\' { 187 | i += 2; 188 | } else if bytes[i] == b':' { 189 | fields[field_idx] = unsafe { std::str::from_utf8_unchecked(&bytes[start..i]) }; 190 | field_idx += 1; 191 | start = i + 1; 192 | i += 1; 193 | } else { 194 | i += 1; 195 | } 196 | } 197 | 198 | fields[field_idx] = unsafe { std::str::from_utf8_unchecked(&bytes[start..]) }; 199 | fields 200 | } 201 | 202 | fn unescape_if_needed(s: &str) -> Cow<'_, str> { 203 | if !s.contains('\\') { 204 | return Cow::Borrowed(s); 205 | } 206 | 207 | let mut result = String::with_capacity(s.len()); 208 | let mut chars = s.chars(); 209 | 210 | while let Some(ch) = chars.next() { 211 | if ch == '\\' { 212 | if let Some(next) = chars.next() { 213 | match next { 214 | 'n' => result.push('\n'), 215 | 't' => result.push('\t'), 216 | '\\' => result.push('\\'), 217 | ':' => result.push(':'), 218 | '{' => result.push('{'), 219 | '}' => result.push('}'), 220 | _ => { 221 | result.push('\\'); 222 | result.push(next); 223 | } 224 | } 225 | } else { 226 | result.push('\\'); 227 | } 228 | } else { 229 | result.push(ch); 230 | } 231 | } 232 | 233 | Cow::Owned(result) 234 | } 235 | 236 | pub fn parse(template: &str) -> Vec> { 237 | Parser::new(template).parse() 238 | } 239 | 240 | #[cfg(test)] 241 | mod tests { 242 | use super::*; 243 | 244 | #[test] 245 | fn test_simple_text() { 246 | let tokens = parse("Hello, World!"); 247 | assert_eq!(tokens, vec![Token::Text(Cow::Borrowed("Hello, World!"))]); 248 | } 249 | 250 | #[test] 251 | fn test_simple_placeholder() { 252 | let tokens = parse("{path}"); 253 | assert_eq!(tokens.len(), 1); 254 | if let Token::Placeholder(params) = &tokens[0] { 255 | assert_eq!(params.module, "path"); 256 | assert_eq!(params.style, ""); 257 | assert_eq!(params.format, ""); 258 | assert_eq!(params.prefix, ""); 259 | assert_eq!(params.suffix, ""); 260 | } else { 261 | panic!("Expected placeholder"); 262 | } 263 | } 264 | 265 | #[test] 266 | fn test_placeholder_with_all_fields() { 267 | let tokens = parse("{path:cyan:short:[:]}"); 268 | assert_eq!(tokens.len(), 1); 269 | if let Token::Placeholder(params) = &tokens[0] { 270 | assert_eq!(params.module, "path"); 271 | assert_eq!(params.style, "cyan"); 272 | assert_eq!(params.format, "short"); 273 | assert_eq!(params.prefix, "["); 274 | assert_eq!(params.suffix, "]"); 275 | } else { 276 | panic!("Expected placeholder"); 277 | } 278 | } 279 | 280 | #[test] 281 | fn test_escaped_colon_in_field() { 282 | let tokens = parse("{module:style:format:pre\\:fix:suffix}"); 283 | if let Token::Placeholder(params) = &tokens[0] { 284 | assert_eq!(params.prefix, "pre:fix"); 285 | } else { 286 | panic!("Expected placeholder"); 287 | } 288 | } 289 | 290 | #[test] 291 | fn test_escaped_braces_in_text() { 292 | let tokens = parse("\\{not a placeholder\\}"); 293 | assert_eq!( 294 | tokens, 295 | vec![ 296 | Token::Text(Cow::Borrowed("{")), 297 | Token::Text(Cow::Borrowed("not a placeholder")), 298 | Token::Text(Cow::Borrowed("}")), 299 | ] 300 | ); 301 | } 302 | 303 | #[test] 304 | fn test_escape_sequences() { 305 | let tokens = parse("Line1\\nLine2\\tTabbed"); 306 | assert_eq!( 307 | tokens, 308 | vec![ 309 | Token::Text(Cow::Borrowed("Line1")), 310 | Token::Text(Cow::Borrowed("\n")), 311 | Token::Text(Cow::Borrowed("Line2")), 312 | Token::Text(Cow::Borrowed("\t")), 313 | Token::Text(Cow::Borrowed("Tabbed")), 314 | ] 315 | ); 316 | } 317 | 318 | #[test] 319 | fn test_unclosed_placeholder() { 320 | let tokens = parse("{unclosed"); 321 | // The parser should treat unclosed placeholders as text 322 | let combined: String = tokens 323 | .iter() 324 | .map(|t| match t { 325 | Token::Text(s) => s.as_ref(), 326 | _ => panic!("Expected text token"), 327 | }) 328 | .collect(); 329 | assert_eq!(combined, "{unclosed"); 330 | } 331 | 332 | #[test] 333 | fn test_empty_fields() { 334 | let tokens = parse("{module::::}"); 335 | if let Token::Placeholder(params) = &tokens[0] { 336 | assert_eq!(params.module, "module"); 337 | assert_eq!(params.style, ""); 338 | assert_eq!(params.format, ""); 339 | assert_eq!(params.prefix, ""); 340 | assert_eq!(params.suffix, ""); 341 | } else { 342 | panic!("Expected placeholder"); 343 | } 344 | } 345 | 346 | #[test] 347 | fn test_mixed_content() { 348 | let tokens = parse("Hello {user:yellow}, welcome to {path:cyan:short}!"); 349 | assert_eq!(tokens.len(), 5); 350 | assert!(matches!(tokens[0], Token::Text(_))); 351 | assert!(matches!(tokens[1], Token::Placeholder(_))); 352 | assert!(matches!(tokens[2], Token::Text(_))); 353 | assert!(matches!(tokens[3], Token::Placeholder(_))); 354 | assert!(matches!(tokens[4], Token::Text(_))); 355 | } 356 | } 357 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # prmt 🚀 2 | 3 | > Ultra-fast, customizable shell prompt that won't slow you down 4 | 5 | [![Crates.io](https://img.shields.io/crates/v/prmt.svg)](https://crates.io/crates/prmt) 6 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 7 | [![Rust](https://img.shields.io/badge/rust-2024-orange.svg)](https://www.rust-lang.org) 8 | 9 | ![Terminal](./terminal.png) 10 | 11 | > Rendered with `"{path:#89dceb}{rust:#f38ba8:f: 🦀}{git:#f9e2af:f: }\n{ok:#a6e3a1}{fail:#f38ba8} "` 12 | 13 | ## Features 14 | 15 | - **⚡ Blazing Fast**: Sub-millisecond rendering for typical prompts (~2ms end-to-end) 16 | - **🎨 Highly Customizable**: Full control over colors, formats, and what information to show 17 | - **🚀 Context Aware**: Automatically detects git repos, project files, shows only what's relevant 18 | - **📦 Zero Dependencies**: Single binary, no runtime dependencies required 19 | - **🦀 Memory Efficient**: Zero-copy parsing with SIMD optimizations 20 | - **✨ Smart Rendering**: Only shows information when relevant to your current directory 21 | 22 | ## Why prmt? 23 | 24 | **Faster than alternatives** – Typical prompts render in ~2ms. Starship averages 10-50ms, oh-my-posh 20-100ms. 25 | 26 | **Zero configuration needed** – Works out of the box with sensible defaults. Customize only what you want. 27 | 28 | **Predictable performance** – No async operations, no network calls, no surprises. Your prompt is always instant. 29 | 30 | **Single binary** – Just install and go. No configuration files required unless you want them. 31 | 32 | **Context-aware** – Automatically detects git repositories, Rust/Node/Python projects, and shows only relevant info. 33 | 34 | ## Quick Start 35 | 36 | **1. Install** 37 | ```bash 38 | cargo install prmt 39 | ``` 40 | 41 | **2. Add to your shell** (pick one) 42 | 43 | **Bash** – Add to `~/.bashrc`: 44 | ```bash 45 | # Simple with named colors 46 | PS1='$(prmt --code $? "{path:cyan} {git:purple} {ok:green}{fail:red} ")' 47 | 48 | # Or with hex colors for precise theming 49 | PS1='$(prmt --code $? "{path:#89dceb} {git:#f9e2af} {ok:#a6e3a1}{fail:#f38ba8} ")' 50 | ``` 51 | 52 | **Zsh** – Add to `~/.zshrc` (auto-detected by default; `--shell zsh` forces wrapping): 53 | ```bash 54 | setopt PROMPT_SUBST 55 | 56 | # Simple with named colors 57 | PROMPT='$(prmt --shell zsh --code $? "{path:cyan} {git:purple} {ok:green}{fail:red} ")' 58 | 59 | # Or with hex colors for precise theming 60 | PROMPT='$(prmt --shell zsh --code $? "{path:#89dceb} {git:#f9e2af} {ok:#a6e3a1}{fail:#f38ba8} ")' 61 | ``` 62 | 63 | **Fish** – Add to `~/.config/fish/config.fish`: 64 | ```fish 65 | function fish_prompt 66 | prmt --code $status '{path:cyan} {git:purple} {ok:green}{fail:red} ' 67 | end 68 | ``` 69 | 70 | **3. Reload your shell** 71 | ```bash 72 | exec $SHELL # or open a new terminal 73 | ``` 74 | 75 | Done! 🎉 76 | 77 |
78 | Advanced setup (PROMPT_COMMAND, precmd, environment variables) 79 | 80 | ### Bash with PROMPT_COMMAND 81 | ```bash 82 | function _prmt_prompt() { 83 | local last=$? 84 | PS1="$(prmt --code $last '{path:cyan} {git:purple} {ok:green}{fail:red}')" 85 | } 86 | PROMPT_COMMAND=_prmt_prompt 87 | ``` 88 | 89 | *If you already use `PROMPT_COMMAND`, append `_prmt_prompt` instead of overwriting it.* 90 | 91 | ### Zsh with precmd 92 | ```zsh 93 | function _prmt_precmd() { 94 | local code=$? 95 | PROMPT="$(prmt --code $code '{path:cyan} {git:purple} {ok:green}{fail:red}')" 96 | } 97 | typeset -ga precmd_functions 98 | precmd_functions+=(_prmt_precmd) 99 | ``` 100 | 101 | ### PowerShell 102 | ```powershell 103 | # Add to $PROFILE 104 | function prompt { 105 | prmt --code $LASTEXITCODE '{path:cyan:s} {git:purple:s:on :} {ok:green}{fail:red} ' 106 | } 107 | ``` 108 | 109 | ### Environment Variable 110 | All shells support using `PRMT_FORMAT` environment variable: 111 | 112 | ```bash 113 | # Bash/Zsh 114 | export PRMT_FORMAT="{path:cyan:r} {rust:red:m:v 🦀} {git:purple}" 115 | PS1='$(prmt --code $?)\$ ' 116 | 117 | # Fish 118 | set -x PRMT_FORMAT "{path:cyan:r} {python:yellow:m: 🐍} {git:purple}" 119 | 120 | # PowerShell 121 | $env:PRMT_FORMAT = "{path:cyan:r} {git:purple}" 122 | ``` 123 | 124 |
125 | 126 | ## Popular Prompts 127 | 128 | **Minimal** 129 | ```bash 130 | prmt '{path:cyan:s} {ok:green}{fail:red} ' 131 | # Output: projects ❯ 132 | ``` 133 | 134 | **Developer** 135 | ```bash 136 | prmt '{path:cyan} {git:purple} {rust:red:s: 🦀} {node:green:s: ⬢} {ok:green}{fail:red} ' 137 | # Output: ~/projects/prmt on main 🦀1.90 ⬢20.5 ❯ 138 | ``` 139 | 140 | **Compact with time** 141 | ```bash 142 | prmt '{time:dim:12h} {path:cyan:s} {git:purple:s} {ok:green}{fail:red} ' 143 | # Output: 02:30PM projects main ❯ 144 | ``` 145 | 146 | **Full featured with newline** 147 | ```bash 148 | prmt '{path:cyan} {git:purple} {python:yellow:m: 🐍} {time:dim}\n{ok:green}{fail:red} ' 149 | # Output: ~/ml-project on develop 🐍3.11 14:30 150 | # ❯ 151 | ``` 152 | 153 | **Status-focused** 154 | ```bash 155 | prmt '{path:cyan:s} {git:purple:s:on :} {ok:green:✓}{fail:red:✗} ' 156 | # Output (success): projects on main ✓ 157 | # Output (failure): projects on main ✗ 158 | ``` 159 | 160 | **With exit codes** 161 | ```bash 162 | prmt '{path:cyan} {git:purple} {ok:green:❯}{fail:red::code} ' 163 | # Output (success): ~/projects main ❯ 164 | # Output (failure): ~/projects main 127 165 | ``` 166 | 167 | ## Installation 168 | 169 | ```bash 170 | # Install from crates.io 171 | cargo install prmt 172 | 173 | # Build from source (Rust 2024 edition required) 174 | cargo build --release 175 | cp target/release/prmt ~/.local/bin/ 176 | 177 | # Or install directly from source 178 | cargo install --path . 179 | 180 | # Verify installation 181 | prmt --version 182 | ``` 183 | 184 | ## Usage Examples 185 | 186 | ```bash 187 | # Simple format with defaults 188 | prmt '{path} {rust} {git}' 189 | # Output: ~/projects 1.89.0 master 190 | 191 | # Format with types and styles 192 | prmt '{path::a}' # /home/user/projects (absolute path) 193 | prmt '{path::r}' # ~/projects (relative with ~) 194 | prmt '{path::s}' # projects (short - last dir only) 195 | prmt '{rust:red:s}' # 1.89 in red (short version) 196 | prmt '{rust:red:m:v:}' # v1 in red (major version with prefix) 197 | prmt '{path:cyan:s:[:]}' # [projects] in cyan 198 | prmt '{git:purple::on :}' # on master in purple 199 | 200 | # Simplified formats with omitted parts 201 | prmt '{rust::::!}' # 1.89.0! (default style/type, suffix only) 202 | prmt '{rust:::v:}' # v1.89.0 (default style/type, prefix only) 203 | prmt '{path::::]}' # ~/projects] (suffix only) 204 | prmt '{git:::on :}' # on master (prefix only) 205 | 206 | # Add your own icons with prefix 207 | prmt '{rust::: 🦀}' # 🦀1.89.0 (default color) 208 | prmt '{node:green:: ⬢}' # ⬢20.5.0 in green 209 | prmt '{python:yellow:: 🐍}' # 🐍3.11.0 in yellow 210 | 211 | # Or add spacing with suffix for better readability 212 | prmt '{rust::: 🦀 }' # 🦀 1.89.0 (space after icon) 213 | prmt '{node:green:: ⬢ }' # ⬢ 20.5.0 (space after icon) 214 | 215 | # Using short format aliases 216 | prmt '{path:cyan:s} {rust:red:m:v:}' # projects v1 (both in color) 217 | prmt '{git::s:on :}' # on master (short format with prefix) 218 | 219 | # No style with type 220 | prmt '{path::s}' # projects (no color, short) 221 | prmt '{path::a}' # /home/user/projects (no color, absolute) 222 | prmt '{rust::m:v}' # v1 (no color, major with prefix) 223 | 224 | # With exit code indicators (requires --code flag) 225 | prmt --code $? '{path:cyan} {ok:green}{fail:red}' 226 | # Output (success): ~/projects ❯ (green) 227 | # Output (failure): ~/projects ❯ (red) 228 | 229 | # Fast mode (no version detection) 230 | prmt --no-version '{path:cyan} {rust:red} {node:green}' 231 | # Output: ~/projects (only shows active modules, no versions) 232 | 233 | # Custom symbols for ok/fail using type as symbol 234 | prmt --code $? '{path} {ok::✓} {fail::✗}' 235 | # Output (success): ~/projects ✓ 236 | # Output (failure): ~/projects ✗ 237 | 238 | # Show exit code on failure 239 | prmt --code $? '{path} {ok::❯} {fail::code}' 240 | # Output (success): ~/projects ❯ 241 | # Output (failure with code 127): ~/projects 127 242 | 243 | # Time formats 244 | prmt '{time}' # 14:30 (default 24h) 245 | prmt '{time::24hs}' # 14:30:45 246 | prmt '{time::12h}' # 02:30PM 247 | prmt '{time::12hs}' # 02:30:45PM 248 | prmt '{path:cyan} {time:dim:12h}' # ~/projects 02:30PM (with styling) 249 | ``` 250 | 251 | ## Format Specification 252 | 253 | ### Format Syntax 254 | ``` 255 | {module} - Default everything 256 | {module:style} - Custom style 257 | {module:style:type} - Custom style and type 258 | {module:style:type:prefix} - Add prefix to value 259 | {module:style:type:prefix:postfix} - Add prefix and postfix 260 | 261 | # Omitting parts (empty means default) 262 | {module::::suffix} - Default style/type, suffix only 263 | {module:::prefix:} - Default style/type, prefix only 264 | {module:::prefix:suffix} - Default style/type, both prefix/suffix 265 | {module::type} - No style, specific type 266 | {module::type::suffix} - No style, specific type, suffix only 267 | ``` 268 | 269 | ### Available Modules 270 | 271 | | Module | Detection | Description | 272 | |--------|-----------|-------------| 273 | | `path` | Always active | Current directory with ~ for home | 274 | | `ok` | Exit code = 0 | Shows when last command succeeded (default: ❯) | 275 | | `fail` | Exit code ≠ 0 | Shows when last command failed (default: ❯) | 276 | | `git` | `.git` directory | Branch name with status indicators | 277 | | `node` | `package.json` | Node.js version | 278 | | `python` | `requirements.txt`, `pyproject.toml`, etc | Python version | 279 | | `rust` | `Cargo.toml` | Rust version | 280 | | `deno` | `deno.json`, `deno.jsonc` | Deno version | 281 | | `bun` | `bun.lockb` | Bun version | 282 | | `go` | `go.mod` | Go version | 283 | | `env` | Requested variable is set/non-empty | Value of a specific environment variable (format = name) | 284 | | `time` | Always active | Current time in various formats | 285 | 286 | ### Type Values 287 | 288 | **Version modules** (rust, node, python, etc.): 289 | - `full` or `f` - Full version (1.89.0) 290 | - `short` or `s` - Major.minor (1.89) 291 | - `major` or `m` - Major only (1) 292 | 293 | **Path module**: 294 | - `relative` or `r` - Path with ~ for home directory (default) 295 | - `absolute`, `a`, or `f` - Full absolute path without ~ substitution 296 | - `short` or `s` - Last directory only 297 | 298 | **Git module**: 299 | - `full` or `f` - Branch with status (default) 300 | - `short` or `s` - Branch name only 301 | 302 | **Ok/Fail modules**: 303 | - `full` - Default symbol (❯) 304 | - `code` - Shows the actual exit code number 305 | - *Any other string* - Uses that string as the symbol (e.g., `{ok::✓}` shows ✓) 306 | 307 | **Time module**: 308 | - `24h` - 24-hour format HH:MM (default) 309 | - `24hs` or `24HS` - 24-hour format with seconds HH:MM:SS 310 | - `12h` or `12H` - 12-hour format hh:MMAM/PM 311 | - `12hs` or `12HS` - 12-hour format with seconds hh:MM:SSAM/PM 312 | 313 | **Env module**: 314 | - The `type` field is required and must be the environment variable name (e.g., `{env::USER}` or `{env:blue:PATH}`). 315 | - The module emits the variable value only when it exists and is non-empty; otherwise it returns nothing so the placeholder is effectively inactive. 316 | 317 | ### Type Validation 318 | 319 | The format parser validates types at parse time to catch errors early: 320 | 321 | ```bash 322 | # Valid types for each module 323 | prmt '{path::short}' # ✓ Valid 324 | prmt '{rust::major}' # ✓ Valid 325 | prmt '{ok::✓}' # ✓ Valid (custom symbol) 326 | prmt '{fail::code}' # ✓ Valid (shows exit code) 327 | 328 | # Invalid types produce clear errors 329 | prmt '{path::major}' 330 | # Error: Invalid type 'major' for module 'path'. Valid types: relative, r, absolute, a, short, s 331 | 332 | prmt '{git::major}' 333 | # Error: Invalid type 'major' for module 'git'. Valid types: full, short 334 | ``` 335 | 336 | ### Default Module Styles 337 | 338 | | Module | Default Color | Can Override | 339 | |--------|--------------|--------------| 340 | | `path` | cyan | Yes | 341 | | `ok` | green | Yes | 342 | | `fail` | red | Yes | 343 | | `git` | purple | Yes | 344 | | `node` | green | Yes | 345 | | `rust` | red | Yes | 346 | | `python` | yellow | Yes | 347 | | `go` | cyan | Yes | 348 | | `deno` | - | Yes | 349 | | `bun` | - | Yes | 350 | | `time` | - | Yes | 351 | 352 | ### Styles 353 | 354 | **Colors**: `black`, `red`, `green`, `yellow`, `blue`, `purple`, `cyan`, `white`, `#hexcode` 355 | 356 | **Modifiers**: `bold`, `dim`, `italic`, `underline`, `reverse`, `strikethrough` 357 | 358 | Combine with dots: `cyan.bold`, `red.dim.italic` 359 | 360 | ### Escaping 361 | 362 | - `\{` → `{` (literal brace) 363 | - `\}` → `}` (literal brace) 364 | - `\n` → newline 365 | - `\t` → tab 366 | - `\:` → `:` (literal colon in fields) 367 | - `\\` → `\` (literal backslash) 368 | 369 | ## Performance 370 | 371 | ### Actual Response Times 372 | | Scenario | Time | Notes | 373 | |----------|------|-------| 374 | | Path only | ~0.01ms | Minimal prompt | 375 | | Path + Git | ~1-2ms | Branch and status | 376 | | With Rust version | ~25-30ms | Includes `rustc --version` | 377 | | With `--no-version` | <5ms | Skips all version detection | 378 | 379 | ### Benchmark Snapshot 380 | 381 | | Scenario | Time (µs) | Notes | 382 | |----------|-----------|-------| 383 | | Minimal render | 0.69 | `{path}` only | 384 | | Typical prompt | 1.71 | `{path} {git} {ok}{fail}` | 385 | | Full prompt with versions | 4.90 | `{path} {git} {rust} {node}` | 386 | | End-to-end (typical) | 2.53 | `prmt` binary execution | 387 | 388 | > Measurements captured on an Intel Core i9-13900K host with project files on a SATA SSD (Rust 1.90.0 release build). Each value is the median of 100 `cargo bench` runs. 389 | 390 | **Why is it fast?** 391 | - Zero-copy parsing with SIMD optimizations 392 | - Efficient memory allocation strategies 393 | - Context-aware detection (only checks what's needed) 394 | - No async operations or network calls 395 | - Written in Rust for maximum performance 396 | 397 | ## Command-Line Options 398 | 399 | ``` 400 | prmt [OPTIONS] [FORMAT] 401 | 402 | OPTIONS: 403 | -n, --no-version Skip version detection for speed 404 | -d, --debug Show debug information and timing 405 | -b, --bench Run benchmark (100 iterations) 406 | --code Exit code of the last command (for ok/fail modules) 407 | --no-color Disable colored output 408 | -h, --help Print help 409 | -V, --version Print version 410 | 411 | ARGS: 412 | Format string (default from PRMT_FORMAT env var) 413 | ``` 414 | 415 | ## Building from Source 416 | 417 | ```bash 418 | # Requirements: Rust 2024 edition 419 | git clone https://github.com/3axap4eHko/prmt.git 420 | cd prmt 421 | cargo build --release 422 | 423 | # Run tests 424 | cargo test 425 | 426 | # Benchmark 427 | ./target/release/prmt --bench '{path} {rust} {git}' 428 | ``` 429 | 430 | ## License 431 | 432 | License [The MIT License](./LICENSE) 433 | Copyright (c) 2025 Ivan Zakharchanka 434 | --------------------------------------------------------------------------------