1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum TokenType {
10 Word,
11 Operator,
12 Redirect,
13 Newline,
14 Comment,
15 Eof,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Token {
21 pub token_type: TokenType,
22 pub text: String,
23}
24
25impl Token {
26 fn new(token_type: TokenType, text: impl Into<String>) -> Self {
27 Token {
28 token_type,
29 text: text.into(),
30 }
31 }
32}
33
34const OPERATORS: [&str; 7] = ["&&", "||", ";;", ";", "|", "(", ")"];
35const WORD_TERMINATORS: [char; 10] = [' ', '\t', '\n', ';', '|', '&', '(', ')', '<', '>'];
36
37fn read_balanced(source: &[char], start: usize, open: char, close: char) -> usize {
40 let mut depth = 0usize;
41 let mut index = start;
42 while index < source.len() {
43 let character = source[index];
44 if character == '\\' {
45 index += 2;
46 continue;
47 }
48 if character == open {
49 depth += 1;
50 } else if character == close {
51 depth -= 1;
52 if depth == 0 {
53 return index + 1;
54 }
55 }
56 index += 1;
57 }
58 source.len()
59}
60
61fn read_quoted(source: &[char], start: usize, quote: char) -> usize {
63 let mut index = start + 1;
64 while index < source.len() {
65 let character = source[index];
66 if quote == '"' && character == '\\' {
67 index += 2;
68 continue;
69 }
70 if character == quote {
71 return index + 1;
72 }
73 index += 1;
74 }
75 source.len()
76}
77
78fn read_word(source: &[char], start: usize) -> (String, usize) {
81 let mut index = start;
82 while index < source.len() {
83 let character = source[index];
84 if character == '\\' {
85 index += 2;
86 continue;
87 }
88 if character == '\'' || character == '"' {
89 index = read_quoted(source, index, character);
90 continue;
91 }
92 if character == '`' {
93 index = read_quoted(source, index, '`');
94 continue;
95 }
96 if character == '$' && source.get(index + 1) == Some(&'(') {
97 index = read_balanced(source, index + 1, '(', ')');
98 continue;
99 }
100 if character == '$' && source.get(index + 1) == Some(&'{') {
101 index = read_balanced(source, index + 1, '{', '}');
102 continue;
103 }
104 if WORD_TERMINATORS.contains(&character) {
105 break;
106 }
107 index += 1;
108 }
109 let end = index.min(source.len());
110 (source[start..end].iter().collect(), end)
111}
112
113fn match_redirect(source: &[char], index: usize) -> Option<String> {
116 let mut cursor = index;
117 while source.get(cursor).is_some_and(char::is_ascii_digit) {
118 cursor += 1;
119 }
120 for operator in [">>", ">&", "<<<", "<<", ">", "<"] {
121 if starts_with(source, cursor, operator) {
122 return Some(
123 source[index..cursor + operator.chars().count()]
124 .iter()
125 .collect(),
126 );
127 }
128 }
129 None
130}
131
132fn starts_with(source: &[char], index: usize, text: &str) -> bool {
133 text.chars()
134 .enumerate()
135 .all(|(offset, expected)| source.get(index + offset) == Some(&expected))
136}
137
138fn match_operator(source: &[char], index: usize) -> Option<&'static str> {
139 OPERATORS
140 .into_iter()
141 .find(|operator| starts_with(source, index, operator))
142}
143
144fn match_trivia(source: &[char], index: usize) -> Option<(Option<Token>, usize)> {
149 let character = source[index];
150
151 if character == '\n' {
152 return Some((Some(Token::new(TokenType::Newline, "\n")), index + 1));
153 }
154 if character == ' ' || character == '\t' || character == '\r' {
155 return Some((None, index + 1));
156 }
157 if character == '\\' && source.get(index + 1) == Some(&'\n') {
159 return Some((None, index + 2));
160 }
161 let after_separator = index == 0
163 || source[index - 1].is_whitespace()
164 || [';', '|', '&', '(', ')'].contains(&source[index - 1]);
165 if character == '#' && after_separator {
166 let end = source[index..]
167 .iter()
168 .position(|&c| c == '\n')
169 .map_or(source.len(), |offset| index + offset);
170 let text: String = source[index..end].iter().collect();
171 return Some((Some(Token::new(TokenType::Comment, text)), end));
172 }
173 None
174}
175
176pub fn tokenize(source: &str) -> Vec<Token> {
178 let source: Vec<char> = source.chars().collect();
179 let mut tokens = Vec::new();
180 let mut index = 0usize;
181
182 while index < source.len() {
183 if let Some((token, next)) = match_trivia(&source, index) {
184 if let Some(token) = token {
185 tokens.push(token);
186 }
187 index = next;
188 continue;
189 }
190
191 if let Some(redirect) = match_redirect(&source, index) {
192 index += redirect.chars().count();
193 tokens.push(Token::new(TokenType::Redirect, redirect));
194 continue;
195 }
196 let operator = match_operator(&source, index).or({
198 if source[index] == '&' {
199 Some("&")
200 } else {
201 None
202 }
203 });
204 if let Some(operator) = operator {
205 tokens.push(Token::new(TokenType::Operator, operator));
206 index += operator.chars().count();
207 continue;
208 }
209
210 let (text, next) = read_word(&source, index);
211 if text.is_empty() {
212 index += 1;
213 continue;
214 }
215 tokens.push(Token::new(TokenType::Word, text));
216 index = next;
217 }
218
219 tokens.push(Token::new(TokenType::Eof, ""));
220 tokens
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 fn kinds(source: &str) -> Vec<(TokenType, String)> {
228 tokenize(source)
229 .into_iter()
230 .map(|token| (token.token_type, token.text))
231 .collect()
232 }
233
234 #[test]
235 fn tokenizes_words_and_operators() {
236 assert_eq!(
237 kinds("ls -la | grep x"),
238 vec![
239 (TokenType::Word, "ls".to_string()),
240 (TokenType::Word, "-la".to_string()),
241 (TokenType::Operator, "|".to_string()),
242 (TokenType::Word, "grep".to_string()),
243 (TokenType::Word, "x".to_string()),
244 (TokenType::Eof, String::new()),
245 ]
246 );
247 }
248
249 #[test]
250 fn keeps_substitutions_and_quotes_in_one_word() {
251 assert_eq!(
252 kinds("echo \"$(ls | wc -l) items\""),
253 vec![
254 (TokenType::Word, "echo".to_string()),
255 (TokenType::Word, "\"$(ls | wc -l) items\"".to_string()),
256 (TokenType::Eof, String::new()),
257 ]
258 );
259 }
260
261 #[test]
262 fn reads_a_file_descriptor_prefixed_redirect() {
263 assert_eq!(
264 kinds("ls >out.txt 2>&1"),
265 vec![
266 (TokenType::Word, "ls".to_string()),
267 (TokenType::Redirect, ">".to_string()),
268 (TokenType::Word, "out.txt".to_string()),
269 (TokenType::Redirect, "2>&".to_string()),
270 (TokenType::Word, "1".to_string()),
271 (TokenType::Eof, String::new()),
272 ]
273 );
274 }
275
276 #[test]
277 fn emits_comments_and_newlines() {
278 assert_eq!(
279 kinds("# note\nls"),
280 vec![
281 (TokenType::Comment, "# note".to_string()),
282 (TokenType::Newline, "\n".to_string()),
283 (TokenType::Word, "ls".to_string()),
284 (TokenType::Eof, String::new()),
285 ]
286 );
287 }
288}