1use std::collections::HashSet;
16use std::sync::{LazyLock, Mutex};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
29pub enum HighlightRole {
30 #[default]
32 Normal,
33 Command,
35 Keyword,
37 Statement,
39 Param,
41 Option,
43 Comment,
45 Error,
47 String,
49 Escape,
51 Operator,
53 Redirection,
55 Path,
57 PathValid,
59 Autosuggestion,
61 Selection,
63 Search,
65 Variable,
67 Quote,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
73pub struct HighlightSpec {
74 pub foreground: HighlightRole,
76 pub background: HighlightRole,
78 pub valid_path: bool,
80 pub force_underline: bool,
82}
83
84impl HighlightSpec {
85 pub fn with_fg(fg: HighlightRole) -> Self {
87 Self {
88 foreground: fg,
89 ..Default::default()
90 }
91 }
92}
93
94pub fn role_to_ansi(role: HighlightRole) -> &'static str {
96 match role {
97 HighlightRole::Normal => "\x1b[0m",
98 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", }
117}
118
119const KEYWORDS: &[&str] = &[
121 "if", "then", "else", "elif", "fi", "case", "esac", "for", "while", "until", "do", "done",
122 "in", "function", "select", "time", "coproc", "{", "}", "[[", "]]", "!", "foreach", "end",
123 "repeat", "always",
124];
125
126const BUILTINS: &[&str] = &[
128 "cd",
129 "echo",
130 "exit",
131 "export",
132 "alias",
133 "unalias",
134 "source",
135 ".",
136 "eval",
137 "exec",
138 "set",
139 "unset",
140 "shift",
141 "return",
142 "break",
143 "continue",
144 "read",
145 "readonly",
146 "declare",
147 "local",
148 "typeset",
149 "let",
150 "test",
151 "[",
152 "printf",
153 "kill",
154 "wait",
155 "jobs",
156 "fg",
157 "bg",
158 "disown",
159 "trap",
160 "umask",
161 "ulimit",
162 "hash",
163 "type",
164 "which",
165 "builtin",
166 "command",
167 "enable",
168 "help",
169 "history",
170 "fc",
171 "pushd",
172 "popd",
173 "dirs",
174 "pwd",
175 "true",
176 "false",
177 ":",
178 "getopts",
179 "compgen",
180 "complete",
181 "compopt",
182 "shopt",
183 "bind",
184 "autoload",
185 "zmodload",
186 "zstyle",
187 "zle",
188 "bindkey",
189 "setopt",
190 "unsetopt",
191 "emulate",
192 "whence",
193 "run_tests",
199 "zassert_contains",
200 "zassert_dies",
201 "zassert_eq",
202 "zassert_err",
203 "zassert_false",
204 "zassert_ge",
205 "zassert_gt",
206 "zassert_le",
207 "zassert_lt",
208 "zassert_match",
209 "zassert_ne",
210 "zassert_near",
211 "zassert_ok",
212 "zassert_true",
213 "ztest_run",
214 "ztest_skip",
215];
216
217pub fn highlight_shell(line: &str) -> Vec<HighlightSpec> {
223 let mut colors = vec![HighlightSpec::default(); line.len()];
224 if line.is_empty() {
225 return colors;
226 }
227
228 let mut in_string = false;
229 let mut string_char = '"';
230 let mut in_comment = false;
231 let mut word_start: Option<usize> = None;
232 let mut is_first_word = true;
233 let mut after_pipe_or_semi = false;
234
235 let chars: Vec<char> = line.chars().collect();
236 let mut i = 0;
237
238 while i < chars.len() {
239 let c = chars[i];
240 let byte_pos = line.char_indices().nth(i).map(|(p, _)| p).unwrap_or(0);
241
242 if !in_string && c == '#' {
244 in_comment = true;
245 }
246 if in_comment {
247 if byte_pos < colors.len() {
248 colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Comment);
249 }
250 i += 1;
251 continue;
252 }
253
254 if !in_string && (c == '"' || c == '\'') {
256 in_string = true;
257 string_char = c;
258 if byte_pos < colors.len() {
259 colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Quote);
260 }
261 i += 1;
262 continue;
263 }
264 if in_string {
265 if c == string_char {
266 in_string = false;
267 if byte_pos < colors.len() {
268 colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Quote);
269 }
270 } else if c == '\\' && string_char == '"' && i + 1 < chars.len() {
271 if byte_pos < colors.len() {
272 colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Escape);
273 }
274 i += 1;
275 let next_byte = line.char_indices().nth(i).map(|(p, _)| p).unwrap_or(0);
276 if next_byte < colors.len() {
277 colors[next_byte] = HighlightSpec::with_fg(HighlightRole::Escape);
278 }
279 } else if c == '$' {
280 if byte_pos < colors.len() {
281 colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Variable);
282 }
283 } else {
284 if byte_pos < colors.len() {
285 colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::String);
286 }
287 }
288 i += 1;
289 continue;
290 }
291
292 if c == '$' {
294 if byte_pos < colors.len() {
295 colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Variable);
296 }
297 i += 1;
298 while i < chars.len() {
300 let vc = chars[i];
301 if vc.is_alphanumeric() || vc == '_' || vc == '{' || vc == '}' {
302 let vbyte = line.char_indices().nth(i).map(|(p, _)| p).unwrap_or(0);
303 if vbyte < colors.len() {
304 colors[vbyte] = HighlightSpec::with_fg(HighlightRole::Variable);
305 }
306 i += 1;
307 } else {
308 break;
309 }
310 }
311 continue;
312 }
313
314 if c == '|' || c == '&' || c == ';' {
316 if byte_pos < colors.len() {
317 colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Operator);
318 }
319 is_first_word = true;
320 after_pipe_or_semi = true;
321 i += 1;
322 continue;
323 }
324 if c == '>' || c == '<' {
325 if byte_pos < colors.len() {
326 colors[byte_pos] = HighlightSpec::with_fg(HighlightRole::Redirection);
327 }
328 if i + 1 < chars.len() && (chars[i + 1] == '>' || chars[i + 1] == '<') {
330 i += 1;
331 let next_byte = line.char_indices().nth(i).map(|(p, _)| p).unwrap_or(0);
332 if next_byte < colors.len() {
333 colors[next_byte] = HighlightSpec::with_fg(HighlightRole::Redirection);
334 }
335 }
336 i += 1;
337 continue;
338 }
339
340 if c.is_whitespace() {
342 if let Some(start) = word_start {
343 let word_end = i;
345 let word: String = chars[start..word_end].iter().collect();
346 colorize_word(
347 &word,
348 start,
349 &mut colors,
350 line,
351 is_first_word || after_pipe_or_semi,
352 );
353 is_first_word = false;
354 after_pipe_or_semi = false;
355 }
356 word_start = None;
357 i += 1;
358 continue;
359 }
360
361 if word_start.is_none() {
363 word_start = Some(i);
364 }
365
366 i += 1;
367 }
368
369 if let Some(start) = word_start {
371 let word: String = chars[start..].iter().collect();
372 colorize_word(
373 &word,
374 start,
375 &mut colors,
376 line,
377 is_first_word || after_pipe_or_semi,
378 );
379 }
380
381 colors
382}
383
384fn colorize_word(
385 word: &str,
386 char_start: usize,
387 colors: &mut [HighlightSpec],
388 line: &str,
389 is_command_position: bool,
390) {
391 let role = if is_command_position {
392 if KEYWORDS.contains(&word) {
393 HighlightRole::Keyword
394 } else if BUILTINS.contains(&word)
395 || command_exists(word)
396 || (word.contains('/') && std::path::Path::new(word).exists())
397 {
398 HighlightRole::Command
399 } else {
400 HighlightRole::Error
401 }
402 } else if word.starts_with('-') {
403 HighlightRole::Option
404 } else if std::path::Path::new(word).exists() {
405 HighlightRole::PathValid
406 } else {
407 HighlightRole::Param
408 };
409
410 for (ci, _) in word.char_indices() {
412 let global_char_idx = char_start + word[..ci].chars().count();
413 if let Some((byte_pos, _)) = line.char_indices().nth(global_char_idx) {
414 if byte_pos < colors.len() {
415 colors[byte_pos] = HighlightSpec::with_fg(role);
416 }
417 }
418 }
419 let last_char_idx = char_start + word.chars().count() - 1;
421 if let Some((byte_pos, _)) = line.char_indices().nth(last_char_idx) {
422 if byte_pos < colors.len() {
423 colors[byte_pos] = HighlightSpec::with_fg(role);
424 }
425 }
426}
427
428fn command_exists(cmd: &str) -> bool {
430 if cmd.is_empty() {
431 return false;
432 }
433 if let Ok(path) = std::env::var("PATH") {
434 for dir in path.split(':') {
435 let full_path = std::path::Path::new(dir).join(cmd);
436 if full_path.is_file() {
437 return true;
438 }
439 }
440 }
441 false
442}
443
444pub fn colorize_line(line: &str, colors: &[HighlightSpec]) -> String {
446 let mut result = String::with_capacity(line.len() * 2);
447 let mut last_role = HighlightRole::Normal;
448
449 for (i, c) in line.chars().enumerate() {
450 let byte_pos = line.char_indices().nth(i).map(|(p, _)| p).unwrap_or(i);
451 let role = colors
452 .get(byte_pos)
453 .map(|s| s.foreground)
454 .unwrap_or(HighlightRole::Normal);
455
456 if role != last_role {
457 result.push_str(role_to_ansi(role));
458 last_role = role;
459 }
460 result.push(c);
461 }
462
463 if last_role != HighlightRole::Normal {
464 result.push_str("\x1b[0m");
465 }
466
467 result
468}
469
470#[derive(Debug, Clone, Copy, PartialEq, Eq)]
476pub enum AbbrPosition {
480 Command, Anywhere, }
485
486#[derive(Debug, Clone)]
488pub struct Abbreviation {
489 pub name: String,
491 pub key: String,
493 pub replacement: String,
495 pub position: AbbrPosition,
497}
498
499impl Abbreviation {
500 pub fn new(name: &str, key: &str, replacement: &str, position: AbbrPosition) -> Self {
502 Self {
503 name: name.to_string(),
504 key: key.to_string(),
505 replacement: replacement.to_string(),
506 position,
507 }
508 }
509 pub fn matches(&self, token: &str, is_command_position: bool) -> bool {
511 let position_ok = match self.position {
512 AbbrPosition::Anywhere => true,
513 AbbrPosition::Command => is_command_position,
514 };
515 position_ok && self.key == token
516 }
517}
518
519static ABBRS: LazyLock<Mutex<AbbreviationSet>> =
521 LazyLock::new(|| Mutex::new(AbbreviationSet::default()));
522pub fn with_abbrs<R>(cb: impl FnOnce(&AbbreviationSet) -> R) -> R {
524 let abbrs = ABBRS.lock().unwrap();
525 cb(&abbrs)
526}
527pub fn with_abbrs_mut<R>(cb: impl FnOnce(&mut AbbreviationSet) -> R) -> R {
529 let mut abbrs = ABBRS.lock().unwrap();
530 cb(&mut abbrs)
531}
532#[derive(Default)]
534pub struct AbbreviationSet {
535 abbrs: Vec<Abbreviation>,
537 used_names: HashSet<String>,
539}
540
541impl AbbreviationSet {
542 pub fn find_match(&self, token: &str, is_command_position: bool) -> Option<&Abbreviation> {
544 self.abbrs
546 .iter()
547 .rev()
548 .find(|a| a.matches(token, is_command_position))
549 }
550
551 pub fn has_match(&self, token: &str, is_command_position: bool) -> bool {
553 self.abbrs
554 .iter()
555 .any(|a| a.matches(token, is_command_position))
556 }
557
558 pub fn add(&mut self, abbr: Abbreviation) {
560 if self.used_names.contains(&abbr.name) {
561 self.abbrs.retain(|a| a.name != abbr.name);
562 }
563 self.used_names.insert(abbr.name.clone());
564 self.abbrs.push(abbr);
565 }
566
567 pub fn remove(&mut self, name: &str) -> bool {
569 if self.used_names.remove(name) {
570 self.abbrs.retain(|a| a.name != name);
571 true
572 } else {
573 false
574 }
575 }
576
577 pub fn list(&self) -> &[Abbreviation] {
579 &self.abbrs
580 }
581}
582
583pub fn expand_abbreviation(line: &str, cursor: usize) -> Option<(String, usize)> {
587 let before_cursor = &line[..cursor.min(line.len())];
589 let word_start = before_cursor
590 .rfind(char::is_whitespace)
591 .map(|i| i + 1)
592 .unwrap_or(0);
593 let word = &before_cursor[word_start..];
594
595 if word.is_empty() {
596 return None;
597 }
598
599 let is_command_position = before_cursor[..word_start].trim().is_empty()
601 || before_cursor[..word_start]
602 .trim()
603 .ends_with(['|', ';', '&']);
604
605 with_abbrs(|set| {
606 set.find_match(word, is_command_position).map(|abbr| {
607 let mut new_line = String::with_capacity(line.len() + abbr.replacement.len());
608 new_line.push_str(&line[..word_start]);
609 new_line.push_str(&abbr.replacement);
610 new_line.push_str(&line[cursor..]);
611 let new_cursor = word_start + abbr.replacement.len();
612 (new_line, new_cursor)
613 })
614 })
615}
616
617pub struct Autosuggestion {
623 pub text: String,
625 pub is_from_history: bool,
627}
628
629impl Autosuggestion {
630 pub fn empty() -> Self {
632 Self {
633 text: String::new(),
634 is_from_history: false,
635 }
636 }
637 pub fn is_empty(&self) -> bool {
639 self.text.is_empty()
640 }
641}
642
643pub fn autosuggest_from_history(line: &str, history: &[String]) -> Autosuggestion {
650 if line.is_empty() {
651 return Autosuggestion::empty();
652 }
653
654 let line_lower = line.to_lowercase();
655
656 for entry in history.iter().rev() {
658 if entry.starts_with(line) && entry.len() > line.len() {
660 return Autosuggestion {
661 text: entry[line.len()..].to_string(),
662 is_from_history: true,
663 };
664 }
665 }
666
667 for entry in history.iter().rev() {
669 let entry_lower = entry.to_lowercase();
670 if entry_lower.starts_with(&line_lower) && entry.len() > line.len() {
671 return Autosuggestion {
672 text: entry[line.len()..].to_string(),
673 is_from_history: true,
674 };
675 }
676 }
677
678 Autosuggestion::empty()
679}
680
681pub fn validate_autosuggestion(suggestion: &str, current_line: &str) -> bool {
683 if suggestion.is_empty() {
684 return false;
685 }
686
687 let full_line = format!("{}{}", current_line, suggestion);
689 let words: Vec<&str> = full_line.split_whitespace().collect();
690
691 if words.is_empty() {
692 return true;
693 }
694
695 let cmd = words[0];
696
697 if !command_exists(cmd) && !BUILTINS.contains(&cmd) && !KEYWORDS.contains(&cmd) {
699 if !cmd.contains('/') || !std::path::Path::new(cmd).exists() {
701 return false;
702 }
703 }
704
705 true
706}
707
708static KILLRING: LazyLock<Mutex<KillRing>> = LazyLock::new(|| Mutex::new(KillRing::new(100)));
713
714pub struct KillRing {
719 entries: Vec<String>,
721 max_size: usize,
723 yank_index: usize,
725}
726
727impl KillRing {
728 pub fn new(max_size: usize) -> Self {
730 Self {
731 entries: Vec::with_capacity(max_size),
732 max_size,
733 yank_index: 0,
734 }
735 }
736
737 pub fn add(&mut self, text: String) {
739 if text.is_empty() {
740 return;
741 }
742 self.entries.retain(|e| e != &text);
744 self.entries.insert(0, text);
745 if self.entries.len() > self.max_size {
746 self.entries.pop();
747 }
748 self.yank_index = 0;
749 }
750
751 pub fn replace(&mut self, text: String) {
753 if text.is_empty() {
754 return;
755 }
756 if self.entries.is_empty() {
757 self.add(text);
758 } else {
759 self.entries[0] = text;
760 }
761 }
762
763 pub fn yank(&self) -> Option<&str> {
765 self.entries.get(self.yank_index).map(|s| s.as_str())
766 }
767
768 pub fn rotate(&mut self) -> Option<&str> {
770 if self.entries.is_empty() {
771 return None;
772 }
773 self.yank_index = (self.yank_index + 1) % self.entries.len();
774 self.yank()
775 }
776
777 pub fn reset_yank(&mut self) {
779 self.yank_index = 0;
780 }
781}
782pub fn kill_add(text: String) {
784 KILLRING.lock().unwrap().add(text);
785}
786pub fn kill_replace(text: String) {
788 KILLRING.lock().unwrap().replace(text);
789}
790pub fn kill_yank() -> Option<String> {
792 KILLRING.lock().unwrap().yank().map(|s| s.to_string())
793}
794pub fn kill_yank_rotate() -> Option<String> {
796 KILLRING.lock().unwrap().rotate().map(|s| s.to_string())
797}
798
799pub fn validate_command(line: &str) -> ValidationStatus {
805 if line.trim().is_empty() {
806 return ValidationStatus::Valid;
807 }
808
809 let mut in_single = false;
811 let mut in_double = false;
812 let mut escaped = false;
813
814 for c in line.chars() {
815 if escaped {
816 escaped = false;
817 continue;
818 }
819 match c {
820 '\\' => escaped = true,
821 '\'' if !in_double => in_single = !in_single,
822 '"' if !in_single => in_double = !in_double,
823 _ => {}
824 }
825 }
826
827 if in_single || in_double {
828 return ValidationStatus::Incomplete;
829 }
830
831 let trimmed = line.trim();
833 if trimmed.ends_with('|') || trimmed.ends_with("&&") || trimmed.ends_with("||") {
834 return ValidationStatus::Incomplete;
835 }
836
837 let mut brace_count = 0i32;
839 let mut bracket_count = 0i32;
840 let mut paren_count = 0i32;
841
842 for c in line.chars() {
843 match c {
844 '{' => brace_count += 1,
845 '}' => brace_count -= 1,
846 '[' => bracket_count += 1,
847 ']' => bracket_count -= 1,
848 '(' => paren_count += 1,
849 ')' => paren_count -= 1,
850 _ => {}
851 }
852 if brace_count < 0 || bracket_count < 0 || paren_count < 0 {
853 return ValidationStatus::Invalid("Unmatched closing bracket".into());
854 }
855 }
856
857 if brace_count > 0 || bracket_count > 0 || paren_count > 0 {
858 return ValidationStatus::Incomplete;
859 }
860
861 ValidationStatus::Valid
862}
863#[derive(Debug, Clone, PartialEq)]
865pub enum ValidationStatus {
866 Valid,
868 Incomplete,
870 Invalid(String),
872}
873
874static PRIVATE_MODE: LazyLock<Mutex<bool>> = LazyLock::new(|| Mutex::new(false));
879pub fn is_private_mode() -> bool {
881 *PRIVATE_MODE.lock().unwrap()
882}
883pub fn set_private_mode(enabled: bool) {
885 *PRIVATE_MODE.lock().unwrap() = enabled;
886}
887
888#[cfg(test)]
889mod tests {
890 use super::*;
891
892 #[test]
893 fn test_highlight_command() {
894 let _g = crate::test_util::global_state_lock();
895 let line = "ls -la /tmp";
896 let colors = highlight_shell(line);
897 assert!(!colors.is_empty());
898 }
899
900 #[test]
901 fn test_abbreviation() {
902 let _g = crate::test_util::global_state_lock();
903 with_abbrs_mut(|set| {
904 set.add(Abbreviation::new("g", "g", "git", AbbrPosition::Command));
905 set.add(Abbreviation::new(
906 "ga",
907 "ga",
908 "git add",
909 AbbrPosition::Command,
910 ));
911 });
912
913 let result = expand_abbreviation("g", 1);
914 assert!(result.is_some());
915 let (new_line, _) = result.unwrap();
916 assert_eq!(new_line, "git");
917 }
918
919 #[test]
920 fn test_autosuggestion() {
921 let _g = crate::test_util::global_state_lock();
922 let history = vec![
923 "ls -la".to_string(),
924 "git status".to_string(),
925 "git commit -m 'test'".to_string(),
926 ];
927
928 let suggestion = autosuggest_from_history("git s", &history);
929 assert!(!suggestion.is_empty());
930 assert_eq!(suggestion.text, "tatus");
931 }
932
933 #[test]
934 fn test_killring() {
935 let _g = crate::test_util::global_state_lock();
936 kill_add("first".to_string());
937 kill_add("second".to_string());
938
939 assert_eq!(kill_yank(), Some("second".to_string()));
940 assert_eq!(kill_yank_rotate(), Some("first".to_string()));
941 }
942
943 #[test]
944 fn test_validation() {
945 let _g = crate::test_util::global_state_lock();
946 assert_eq!(validate_command("echo hello"), ValidationStatus::Valid);
947 assert_eq!(
948 validate_command("echo \"unclosed"),
949 ValidationStatus::Incomplete
950 );
951 assert_eq!(validate_command("ls |"), ValidationStatus::Incomplete);
952 }
953}