├── .gitignore ├── Cargo.toml ├── src ├── model.rs ├── main.rs ├── matcher.rs ├── lexer.rs └── parser.rs ├── fast-alias-tips.plugin.zsh ├── LICENSE ├── README.md ├── flake.lock └── flake.nix /.gitignore: -------------------------------------------------------------------------------- 1 | # Rust 2 | /target 3 | Cargo.lock 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "alias-matcher" 3 | version = "0.2.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | -------------------------------------------------------------------------------- /src/model.rs: -------------------------------------------------------------------------------- 1 | /// Represents an alias definition with its name and expansion. 2 | #[derive(Debug, Clone, PartialEq, Eq)] 3 | pub struct AliasDefinition { 4 | /// The alias name (e.g., "gst") 5 | pub name: String, 6 | /// The expanded command (e.g., "git status") 7 | pub expansion: String, 8 | } 9 | 10 | impl AliasDefinition { 11 | pub fn new(name: String, expansion: String) -> Self { 12 | Self { name, expansion } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /fast-alias-tips.plugin.zsh: -------------------------------------------------------------------------------- 1 | # fast-alias-tips.plugin.zsh 2 | # author: Seong Yong-ju 3 | 4 | : ${FAST_ALIAS_TIPS_PREFIX:="💡 $(tput bold)"} 5 | : ${FAST_ALIAS_TIPS_SUFFIX:="$(tput sgr0)"} 6 | 7 | # Get the directory where this plugin is installed 8 | 0="${${ZERO:-${0:#$ZSH_ARGZERO}}:-${(%):-%N}}" 9 | __fast_alias_tips_plugin_dir="${0:A:h}" 10 | __fast_alias_tips_bin="${__fast_alias_tips_plugin_dir}/target/release/alias-matcher" 11 | 12 | __fast_alias_tips_preexec() { 13 | local cmd="$1" 14 | local cmd_expanded="$2" 15 | 16 | local first="$(cut -d' ' -f1 <<<"$cmd")" 17 | 18 | local suggested="$(alias | "$__fast_alias_tips_bin" "$cmd_expanded")" 19 | if [[ "$suggested" == '' ]]; then 20 | return 21 | fi 22 | 23 | local suggested_first="$(cut -d' ' -f1 <<<"$suggested")" 24 | if [[ "$suggested_first" == "$first" ]]; then 25 | return 26 | fi 27 | 28 | echo "${FAST_ALIAS_TIPS_PREFIX}${suggested}${FAST_ALIAS_TIPS_SUFFIX}" 29 | } 30 | 31 | autoload -Uz add-zsh-hook 32 | add-zsh-hook preexec __fast_alias_tips_preexec 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Seong Yong-ju 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # zsh-fast-alias-tips 2 | 3 | Helps you remembering the aliases you defined once. 4 | 5 | Written in zsh and Rust. Ported from [djui/alias-tips](https://github.com/djui/alias-tips). 6 | 7 | ## Example 8 | 9 | ``` 10 | $ alias gst='git status' 11 | 12 | $ git status 13 | 💡 gst 14 | On branch master 15 | Your branch is up to date with 'origin/master'. 16 | 17 | nothing to commit, working tree clean 18 | ``` 19 | 20 | ## Install 21 | 22 | ### Requirements 23 | 24 | - Rust (cargo) 25 | - zsh 26 | 27 | ### Install with [zinit](https://github.com/zdharma/zinit) 28 | 29 | ```sh 30 | zinit ice atclone'cargo build --release' atpull'%atclone' 31 | zinit light sei40kr/zsh-fast-alias-tips 32 | ``` 33 | 34 | The plugin will automatically build the `alias-matcher` binary during installation and updates. 35 | 36 | ## Customization 37 | 38 | | Variable | Default value | Description | 39 | | :-- | :-- | :-- | 40 | | `FAST_ALIAS_TIPS_PREFIX` | `"💡 $(tput bold)"` | The prefix of the Tips | 41 | | `FAST_ALIAS_TIPS_SUFFIX` | `"$(tput sgr0)"` | The suffix of the Tips | 42 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1764950072, 24 | "narHash": "sha256-BmPWzogsG2GsXZtlT+MTcAWeDK5hkbGRZTeZNW42fwA=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "f61125a668a320878494449750330ca58b78c557", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Help remembering the aliases you defined once"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = 10 | { 11 | self, 12 | nixpkgs, 13 | flake-utils, 14 | }: 15 | flake-utils.lib.eachDefaultSystem ( 16 | system: 17 | let 18 | pkgs = nixpkgs.legacyPackages.${system}; 19 | 20 | zshrc = pkgs.writeText "zshrc" '' 21 | source ${pkgs.zinit}/share/zinit/zinit.zsh 22 | 23 | # Load the plugin from current directory 24 | zinit ice atclone'cargo build --release' atpull'%atclone' 25 | zinit load $PWD 26 | 27 | # Test aliases 28 | alias gst='git status' 29 | alias gco='git checkout' 30 | alias gcb='git checkout -b' 31 | 32 | echo "========================================" 33 | echo "zsh-fast-alias-tips test environment" 34 | echo "========================================" 35 | echo "" 36 | echo "Available test aliases:" 37 | echo " gst -> git status" 38 | echo " gco -> git checkout" 39 | echo " gcb -> git checkout -b" 40 | echo "" 41 | echo "Try running:" 42 | echo " git status" 43 | echo " git checkout -b feature" 44 | echo "" 45 | echo "You should see alias tips appear!" 46 | echo "========================================" 47 | ''; 48 | in 49 | { 50 | devShells.default = pkgs.mkShell { 51 | buildInputs = with pkgs; [ 52 | zsh 53 | zinit 54 | git 55 | 56 | cargo 57 | rustc 58 | ]; 59 | 60 | shellHook = '' 61 | export ZDOTDIR=$(mktemp -d) 62 | ln -sf ${zshrc} $ZDOTDIR/.zshrc 63 | exec zsh 64 | ''; 65 | }; 66 | } 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod lexer; 2 | mod matcher; 3 | mod model; 4 | mod parser; 5 | 6 | use std::io::{self, BufRead}; 7 | 8 | use lexer::Lexer; 9 | use matcher::find_best_match; 10 | use parser::Parser; 11 | 12 | /// Reads alias definitions from stdin and finds the best match for the command. 13 | /// 14 | /// # Arguments 15 | /// 16 | /// * Command to match (provided as first command-line argument) 17 | /// 18 | /// # Input 19 | /// 20 | /// Reads alias definitions from stdin, one per line in the format: 21 | /// - `name=expansion` 22 | /// - `name='expansion'` 23 | /// - `'name'='expansion'` 24 | /// 25 | /// # Output 26 | /// 27 | /// Prints the suggested alias to stdout: 28 | /// - Full match: alias name only (e.g., `gst`) 29 | /// - Partial match: alias name + remaining arguments (e.g., `gco feature-branch`) 30 | fn main() { 31 | let args: Vec = std::env::args().collect(); 32 | 33 | if args.len() != 2 { 34 | eprintln!("Invalid number of arguments"); 35 | std::process::exit(1); 36 | } 37 | 38 | let command = &args[1]; 39 | 40 | let definitions = read_alias_definitions_from_stdin(); 41 | 42 | if let Some(result) = find_best_match(&definitions, command) { 43 | let output = format_match_result(result, command); 44 | println!("{}", output); 45 | } 46 | } 47 | 48 | /// Reads and parses alias definitions from stdin. 49 | fn read_alias_definitions_from_stdin() -> Vec { 50 | let stdin = io::stdin(); 51 | let reader = io::BufReader::with_capacity(1024, stdin.lock()); 52 | 53 | reader 54 | .lines() 55 | .map_while(Result::ok) 56 | .filter_map(|line| { 57 | let mut lexer = Lexer::new(&line); 58 | let tokens = lexer.tokenize(); 59 | let mut parser = Parser::new(tokens); 60 | parser.parse().ok() 61 | }) 62 | .collect() 63 | } 64 | 65 | /// Formats the match result for output. 66 | fn format_match_result(result: matcher::MatchResult, command: &str) -> String { 67 | if result.is_full_match { 68 | result.definition.name.clone() 69 | } else { 70 | format!( 71 | "{}{}", 72 | result.definition.name, 73 | &command[result.definition.expansion.len()..] 74 | ) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/matcher.rs: -------------------------------------------------------------------------------- 1 | use crate::model::AliasDefinition; 2 | 3 | /// The result of matching a command against alias definitions. 4 | #[derive(Debug)] 5 | pub struct MatchResult<'a> { 6 | /// The matched alias definition 7 | pub definition: &'a AliasDefinition, 8 | /// Whether the match is a full match (true) or partial match (false) 9 | pub is_full_match: bool, 10 | } 11 | 12 | /// Finds the best matching alias for a given command. 13 | /// 14 | /// This function implements a recursive matching algorithm: 15 | /// 1. Sorts aliases by expansion length (longest first) 16 | /// 2. Finds the longest matching alias 17 | /// 3. Substitutes the matched alias and repeats 18 | /// 4. Returns the final matched alias 19 | /// 20 | /// # Examples 21 | /// 22 | /// ``` 23 | /// # use alias_matcher::model::AliasDefinition; 24 | /// # use alias_matcher::matcher::find_best_match; 25 | /// let defs = vec![ 26 | /// AliasDefinition::new("dk".to_string(), "docker".to_string()), 27 | /// AliasDefinition::new("gst".to_string(), "git status".to_string()), 28 | /// ]; 29 | /// 30 | /// let result = find_best_match(&defs, "docker"); 31 | /// assert!(result.is_some()); 32 | /// assert_eq!(result.unwrap().definition.name, "dk"); 33 | /// ``` 34 | pub fn find_best_match<'a>( 35 | definitions: &'a [AliasDefinition], 36 | command: &str, 37 | ) -> Option> { 38 | let mut current_command = command.to_string(); 39 | let mut candidate: Option<&'a AliasDefinition> = None; 40 | let mut is_full_match = false; 41 | 42 | loop { 43 | let matched = find_longest_match(definitions, ¤t_command); 44 | 45 | match matched { 46 | Some((def, is_full)) => { 47 | current_command = 48 | format!("{}{}", def.name, ¤t_command[def.expansion.len()..]); 49 | candidate = Some(def); 50 | is_full_match = is_full; 51 | } 52 | None => break, 53 | } 54 | } 55 | 56 | candidate.map(|def| MatchResult { 57 | definition: def, 58 | is_full_match, 59 | }) 60 | } 61 | 62 | /// Finds the longest matching alias for the given command. 63 | fn find_longest_match<'a>( 64 | definitions: &'a [AliasDefinition], 65 | command: &str, 66 | ) -> Option<(&'a AliasDefinition, bool)> { 67 | let mut best_match: Option<(&'a AliasDefinition, bool)> = None; 68 | let mut best_length = 0; 69 | 70 | for def in definitions { 71 | if command == def.expansion && def.expansion.len() > best_length { 72 | best_match = Some((def, true)); 73 | best_length = def.expansion.len(); 74 | } else if command.starts_with(&def.expansion) && def.expansion.len() > best_length { 75 | best_match = Some((def, false)); 76 | best_length = def.expansion.len(); 77 | } 78 | } 79 | 80 | best_match 81 | } 82 | 83 | #[cfg(test)] 84 | mod tests { 85 | use super::*; 86 | 87 | fn create_test_definitions() -> Vec { 88 | vec![ 89 | AliasDefinition::new("dk".to_string(), "docker".to_string()), 90 | AliasDefinition::new("gb".to_string(), "git branch".to_string()), 91 | AliasDefinition::new("gco".to_string(), "git checkout".to_string()), 92 | AliasDefinition::new("gcb".to_string(), "git checkout -b".to_string()), 93 | AliasDefinition::new("ls".to_string(), "ls -G".to_string()), 94 | AliasDefinition::new("ll".to_string(), "ls -lh".to_string()), 95 | ] 96 | } 97 | 98 | // Normal cases 99 | #[test] 100 | fn test_match_single_token() { 101 | let defs = create_test_definitions(); 102 | let result = find_best_match(&defs, "docker"); 103 | assert!(result.is_some()); 104 | assert_eq!(result.unwrap().definition.name, "dk"); 105 | } 106 | 107 | #[test] 108 | fn test_match_multiple_tokens() { 109 | let defs = create_test_definitions(); 110 | let result = find_best_match(&defs, "git branch"); 111 | assert!(result.is_some()); 112 | assert_eq!(result.unwrap().definition.name, "gb"); 113 | } 114 | 115 | #[test] 116 | fn test_match_prefers_longest() { 117 | let defs = create_test_definitions(); 118 | let result = find_best_match(&defs, "git checkout -b"); 119 | assert!(result.is_some()); 120 | assert_eq!(result.unwrap().definition.name, "gcb"); 121 | } 122 | 123 | #[test] 124 | fn test_match_recursive() { 125 | let defs = create_test_definitions(); 126 | let result = find_best_match(&defs, "ls -G -lh"); 127 | assert!(result.is_some()); 128 | assert_eq!(result.unwrap().definition.name, "ll"); 129 | } 130 | 131 | #[test] 132 | fn test_match_no_matches() { 133 | let defs = create_test_definitions(); 134 | let result = find_best_match(&defs, "cd .."); 135 | assert!(result.is_none()); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/lexer.rs: -------------------------------------------------------------------------------- 1 | /// Represents a token in the alias definition syntax. 2 | #[derive(Debug, Clone, PartialEq, Eq)] 3 | pub enum Token { 4 | /// An unquoted identifier (e.g., `dk`, `git`) 5 | Identifier(String), 6 | /// A quoted string (e.g., `'git status'`) 7 | QuotedString(String), 8 | /// The equals sign separator 9 | Equals, 10 | } 11 | 12 | /// Lexer for tokenizing alias definition lines. 13 | pub struct Lexer { 14 | input: Vec, 15 | position: usize, 16 | } 17 | 18 | impl Lexer { 19 | pub fn new(input: &str) -> Self { 20 | Self { 21 | input: input.chars().collect(), 22 | position: 0, 23 | } 24 | } 25 | 26 | /// Tokenizes the entire input string. 27 | pub fn tokenize(&mut self) -> Vec { 28 | let mut tokens = Vec::new(); 29 | 30 | while !self.is_at_end() { 31 | if let Some(token) = self.next_token() { 32 | tokens.push(token); 33 | } 34 | } 35 | 36 | tokens 37 | } 38 | 39 | fn next_token(&mut self) -> Option { 40 | self.skip_whitespace(); 41 | 42 | if self.is_at_end() { 43 | return None; 44 | } 45 | 46 | let ch = self.current_char()?; 47 | 48 | match ch { 49 | '=' => { 50 | self.advance(); 51 | Some(Token::Equals) 52 | } 53 | '\'' => Some(self.read_quoted_string()), 54 | _ => Some(self.read_identifier()), 55 | } 56 | } 57 | 58 | fn read_quoted_string(&mut self) -> Token { 59 | self.advance(); // Skip opening quote 60 | 61 | let mut content = String::new(); 62 | let mut is_escaped = false; 63 | 64 | while !self.is_at_end() { 65 | let ch = self.current_char().unwrap(); 66 | 67 | if is_escaped { 68 | content.push(ch); 69 | is_escaped = false; 70 | } else if ch == '\\' { 71 | is_escaped = true; 72 | } else if ch == '\'' { 73 | self.advance(); // Skip closing quote 74 | break; 75 | } else { 76 | content.push(ch); 77 | } 78 | 79 | self.advance(); 80 | } 81 | 82 | Token::QuotedString(content) 83 | } 84 | 85 | fn read_identifier(&mut self) -> Token { 86 | let mut identifier = String::new(); 87 | 88 | while !self.is_at_end() { 89 | let ch = self.current_char().unwrap(); 90 | 91 | if ch == '=' || ch == '\'' || ch.is_whitespace() { 92 | break; 93 | } 94 | 95 | identifier.push(ch); 96 | self.advance(); 97 | } 98 | 99 | Token::Identifier(identifier) 100 | } 101 | 102 | fn skip_whitespace(&mut self) { 103 | while !self.is_at_end() { 104 | if let Some(ch) = self.current_char() { 105 | if !ch.is_whitespace() { 106 | break; 107 | } 108 | self.advance(); 109 | } 110 | } 111 | } 112 | 113 | fn current_char(&self) -> Option { 114 | self.input.get(self.position).copied() 115 | } 116 | 117 | fn advance(&mut self) { 118 | self.position += 1; 119 | } 120 | 121 | fn is_at_end(&self) -> bool { 122 | self.position >= self.input.len() 123 | } 124 | } 125 | 126 | #[cfg(test)] 127 | mod tests { 128 | use super::*; 129 | 130 | #[test] 131 | fn test_tokenize_simple_alias() { 132 | let mut lexer = Lexer::new("dk=docker"); 133 | let tokens = lexer.tokenize(); 134 | 135 | assert_eq!( 136 | tokens, 137 | vec![ 138 | Token::Identifier("dk".to_string()), 139 | Token::Equals, 140 | Token::Identifier("docker".to_string()), 141 | ] 142 | ); 143 | } 144 | 145 | #[test] 146 | fn test_tokenize_quoted_expansion() { 147 | let mut lexer = Lexer::new("gb='git branch'"); 148 | let tokens = lexer.tokenize(); 149 | 150 | assert_eq!( 151 | tokens, 152 | vec![ 153 | Token::Identifier("gb".to_string()), 154 | Token::Equals, 155 | Token::QuotedString("git branch".to_string()), 156 | ] 157 | ); 158 | } 159 | 160 | #[test] 161 | fn test_tokenize_both_quoted() { 162 | let mut lexer = Lexer::new("'g cb'='git checkout -b'"); 163 | let tokens = lexer.tokenize(); 164 | 165 | assert_eq!( 166 | tokens, 167 | vec![ 168 | Token::QuotedString("g cb".to_string()), 169 | Token::Equals, 170 | Token::QuotedString("git checkout -b".to_string()), 171 | ] 172 | ); 173 | } 174 | 175 | #[test] 176 | fn test_tokenize_with_escape() { 177 | let mut lexer = Lexer::new("test='it\\'s working'"); 178 | let tokens = lexer.tokenize(); 179 | 180 | assert_eq!( 181 | tokens, 182 | vec![ 183 | Token::Identifier("test".to_string()), 184 | Token::Equals, 185 | Token::QuotedString("it's working".to_string()), 186 | ] 187 | ); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/parser.rs: -------------------------------------------------------------------------------- 1 | use crate::lexer::Token; 2 | use crate::model::AliasDefinition; 3 | 4 | /// Error type for parsing failures. 5 | #[derive(Debug, Clone, PartialEq, Eq)] 6 | pub enum ParseError { 7 | /// Expected an equals sign but found something else 8 | ExpectedEquals, 9 | /// Expected a name (identifier or quoted string) but found something else 10 | ExpectedName, 11 | /// Expected an expansion (identifier or quoted string) but found something else 12 | ExpectedExpansion, 13 | /// Unexpected end of input 14 | UnexpectedEndOfInput, 15 | } 16 | 17 | /// Parser for alias definitions. 18 | /// 19 | /// Grammar: 20 | /// ```text 21 | /// alias_def := name '=' expansion 22 | /// name := Identifier | QuotedString 23 | /// expansion := Identifier | QuotedString 24 | /// ``` 25 | pub struct Parser { 26 | tokens: Vec, 27 | position: usize, 28 | } 29 | 30 | impl Parser { 31 | pub fn new(tokens: Vec) -> Self { 32 | Self { 33 | tokens, 34 | position: 0, 35 | } 36 | } 37 | 38 | /// Parses an alias definition from the token stream. 39 | pub fn parse(&mut self) -> Result { 40 | let name = self.parse_name()?; 41 | self.expect_equals()?; 42 | let expansion = self.parse_expansion()?; 43 | 44 | Ok(AliasDefinition::new(name, expansion)) 45 | } 46 | 47 | fn parse_name(&mut self) -> Result { 48 | match self.current_token() { 49 | Some(Token::Identifier(s)) | Some(Token::QuotedString(s)) => { 50 | let name = s.clone(); 51 | self.advance(); 52 | Ok(name) 53 | } 54 | Some(_) => Err(ParseError::ExpectedName), 55 | None => Err(ParseError::UnexpectedEndOfInput), 56 | } 57 | } 58 | 59 | fn expect_equals(&mut self) -> Result<(), ParseError> { 60 | match self.current_token() { 61 | Some(Token::Equals) => { 62 | self.advance(); 63 | Ok(()) 64 | } 65 | Some(_) => Err(ParseError::ExpectedEquals), 66 | None => Err(ParseError::UnexpectedEndOfInput), 67 | } 68 | } 69 | 70 | fn parse_expansion(&mut self) -> Result { 71 | match self.current_token() { 72 | Some(Token::Identifier(s)) | Some(Token::QuotedString(s)) => { 73 | let expansion = s.clone(); 74 | self.advance(); 75 | Ok(expansion) 76 | } 77 | Some(_) => Err(ParseError::ExpectedExpansion), 78 | None => Err(ParseError::UnexpectedEndOfInput), 79 | } 80 | } 81 | 82 | fn current_token(&self) -> Option<&Token> { 83 | self.tokens.get(self.position) 84 | } 85 | 86 | fn advance(&mut self) { 87 | self.position += 1; 88 | } 89 | } 90 | 91 | #[cfg(test)] 92 | mod tests { 93 | use super::*; 94 | use crate::lexer::Token; 95 | 96 | // Normal cases 97 | #[test] 98 | fn test_parser_success() { 99 | let tokens = vec![ 100 | Token::Identifier("dk".to_string()), 101 | Token::Equals, 102 | Token::Identifier("docker".to_string()), 103 | ]; 104 | let mut parser = Parser::new(tokens); 105 | let def = parser.parse().unwrap(); 106 | assert_eq!(def.name, "dk"); 107 | assert_eq!(def.expansion, "docker"); 108 | } 109 | 110 | #[test] 111 | fn test_parser_with_quoted_strings() { 112 | let tokens = vec![ 113 | Token::QuotedString("g cb".to_string()), 114 | Token::Equals, 115 | Token::QuotedString("git checkout -b".to_string()), 116 | ]; 117 | let mut parser = Parser::new(tokens); 118 | let def = parser.parse().unwrap(); 119 | assert_eq!(def.name, "g cb"); 120 | assert_eq!(def.expansion, "git checkout -b"); 121 | } 122 | 123 | // Semi-normal cases 124 | #[test] 125 | fn test_parser_expected_equals() { 126 | let tokens = vec![ 127 | Token::Identifier("dk".to_string()), 128 | Token::Identifier("docker".to_string()), 129 | ]; 130 | let mut parser = Parser::new(tokens); 131 | let result = parser.parse(); 132 | assert_eq!(result, Err(ParseError::ExpectedEquals)); 133 | } 134 | 135 | #[test] 136 | fn test_parser_expected_name() { 137 | let tokens = vec![Token::Equals, Token::Identifier("docker".to_string())]; 138 | let mut parser = Parser::new(tokens); 139 | let result = parser.parse(); 140 | assert_eq!(result, Err(ParseError::ExpectedName)); 141 | } 142 | 143 | #[test] 144 | fn test_parser_expected_expansion() { 145 | let tokens = vec![Token::Identifier("dk".to_string()), Token::Equals]; 146 | let mut parser = Parser::new(tokens); 147 | let result = parser.parse(); 148 | assert_eq!(result, Err(ParseError::UnexpectedEndOfInput)); 149 | } 150 | 151 | #[test] 152 | fn test_parser_unexpected_end_of_input_at_name() { 153 | let tokens = vec![]; 154 | let mut parser = Parser::new(tokens); 155 | let result = parser.parse(); 156 | assert_eq!(result, Err(ParseError::UnexpectedEndOfInput)); 157 | } 158 | 159 | #[test] 160 | fn test_parser_unexpected_end_of_input_at_equals() { 161 | let tokens = vec![Token::Identifier("dk".to_string())]; 162 | let mut parser = Parser::new(tokens); 163 | let result = parser.parse(); 164 | assert_eq!(result, Err(ParseError::UnexpectedEndOfInput)); 165 | } 166 | } 167 | --------------------------------------------------------------------------------