├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── examples ├── binps1.rs └── custom_theme.rs └── src ├── lib.rs └── themes.rs /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | Cargo.lock 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "libps1" 3 | version = "0.0.1" 4 | authors = ["Josh Mcguigan", "NerdyPepper "] 5 | edition = "2018" 6 | description = "A simple bash prompt for programmers" 7 | homepage = "https://github.com/joshmcguigan/libps1" 8 | repository = "https://github.com/joshmcguigan/libps1" 9 | readme = "./readme.md" 10 | keywords = ["shell", "prompt", "theme", "cli"] 11 | categories = ["command-line-interface", "command-line-utilities", "config"] 12 | license = "MIT" 13 | 14 | [dependencies] 15 | tico = "1.0" 16 | libc = "0.2" 17 | git2 = "0.8" 18 | ansi_term = "0.12" 19 | 20 | [dev-dependencies] 21 | structopt = "0.3" 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Akshay Oppiliappan 2 | Copyright 2020 Josh Mcguigan 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | this software and associated documentation files (the "Software"), to deal in 6 | the Software without restriction, including without limitation the rights to 7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 8 | of the Software, and to permit persons to whom the Software is furnished to do 9 | so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # libps1 2 | 3 | libps1 is an experimental shell prompt for power users. There are many great shell prompt projects in existence, but this one is different. While nearly every other option uses some kind of configuration file (toml, yaml, etc), libps1 is intended to be used as a library, so it can be customized and documented using the incredible tooling developed by the Rust ecosystem. No more wondering what other options are available, or if you are spelling things correctly, your IDE and the compiler will guide you. 4 | 5 | ![screenshot](https://user-images.githubusercontent.com/22216761/85191296-2d58d100-b273-11ea-99b7-45653b579d49.png) 6 | 7 | ## Getting Started 8 | 9 | ### Option 1 - binps1 10 | 11 | binps1 is the reference shell prompt implementation, using libps1. 12 | 13 | ```shell 14 | cargo install --force --path . --example binps1 15 | ``` 16 | 17 | ```shell 18 | # .bashrc 19 | 20 | # default color scheme 21 | PS1='$(binps1)' 22 | 23 | # or pick one of the available custom color schemes 24 | PS1='$(binps1 --theme solarized)' 25 | ``` 26 | 27 | ```shell 28 | # .zshrc 29 | 30 | autoload -Uz add-zsh-hook 31 | _prompt() { 32 | PS1="$(binps1 --theme solarized)" 33 | } 34 | add-zsh-hook precmd _prompt 35 | ``` 36 | 37 | ### Option 2 - Make it your own! 38 | 39 | ```shell 40 | cargo new --bin my_shell # pick your own name here 41 | ``` 42 | 43 | ```toml 44 | # cargo.toml 45 | [dependencies] 46 | libps1 = { git = "https://github.com/JoshMcguigan/libps1" } 47 | ``` 48 | 49 | ```rust 50 | # main.rs 51 | 52 | use libps1::{ 53 | Color::{Green, Purple, Red, Yellow, RGB}, 54 | Prompt, 55 | }; 56 | 57 | /// This is a demonstration of a fully customized shell prompt 58 | /// using libps1. 59 | fn main() { 60 | Prompt { 61 | cwd_color: Purple, 62 | cwd_shorten_directories: false, 63 | cwd_shorten_home: Some("⌂"), 64 | 65 | git_branch_color: RGB(0x17, 0xC8, 0xB0), 66 | 67 | git_status_clean_color: Green, 68 | git_status_unstaged_color: Red, 69 | git_status_staged_color: Yellow, 70 | git_status_clean_icon: "➖", 71 | git_status_unstaged_icon: "❌", 72 | git_status_staged_icon: "➕", 73 | 74 | prompt_char_separator: "\n", 75 | // If you'd prefer not to specify all of the values, uncomment 76 | // the line below to fall back on a theme. And add the import 77 | // to the top of the file. 78 | // 79 | // use libps1::Theme::Solarized; 80 | // ..Prompt::with_theme(Solarized) 81 | } 82 | .show() 83 | } 84 | ``` 85 | 86 | Then `cargo install` your binary and setup your `.bashrc`/`.zshrc` as shown in Option 1. 87 | 88 | ## Related Projects 89 | 90 | libps1 is a fork of [pista](https://github.com/NerdyPepper/pista) by [@NerdyPepper](https://github.com/NerdyPepper). 91 | -------------------------------------------------------------------------------- /examples/binps1.rs: -------------------------------------------------------------------------------- 1 | use libps1::{Prompt, Theme}; 2 | 3 | use structopt::StructOpt; 4 | 5 | #[derive(StructOpt)] 6 | #[structopt(name = "binps1")] 7 | struct Args { 8 | #[structopt(long)] 9 | theme: Option, 10 | } 11 | 12 | /// This is a reference implementation of a shell prompt using libps1. 13 | /// 14 | /// If you like one of the built-in themes, feel free to use this as 15 | /// your shell prompt. If you prefer more customization, check out 16 | /// the other examples. 17 | fn main() { 18 | let args = Args::from_args(); 19 | 20 | let prompt = match args.theme { 21 | Some(theme) => Prompt::with_theme(theme), 22 | None => Prompt::default(), 23 | }; 24 | 25 | prompt.show(); 26 | } 27 | -------------------------------------------------------------------------------- /examples/custom_theme.rs: -------------------------------------------------------------------------------- 1 | use libps1::{ 2 | Color::{Green, Purple, Red, Yellow, RGB}, 3 | Prompt, 4 | }; 5 | 6 | /// This is a demonstration of a fully customized shell prompt 7 | /// using libps1. 8 | fn main() { 9 | Prompt { 10 | cwd_color: Purple, 11 | cwd_shorten_directories: false, 12 | cwd_shorten_home: Some("⌂"), 13 | 14 | git_branch_color: RGB(0x17, 0xC8, 0xB0), 15 | 16 | git_status_clean_color: Green, 17 | git_status_unstaged_color: Red, 18 | git_status_staged_color: Yellow, 19 | git_status_clean_icon: "➖", 20 | git_status_unstaged_icon: "❌", 21 | git_status_staged_icon: "➕", 22 | 23 | prompt_char_separator: "\n", 24 | // If you'd prefer not to specify all of the values, uncomment 25 | // the line below to fall back on a theme. And add the import 26 | // to the top of the file. 27 | // 28 | // use libps1::Theme::Solarized; 29 | // ..Prompt::with_theme(Solarized) 30 | } 31 | .show() 32 | } 33 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use git2::{Repository, Status}; 2 | use std::{env, fmt::Display, path::Path}; 3 | use tico::tico; 4 | 5 | pub use ansi_term::Color; 6 | use ansi_term::Color::{Blue, Cyan, Green, Red, Yellow, RGB}; 7 | 8 | mod themes; 9 | pub use themes::Theme; 10 | 11 | pub struct Prompt { 12 | pub cwd_color: Color, 13 | /// Shorten the current working directory, by only printing 14 | /// the first character of each but the last directory. 15 | /// 16 | /// For exapmle, `/tmp/my_dir/foo` would become `/t/m/foo`. 17 | pub cwd_shorten_directories: bool, 18 | 19 | /// If provided, this string will be used in place of `/home/my_user` 20 | /// when printing the current working directory. For example, if 21 | /// this is set to `Some("~")`, then `/home/my_user/foo` will be 22 | /// printed as `~/foo`. 23 | pub cwd_shorten_home: Option<&'static str>, 24 | 25 | pub git_branch_color: Color, 26 | 27 | pub git_status_clean_color: Color, 28 | pub git_status_unstaged_color: Color, 29 | pub git_status_staged_color: Color, 30 | pub git_status_clean_icon: &'static str, 31 | pub git_status_unstaged_icon: &'static str, 32 | pub git_status_staged_icon: &'static str, 33 | 34 | /// Specify the string used to separate the prompt character 35 | /// from the preceding text. Some useful values for this are 36 | /// " " (single whitespace) or "\n" to draw the prompt character 37 | /// on a new line. 38 | pub prompt_char_separator: &'static str, 39 | } 40 | 41 | impl Default for Prompt { 42 | fn default() -> Self { 43 | Self { 44 | cwd_color: Cyan, 45 | cwd_shorten_directories: false, 46 | cwd_shorten_home: Some("~"), 47 | 48 | git_branch_color: Blue, 49 | 50 | git_status_clean_color: Green, 51 | git_status_unstaged_color: Red, 52 | git_status_staged_color: Yellow, 53 | git_status_clean_icon: "✓", 54 | git_status_unstaged_icon: "×", 55 | git_status_staged_icon: "±", 56 | 57 | prompt_char_separator: "\n", 58 | } 59 | } 60 | } 61 | 62 | impl Prompt { 63 | pub fn with_theme(theme: Theme) -> Self { 64 | match theme { 65 | Theme::Nord => { 66 | let nord_8 = RGB(0x88, 0xC0, 0xD0); 67 | let nord_9 = RGB(0x81, 0xA1, 0xC1); 68 | let nord_11 = RGB(0xBF, 0x61, 0x6A); 69 | let nord_13 = RGB(0xEB, 0xCB, 0x8B); 70 | let nord_14 = RGB(0xA3, 0xBE, 0x8C); 71 | 72 | Self { 73 | cwd_color: nord_8, 74 | git_branch_color: nord_9, 75 | git_status_clean_color: nord_14, 76 | git_status_unstaged_color: nord_11, 77 | git_status_staged_color: nord_13, 78 | ..Self::default() 79 | } 80 | } 81 | Theme::Solarized => Self { 82 | cwd_color: RGB(0x2A, 0xA1, 0x98), 83 | git_branch_color: RGB(0x26, 0x8B, 0xD2), 84 | git_status_clean_color: RGB(0x58, 0x6E, 0x75), 85 | git_status_unstaged_color: RGB(0xCB, 0x4B, 0x16), 86 | git_status_staged_color: RGB(0x65, 0x7B, 0x83), 87 | ..Self::default() 88 | }, 89 | } 90 | } 91 | 92 | pub fn show(self) { 93 | let cwd = { 94 | let cwd = self.cwd().unwrap_or_else(|| "".into()); 95 | 96 | apply_color(cwd, self.cwd_color) 97 | }; 98 | 99 | let vcs_status = vcs_status(); 100 | 101 | let prompt_char = get_char(); 102 | 103 | match vcs_status { 104 | Some((branch, status)) => { 105 | let branch = apply_color(branch, self.git_branch_color); 106 | let status = match status { 107 | GitStatus::Clean => { 108 | apply_color(self.git_status_clean_icon, self.git_status_clean_color) 109 | } 110 | GitStatus::Unstaged => apply_color( 111 | self.git_status_unstaged_icon, 112 | self.git_status_unstaged_color, 113 | ), 114 | GitStatus::Staged => { 115 | apply_color(self.git_status_staged_icon, self.git_status_staged_color) 116 | } 117 | }; 118 | println!( 119 | "{cwd} {branch} {status}{separator}{pchar} ", 120 | cwd = cwd, 121 | branch = branch, 122 | status = status, 123 | separator = self.prompt_char_separator, 124 | pchar = prompt_char, 125 | ) 126 | } 127 | None => println!( 128 | "{cwd}{separator}{pchar} ", 129 | cwd = cwd, 130 | separator = self.prompt_char_separator, 131 | pchar = prompt_char, 132 | ), 133 | }; 134 | } 135 | 136 | fn cwd(&self) -> Option { 137 | let path_env = env::current_dir().ok()?; 138 | let mut path = format!("{}", path_env.display()); 139 | 140 | if let Some(user_desired_home_str) = self.cwd_shorten_home { 141 | let home_dir = env::var("HOME").ok()?; 142 | let home_dir_ext = format!("{}/", home_dir); 143 | 144 | if (path == home_dir) || path.starts_with(&home_dir_ext) { 145 | path = path.replacen(&home_dir, user_desired_home_str, 1); 146 | } 147 | } 148 | 149 | if self.cwd_shorten_directories { 150 | path = tico(&path); 151 | } 152 | 153 | Some(path) 154 | } 155 | } 156 | 157 | fn apply_color(text: impl Display, color: Color) -> String { 158 | let start_color_code = 1 as char; 159 | let end_color_code = 2 as char; 160 | format!( 161 | "{}{}{}{}{}{}{}", 162 | start_color_code, 163 | color.prefix(), 164 | end_color_code, 165 | text, 166 | start_color_code, 167 | color.suffix(), 168 | end_color_code, 169 | ) 170 | } 171 | 172 | fn get_char() -> &'static str { 173 | const ROOT_UID: u32 = 0; 174 | let uid = unsafe { libc::geteuid() }; 175 | 176 | if uid == ROOT_UID { 177 | "#" 178 | } else { 179 | "$" 180 | } 181 | } 182 | 183 | enum GitStatus { 184 | Clean, 185 | /// Has some unstaged changed. 186 | Unstaged, 187 | /// All changes staged. 188 | Staged, 189 | } 190 | 191 | fn vcs_status() -> Option<(String, GitStatus)> { 192 | let current_dir = env::var("PWD").ok()?; 193 | 194 | let repo = { 195 | let mut repo: Option = None; 196 | let current_path = Path::new(¤t_dir[..]); 197 | for path in current_path.ancestors() { 198 | if let Ok(r) = Repository::open(path) { 199 | repo = Some(r); 200 | break; 201 | } 202 | } 203 | 204 | repo? 205 | }; 206 | 207 | let mut commit_dist: String = "".into(); 208 | if let Some((ahead, behind)) = get_ahead_behind(&repo) { 209 | if ahead > 0 { 210 | commit_dist.push_str(" ↑"); 211 | } 212 | if behind > 0 { 213 | commit_dist.push_str(" ↓"); 214 | } 215 | } 216 | 217 | let reference = repo.head().ok()?; 218 | 219 | let branch = if reference.is_branch() { 220 | format!("{}{}", reference.shorthand()?, commit_dist) 221 | } else { 222 | let commit = reference.peel_to_commit().ok()?; 223 | let id = commit.id(); 224 | 225 | format!("{:.6}{}", id, commit_dist) 226 | }; 227 | 228 | let mut repo_status = GitStatus::Clean; 229 | 230 | for file in repo.statuses(None).ok()?.iter() { 231 | match file.status() { 232 | // STATE: unstaged (working tree modified) 233 | Status::WT_NEW 234 | | Status::WT_MODIFIED 235 | | Status::WT_DELETED 236 | | Status::WT_TYPECHANGE 237 | | Status::WT_RENAMED => { 238 | repo_status = GitStatus::Unstaged; 239 | break; 240 | } 241 | // STATE: staged (changes added to index) 242 | Status::INDEX_NEW 243 | | Status::INDEX_MODIFIED 244 | | Status::INDEX_DELETED 245 | | Status::INDEX_TYPECHANGE 246 | | Status::INDEX_RENAMED => { 247 | repo_status = GitStatus::Staged; 248 | } 249 | // STATE: committed (changes have been saved in the repo) 250 | _ => {} 251 | } 252 | } 253 | 254 | Some((branch, repo_status)) 255 | } 256 | 257 | fn get_ahead_behind(r: &Repository) -> Option<(usize, usize)> { 258 | let head = r.head().ok()?; 259 | if !head.is_branch() { 260 | return None; 261 | } 262 | 263 | let head_name = head.shorthand()?; 264 | let head_branch = r.find_branch(head_name, git2::BranchType::Local).ok()?; 265 | let upstream = head_branch.upstream().ok()?; 266 | let head_oid = head.target()?; 267 | let upstream_oid = upstream.get().target()?; 268 | 269 | r.graph_ahead_behind(head_oid, upstream_oid).ok() 270 | } 271 | -------------------------------------------------------------------------------- /src/themes.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | pub enum Theme { 4 | Nord, 5 | Solarized, 6 | } 7 | 8 | impl FromStr for Theme { 9 | type Err = &'static str; 10 | 11 | fn from_str(s: &str) -> Result { 12 | match s { 13 | "nord" => Ok(Theme::Nord), 14 | "solarized" => Ok(Theme::Solarized), 15 | _ => Err("Expected one of: 'nord', 'solarized'"), 16 | } 17 | } 18 | } 19 | --------------------------------------------------------------------------------