| 1 | use nu_ansi_term::{Color, Style}; |
| 2 | use reedline::{Highlighter, StyledText}; |
| 3 | use std::env; |
| 4 | use std::path::PathBuf; |
| 5 | |
| 6 | /// Syntax highlighter for Rush shell |
| 7 | /// |
| 8 | /// Provides fish-like syntax highlighting: |
| 9 | /// - Valid commands in green |
| 10 | /// - Invalid commands in red |
| 11 | /// - Keywords (if, while, for, etc.) in bold cyan |
| 12 | /// - Operators (|, &, &&, ||, etc.) in yellow |
| 13 | /// - Strings in green |
| 14 | /// - Variables in cyan |
| 15 | pub struct RushHighlighter; |
| 16 | |
| 17 | impl RushHighlighter { |
| 18 | pub fn new() -> Self { |
| 19 | Self |
| 20 | } |
| 21 | |
| 22 | /// Check if a command exists in PATH |
| 23 | fn command_exists(&self, command: &str) -> bool { |
| 24 | // Built-in commands always exist |
| 25 | if Self::is_builtin(command) { |
| 26 | return true; |
| 27 | } |
| 28 | |
| 29 | // Check if it's a path (contains /) |
| 30 | if command.contains('/') { |
| 31 | let path = PathBuf::from(command); |
| 32 | return path.exists() && Self::is_executable(&path); |
| 33 | } |
| 34 | |
| 35 | // Search in PATH |
| 36 | if let Some(path_var) = env::var_os("PATH") { |
| 37 | for dir in env::split_paths(&path_var) { |
| 38 | let candidate = dir.join(command); |
| 39 | if candidate.exists() && Self::is_executable(&candidate) { |
| 40 | return true; |
| 41 | } |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | false |
| 46 | } |
| 47 | |
| 48 | /// Check if a command is a shell built-in |
| 49 | fn is_builtin(command: &str) -> bool { |
| 50 | matches!( |
| 51 | command, |
| 52 | "cd" | "pwd" | "exit" | "true" | "false" | "test" | "[" | ":" |
| 53 | | "eval" | "alias" | "unalias" | "trap" | "set" | "shopt" |
| 54 | | "export" | "unset" | "readonly" | "declare" | "typeset" | "local" |
| 55 | | "read" | "shift" | "wait" | "kill" | "times" | "umask" | "hash" |
| 56 | | "getopts" | "exec" | "command" | "jobs" | "fg" | "bg" |
| 57 | | "coproc" | "disown" | "printf" | "mapfile" | "readarray" |
| 58 | | "break" | "continue" | "return" | "source" | "." |
| 59 | | "complete" | "pushd" | "popd" | "dirs" |
| 60 | ) |
| 61 | } |
| 62 | |
| 63 | /// Check if a command is a shell keyword |
| 64 | fn is_keyword(word: &str) -> bool { |
| 65 | matches!( |
| 66 | word, |
| 67 | "if" | "then" | "else" | "elif" | "fi" |
| 68 | | "while" | "do" | "done" |
| 69 | | "for" | "in" |
| 70 | | "case" | "esac" |
| 71 | ) |
| 72 | } |
| 73 | |
| 74 | /// Check if a file is executable |
| 75 | #[cfg(unix)] |
| 76 | fn is_executable(path: &PathBuf) -> bool { |
| 77 | use std::os::unix::fs::PermissionsExt; |
| 78 | path.metadata() |
| 79 | .map(|m| m.permissions().mode() & 0o111 != 0) |
| 80 | .unwrap_or(false) |
| 81 | } |
| 82 | |
| 83 | #[cfg(not(unix))] |
| 84 | fn is_executable(_path: &PathBuf) -> bool { |
| 85 | true |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | impl Default for RushHighlighter { |
| 90 | fn default() -> Self { |
| 91 | Self::new() |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | impl Highlighter for RushHighlighter { |
| 96 | fn highlight(&self, line: &str, _cursor: usize) -> StyledText { |
| 97 | let mut styled = StyledText::new(); |
| 98 | |
| 99 | // Simple tokenization for now |
| 100 | // This is a basic implementation - we'll improve it later |
| 101 | let mut current_word = String::new(); |
| 102 | let mut is_first_word = true; |
| 103 | let mut in_string = false; |
| 104 | let mut string_char = ' '; |
| 105 | |
| 106 | for ch in line.chars() { |
| 107 | // Handle strings |
| 108 | if (ch == '"' || ch == '\'') && !in_string { |
| 109 | // Start of string |
| 110 | if !current_word.is_empty() { |
| 111 | Self::push_word(&mut styled, ¤t_word, is_first_word, self); |
| 112 | current_word.clear(); |
| 113 | is_first_word = false; |
| 114 | } |
| 115 | in_string = true; |
| 116 | string_char = ch; |
| 117 | current_word.push(ch); |
| 118 | } else if in_string && ch == string_char { |
| 119 | // End of string |
| 120 | current_word.push(ch); |
| 121 | styled.push((Style::new().fg(Color::Green), current_word.clone())); |
| 122 | current_word.clear(); |
| 123 | in_string = false; |
| 124 | } else if in_string { |
| 125 | // Inside string |
| 126 | current_word.push(ch); |
| 127 | } else if ch.is_whitespace() { |
| 128 | // Whitespace - flush current word |
| 129 | if !current_word.is_empty() { |
| 130 | Self::push_word(&mut styled, ¤t_word, is_first_word, self); |
| 131 | current_word.clear(); |
| 132 | is_first_word = false; |
| 133 | } |
| 134 | styled.push((Style::default(), ch.to_string())); |
| 135 | } else if ch == '|' || ch == '&' || ch == '>' || ch == '<' || ch == ';' { |
| 136 | // Operators |
| 137 | if !current_word.is_empty() { |
| 138 | Self::push_word(&mut styled, ¤t_word, is_first_word, self); |
| 139 | current_word.clear(); |
| 140 | is_first_word = false; |
| 141 | } |
| 142 | current_word.push(ch); |
| 143 | // Check for multi-char operators (||, &&, >>, etc.) |
| 144 | } else if current_word.chars().all(|c| c == '|' || c == '&' || c == '>' || c == '<') { |
| 145 | // Continue operator |
| 146 | current_word.push(ch); |
| 147 | } else { |
| 148 | // Check if we need to flush an operator |
| 149 | if current_word.chars().all(|c| c == '|' || c == '&' || c == '>' || c == '<') && !current_word.is_empty() { |
| 150 | styled.push((Style::new().fg(Color::Yellow), current_word.clone())); |
| 151 | current_word.clear(); |
| 152 | } |
| 153 | current_word.push(ch); |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | // Flush any remaining content |
| 158 | if !current_word.is_empty() { |
| 159 | if in_string { |
| 160 | // Unclosed string |
| 161 | styled.push((Style::new().fg(Color::Red), current_word)); |
| 162 | } else if current_word.chars().all(|c| c == '|' || c == '&' || c == '>' || c == '<') { |
| 163 | styled.push((Style::new().fg(Color::Yellow), current_word)); |
| 164 | } else { |
| 165 | Self::push_word(&mut styled, ¤t_word, is_first_word, self); |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | styled |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | impl RushHighlighter { |
| 174 | /// Push a word with appropriate styling |
| 175 | fn push_word(styled: &mut StyledText, word: &str, is_command: bool, highlighter: &RushHighlighter) { |
| 176 | // Check for variables |
| 177 | if word.starts_with('$') { |
| 178 | styled.push((Style::new().fg(Color::Cyan), word.to_string())); |
| 179 | return; |
| 180 | } |
| 181 | |
| 182 | // Check for keywords |
| 183 | if Self::is_keyword(word) { |
| 184 | styled.push((Style::new().fg(Color::Cyan).bold(), word.to_string())); |
| 185 | return; |
| 186 | } |
| 187 | |
| 188 | // Check if it's a command (first word) |
| 189 | if is_command { |
| 190 | if highlighter.command_exists(word) { |
| 191 | styled.push((Style::new().fg(Color::Green), word.to_string())); |
| 192 | } else { |
| 193 | styled.push((Style::new().fg(Color::Red), word.to_string())); |
| 194 | } |
| 195 | return; |
| 196 | } |
| 197 | |
| 198 | // Default: no styling |
| 199 | styled.push((Style::default(), word.to_string())); |
| 200 | } |
| 201 | } |
| 202 |