1use std::collections::HashSet;
7use std::sync::{LazyLock, Mutex};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
15pub enum HighlightRole {
16 #[default]
17 Normal,
18 Command,
19 Keyword,
20 Statement,
21 Param,
22 Option,
23 Comment,
24 Error,
25 String,
26 Escape,
27 Operator,
28 Redirection,
29 Path,
30 PathValid,
31 Autosuggestion,
32 Selection,
33 Search,
34 Variable,
35 Quote,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
40pub struct HighlightSpec {
41 pub foreground: HighlightRole,
42 pub background: HighlightRole,
43 pub valid_path: bool,
44 pub force_underline: bool,
45}
46
47impl HighlightSpec {
48 pub fn with_fg(fg: HighlightRole) -> Self {
49 Self {
50 foreground: fg,
51 ..Default::default()
52 }
53 }
54}
55
56pub fn role_to_ansi(role: HighlightRole) -> &'static str {
58 match role {
59 HighlightRole::Normal => "\x1b[0m",
60 HighlightRole::Command => "\x1b[1;32m", HighlightRole::Keyword => "\x1b[1;34m", HighlightRole::Statement => "\x1b[1;35m", HighlightRole::Param => "\x1b[0m", HighlightRole::Option => "\x1b[36m", HighlightRole::Comment => "\x1b[90m", HighlightRole::Error => "\x1b[1;31m", HighlightRole::String => "\x1b[33m", HighlightRole::Escape => "\x1b[1;33m", HighlightRole::Operator => "\x1b[1;37m", HighlightRole::Redirection => "\x1b[35m", HighlightRole::Path => "\x1b[4m", HighlightRole::PathValid => "\x1b[4;32m", HighlightRole::Autosuggestion => "\x1b[90m", HighlightRole::Selection => "\x1b[7m", HighlightRole::Search => "\x1b[1;43m", HighlightRole::Variable => "\x1b[1;36m", HighlightRole::Quote => "\x1b[33m", }
79}
80
81const KEYWORDS: &[&str] = &[
83 "if", "then", "else", "elif", "fi", "case", "esac", "for", "while", "until", "do", "done",
84 "in", "function", "select", "time", "coproc", "{", "}", "[[", "]]", "!", "foreach", "end",
85 "repeat", "always",
86];
87
88const BUILTINS: &[&str] = &[
90 "cd", "echo", "exit", "export", "alias", "unalias", "source", ".", "eval", "exec", "set",
91 "unset", "shift", "return", "break", "continue", "read", "readonly", "declare", "local",
92 "typeset", "let", "test", "[", "printf", "kill", "wait", "jobs", "fg", "bg", "disown", "trap",
93 "umask", "ulimit", "hash", "type", "which", "builtin", "command", "enable", "help", "history",
94 "fc", "pushd", "popd", "dirs", "pwd", "true", "false", ":", "getopts", "compgen", "complete",
95 "compopt", "shopt", "bind", "autoload", "zmodload", "zstyle", "zle", "bindkey", "setopt",
96 "unsetopt", "emulate", "whence",
97];
98
99pub fn highlight_shell(line: &str) -> Vec<HighlightSpec> {
101 let mut colors = vec![HighlightSpec::default(); line.len()];
102 if line.is_empty() {
103 return colors;
104 }
105
106 let mut in_string = false;
107 let mut string_char = '"';
108 let mut in_comment = false;
109 let mut word_start: Option<usize> = None;
110 let mut is_first_word = true;
111 let mut after_pipe_or_semi = false;
112
113 let chars: Vec<char> = line.chars().collect();
114 let mut i = 0;
115
116 while i < chars.len() {
117 let c = chars[i];
118 let byte_pos = line.char_indices().nth(i).map(|(p, _)| p).unwrap_or(0);
119
120 if !in_string && c == '#' {
122 in_comment = true;
123 }
124 if in_comment {
125 if byte_pos < colors.len() {
126 colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Comment);
127 }
128 i += 1;
129 continue;
130 }
131
132 if !in_string && (c == '"' || c == '\'') {
134 in_string = true;
135 string_char = c;
136 if byte_pos < colors.len() {
137 colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Quote);
138 }
139 i += 1;
140 continue;
141 }
142 if in_string {
143 if c == string_char {
144 in_string = false;
145 if byte_pos < colors.len() {
146 colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Quote);
147 }
148 } else if c == '\\' && string_char == '"' && i + 1 < chars.len() {
149 if byte_pos < colors.len() {
150 colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Escape);
151 }
152 i += 1;
153 let next_byte = line.char_indices().nth(i).map(|(p, _)| p).unwrap_or(0);
154 if next_byte < colors.len() {
155 colors[next_byte] = HighlightSpec::with_fg(HighlightRole::Escape);
156 }
157 } else if c == '$' {
158 if byte_pos < colors.len() {
159 colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Variable);
160 }
161 } else {
162 if byte_pos < colors.len() {
163 colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::String);
164 }
165 }
166 i += 1;
167 continue;
168 }
169
170 if c == '$' {
172 if byte_pos < colors.len() {
173 colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Variable);
174 }
175 i += 1;
176 while i < chars.len() {
178 let vc = chars[i];
179 if vc.is_alphanumeric() || vc == '_' || vc == '{' || vc == '}' {
180 let vbyte = line.char_indices().nth(i).map(|(p, _)| p).unwrap_or(0);
181 if vbyte < colors.len() {
182 colors[vbyte] = HighlightSpec::with_fg(HighlightRole::Variable);
183 }
184 i += 1;
185 } else {
186 break;
187 }
188 }
189 continue;
190 }
191
192 if c == '|' || c == '&' || c == ';' {
194 if byte_pos < colors.len() {
195 colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Operator);
196 }
197 is_first_word = true;
198 after_pipe_or_semi = true;
199 i += 1;
200 continue;
201 }
202 if c == '>' || c == '<' {
203 if byte_pos < colors.len() {
204 colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Redirection);
205 }
206 if i + 1 < chars.len() && (chars[i + 1] == '>' || chars[i + 1] == '<') {
208 i += 1;
209 let next_byte = line.char_indices().nth(i).map(|(p, _)| p).unwrap_or(0);
210 if next_byte < colors.len() {
211 colors[next_byte] = HighlightSpec::with_fg(HighlightRole::Redirection);
212 }
213 }
214 i += 1;
215 continue;
216 }
217
218 if c.is_whitespace() {
220 if let Some(start) = word_start {
221 let word_end = i;
223 let word: String = chars[start..word_end].iter().collect();
224 colorize_word(
225 &word,
226 start,
227 &mut colors,
228 line,
229 is_first_word || after_pipe_or_semi,
230 );
231 is_first_word = false;
232 after_pipe_or_semi = false;
233 }
234 word_start = None;
235 i += 1;
236 continue;
237 }
238
239 if word_start.is_none() {
241 word_start = Some(i);
242 }
243
244 i += 1;
245 }
246
247 if let Some(start) = word_start {
249 let word: String = chars[start..].iter().collect();
250 colorize_word(
251 &word,
252 start,
253 &mut colors,
254 line,
255 is_first_word || after_pipe_or_semi,
256 );
257 }
258
259 colors
260}
261
262fn colorize_word(
263 word: &str,
264 char_start: usize,
265 colors: &mut [HighlightSpec],
266 line: &str,
267 is_command_position: bool,
268) {
269 let role = if is_command_position {
270 if KEYWORDS.contains(&word) {
271 HighlightRole::Keyword
272 } else if BUILTINS.contains(&word) {
273 HighlightRole::Command
274 } else if command_exists(word) {
275 HighlightRole::Command
276 } else if word.contains('/') && std::path::Path::new(word).exists() {
277 HighlightRole::Command
278 } else {
279 HighlightRole::Error
280 }
281 } else if word.starts_with('-') {
282 HighlightRole::Option
283 } else if std::path::Path::new(word).exists() {
284 HighlightRole::PathValid
285 } else {
286 HighlightRole::Param
287 };
288
289 for (ci, _) in word.char_indices() {
291 let global_char_idx = char_start + word[..ci].chars().count();
292 if let Some((byte_pos, _)) = line.char_indices().nth(global_char_idx) {
293 if byte_pos < colors.len() {
294 colors[byte_pos] = HighlightSpec::with_fg(role);
295 }
296 }
297 }
298 let last_char_idx = char_start + word.chars().count() - 1;
300 if let Some((byte_pos, _)) = line.char_indices().nth(last_char_idx) {
301 if byte_pos < colors.len() {
302 colors[byte_pos] = HighlightSpec::with_fg(role);
303 }
304 }
305}
306
307fn command_exists(cmd: &str) -> bool {
309 if cmd.is_empty() {
310 return false;
311 }
312 if let Ok(path) = std::env::var("PATH") {
313 for dir in path.split(':') {
314 let full_path = std::path::Path::new(dir).join(cmd);
315 if full_path.is_file() {
316 return true;
317 }
318 }
319 }
320 false
321}
322
323pub fn colorize_line(line: &str, colors: &[HighlightSpec]) -> String {
325 let mut result = String::with_capacity(line.len() * 2);
326 let mut last_role = HighlightRole::Normal;
327
328 for (i, c) in line.chars().enumerate() {
329 let byte_pos = line.char_indices().nth(i).map(|(p, _)| p).unwrap_or(i);
330 let role = colors
331 .get(byte_pos)
332 .map(|s| s.foreground)
333 .unwrap_or(HighlightRole::Normal);
334
335 if role != last_role {
336 result.push_str(role_to_ansi(role));
337 last_role = role;
338 }
339 result.push(c);
340 }
341
342 if last_role != HighlightRole::Normal {
343 result.push_str("\x1b[0m");
344 }
345
346 result
347}
348
349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355pub enum AbbrPosition {
356 Command, Anywhere, }
359
360#[derive(Debug, Clone)]
362pub struct Abbreviation {
363 pub name: String,
364 pub key: String,
365 pub replacement: String,
366 pub position: AbbrPosition,
367}
368
369impl Abbreviation {
370 pub fn new(name: &str, key: &str, replacement: &str, position: AbbrPosition) -> Self {
371 Self {
372 name: name.to_string(),
373 key: key.to_string(),
374 replacement: replacement.to_string(),
375 position,
376 }
377 }
378
379 pub fn matches(&self, token: &str, is_command_position: bool) -> bool {
380 let position_ok = match self.position {
381 AbbrPosition::Anywhere => true,
382 AbbrPosition::Command => is_command_position,
383 };
384 position_ok && self.key == token
385 }
386}
387
388static ABBRS: LazyLock<Mutex<AbbreviationSet>> =
390 LazyLock::new(|| Mutex::new(AbbreviationSet::default()));
391
392pub fn with_abbrs<R>(cb: impl FnOnce(&AbbreviationSet) -> R) -> R {
393 let abbrs = ABBRS.lock().unwrap();
394 cb(&abbrs)
395}
396
397pub fn with_abbrs_mut<R>(cb: impl FnOnce(&mut AbbreviationSet) -> R) -> R {
398 let mut abbrs = ABBRS.lock().unwrap();
399 cb(&mut abbrs)
400}
401
402#[derive(Default)]
403pub struct AbbreviationSet {
404 abbrs: Vec<Abbreviation>,
405 used_names: HashSet<String>,
406}
407
408impl AbbreviationSet {
409 pub fn find_match(&self, token: &str, is_command_position: bool) -> Option<&Abbreviation> {
411 self.abbrs
413 .iter()
414 .rev()
415 .find(|a| a.matches(token, is_command_position))
416 }
417
418 pub fn has_match(&self, token: &str, is_command_position: bool) -> bool {
420 self.abbrs
421 .iter()
422 .any(|a| a.matches(token, is_command_position))
423 }
424
425 pub fn add(&mut self, abbr: Abbreviation) {
427 if self.used_names.contains(&abbr.name) {
428 self.abbrs.retain(|a| a.name != abbr.name);
429 }
430 self.used_names.insert(abbr.name.clone());
431 self.abbrs.push(abbr);
432 }
433
434 pub fn remove(&mut self, name: &str) -> bool {
436 if self.used_names.remove(name) {
437 self.abbrs.retain(|a| a.name != name);
438 true
439 } else {
440 false
441 }
442 }
443
444 pub fn list(&self) -> &[Abbreviation] {
446 &self.abbrs
447 }
448}
449
450pub fn expand_abbreviation(line: &str, cursor: usize) -> Option<(String, usize)> {
452 let before_cursor = &line[..cursor.min(line.len())];
454 let word_start = before_cursor
455 .rfind(char::is_whitespace)
456 .map(|i| i + 1)
457 .unwrap_or(0);
458 let word = &before_cursor[word_start..];
459
460 if word.is_empty() {
461 return None;
462 }
463
464 let is_command_position = before_cursor[..word_start].trim().is_empty()
466 || before_cursor[..word_start]
467 .trim()
468 .ends_with(|c| c == '|' || c == ';' || c == '&');
469
470 with_abbrs(|set| {
471 set.find_match(word, is_command_position).map(|abbr| {
472 let mut new_line = String::with_capacity(line.len() + abbr.replacement.len());
473 new_line.push_str(&line[..word_start]);
474 new_line.push_str(&abbr.replacement);
475 new_line.push_str(&line[cursor..]);
476 let new_cursor = word_start + abbr.replacement.len();
477 (new_line, new_cursor)
478 })
479 })
480}
481
482pub struct Autosuggestion {
488 pub text: String,
489 pub is_from_history: bool,
490}
491
492impl Autosuggestion {
493 pub fn empty() -> Self {
494 Self {
495 text: String::new(),
496 is_from_history: false,
497 }
498 }
499
500 pub fn is_empty(&self) -> bool {
501 self.text.is_empty()
502 }
503}
504
505pub fn autosuggest_from_history(line: &str, history: &[String]) -> Autosuggestion {
507 if line.is_empty() {
508 return Autosuggestion::empty();
509 }
510
511 let line_lower = line.to_lowercase();
512
513 for entry in history.iter().rev() {
515 if entry.starts_with(line) && entry.len() > line.len() {
517 return Autosuggestion {
518 text: entry[line.len()..].to_string(),
519 is_from_history: true,
520 };
521 }
522 }
523
524 for entry in history.iter().rev() {
526 let entry_lower = entry.to_lowercase();
527 if entry_lower.starts_with(&line_lower) && entry.len() > line.len() {
528 return Autosuggestion {
529 text: entry[line.len()..].to_string(),
530 is_from_history: true,
531 };
532 }
533 }
534
535 Autosuggestion::empty()
536}
537
538pub fn validate_autosuggestion(suggestion: &str, current_line: &str) -> bool {
540 if suggestion.is_empty() {
541 return false;
542 }
543
544 let full_line = format!("{}{}", current_line, suggestion);
546 let words: Vec<&str> = full_line.split_whitespace().collect();
547
548 if words.is_empty() {
549 return true;
550 }
551
552 let cmd = words[0];
553
554 if !command_exists(cmd) && !BUILTINS.contains(&cmd) && !KEYWORDS.contains(&cmd) {
556 if !cmd.contains('/') || !std::path::Path::new(cmd).exists() {
558 return false;
559 }
560 }
561
562 true
563}
564
565static KILLRING: LazyLock<Mutex<KillRing>> = LazyLock::new(|| Mutex::new(KillRing::new(100)));
570
571pub struct KillRing {
572 entries: Vec<String>,
573 max_size: usize,
574 yank_index: usize,
575}
576
577impl KillRing {
578 pub fn new(max_size: usize) -> Self {
579 Self {
580 entries: Vec::with_capacity(max_size),
581 max_size,
582 yank_index: 0,
583 }
584 }
585
586 pub fn add(&mut self, text: String) {
588 if text.is_empty() {
589 return;
590 }
591 self.entries.retain(|e| e != &text);
593 self.entries.insert(0, text);
594 if self.entries.len() > self.max_size {
595 self.entries.pop();
596 }
597 self.yank_index = 0;
598 }
599
600 pub fn replace(&mut self, text: String) {
602 if text.is_empty() {
603 return;
604 }
605 if self.entries.is_empty() {
606 self.add(text);
607 } else {
608 self.entries[0] = text;
609 }
610 }
611
612 pub fn yank(&self) -> Option<&str> {
614 self.entries.get(self.yank_index).map(|s| s.as_str())
615 }
616
617 pub fn rotate(&mut self) -> Option<&str> {
619 if self.entries.is_empty() {
620 return None;
621 }
622 self.yank_index = (self.yank_index + 1) % self.entries.len();
623 self.yank()
624 }
625
626 pub fn reset_yank(&mut self) {
628 self.yank_index = 0;
629 }
630}
631
632pub fn kill_add(text: String) {
633 KILLRING.lock().unwrap().add(text);
634}
635
636pub fn kill_replace(text: String) {
637 KILLRING.lock().unwrap().replace(text);
638}
639
640pub fn kill_yank() -> Option<String> {
641 KILLRING.lock().unwrap().yank().map(|s| s.to_string())
642}
643
644pub fn kill_yank_rotate() -> Option<String> {
645 KILLRING.lock().unwrap().rotate().map(|s| s.to_string())
646}
647
648pub fn validate_command(line: &str) -> ValidationStatus {
654 if line.trim().is_empty() {
655 return ValidationStatus::Valid;
656 }
657
658 let mut in_single = false;
660 let mut in_double = false;
661 let mut escaped = false;
662
663 for c in line.chars() {
664 if escaped {
665 escaped = false;
666 continue;
667 }
668 match c {
669 '\\' => escaped = true,
670 '\'' if !in_double => in_single = !in_single,
671 '"' if !in_single => in_double = !in_double,
672 _ => {}
673 }
674 }
675
676 if in_single || in_double {
677 return ValidationStatus::Incomplete;
678 }
679
680 let trimmed = line.trim();
682 if trimmed.ends_with('|') || trimmed.ends_with("&&") || trimmed.ends_with("||") {
683 return ValidationStatus::Incomplete;
684 }
685
686 let mut brace_count = 0i32;
688 let mut bracket_count = 0i32;
689 let mut paren_count = 0i32;
690
691 for c in line.chars() {
692 match c {
693 '{' => brace_count += 1,
694 '}' => brace_count -= 1,
695 '[' => bracket_count += 1,
696 ']' => bracket_count -= 1,
697 '(' => paren_count += 1,
698 ')' => paren_count -= 1,
699 _ => {}
700 }
701 if brace_count < 0 || bracket_count < 0 || paren_count < 0 {
702 return ValidationStatus::Invalid("Unmatched closing bracket".into());
703 }
704 }
705
706 if brace_count > 0 || bracket_count > 0 || paren_count > 0 {
707 return ValidationStatus::Incomplete;
708 }
709
710 ValidationStatus::Valid
711}
712
713#[derive(Debug, Clone, PartialEq)]
714pub enum ValidationStatus {
715 Valid,
716 Incomplete,
717 Invalid(String),
718}
719
720static PRIVATE_MODE: LazyLock<Mutex<bool>> = LazyLock::new(|| Mutex::new(false));
725
726pub fn is_private_mode() -> bool {
727 *PRIVATE_MODE.lock().unwrap()
728}
729
730pub fn set_private_mode(enabled: bool) {
731 *PRIVATE_MODE.lock().unwrap() = enabled;
732}
733
734#[cfg(test)]
735mod tests {
736 use super::*;
737
738 #[test]
739 fn test_highlight_command() {
740 let line = "ls -la /tmp";
741 let colors = highlight_shell(line);
742 assert!(!colors.is_empty());
743 }
744
745 #[test]
746 fn test_abbreviation() {
747 with_abbrs_mut(|set| {
748 set.add(Abbreviation::new("g", "g", "git", AbbrPosition::Command));
749 set.add(Abbreviation::new(
750 "ga",
751 "ga",
752 "git add",
753 AbbrPosition::Command,
754 ));
755 });
756
757 let result = expand_abbreviation("g", 1);
758 assert!(result.is_some());
759 let (new_line, _) = result.unwrap();
760 assert_eq!(new_line, "git");
761 }
762
763 #[test]
764 fn test_autosuggestion() {
765 let history = vec![
766 "ls -la".to_string(),
767 "git status".to_string(),
768 "git commit -m 'test'".to_string(),
769 ];
770
771 let suggestion = autosuggest_from_history("git s", &history);
772 assert!(!suggestion.is_empty());
773 assert_eq!(suggestion.text, "tatus");
774 }
775
776 #[test]
777 fn test_killring() {
778 kill_add("first".to_string());
779 kill_add("second".to_string());
780
781 assert_eq!(kill_yank(), Some("second".to_string()));
782 assert_eq!(kill_yank_rotate(), Some("first".to_string()));
783 }
784
785 #[test]
786 fn test_validation() {
787 assert_eq!(validate_command("echo hello"), ValidationStatus::Valid);
788 assert_eq!(
789 validate_command("echo \"unclosed"),
790 ValidationStatus::Incomplete
791 );
792 assert_eq!(validate_command("ls |"), ValidationStatus::Incomplete);
793 }
794}