Rust · 7005 bytes Raw Blame History
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, &current_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, &current_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, &current_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, &current_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