rush_sh/
script_engine.rs

1use crate::brace_expansion;
2use crate::executor;
3use crate::lexer;
4use crate::parser;
5use crate::state;
6use std::collections::HashSet;
7use std::sync::atomic::{AtomicBool, Ordering};
8
9/// Check if a line contains a heredoc redirection using proper lexer-based detection
10/// Returns the delimiter if found, None otherwise
11pub fn line_contains_heredoc(line: &str, shell_state: &state::ShellState) -> Option<String> {
12    // Use the lexer to properly parse the line
13    match lexer::lex(line, shell_state) {
14        Ok(tokens) => {
15            // Look for a RedirHereDoc token
16            for token in tokens {
17                if let lexer::Token::RedirHereDoc(delimiter, _quoted) = token {
18                    return Some(delimiter);
19                }
20            }
21            None
22        }
23        Err(_) => None,
24    }
25}
26
27/// Check if a line contains a specific keyword as a distinct token
28/// This handles comments and ensures the keyword is not part of another word
29pub fn contains_keyword(line: &str, keyword: &str) -> bool {
30    let mut chars = line.chars().peekable();
31    let mut in_single_quote = false;
32    let mut in_double_quote = false;
33    let mut escaped = false;
34    let mut current_word = String::new();
35
36    while let Some(ch) = chars.next() {
37        if escaped {
38            escaped = false;
39            // Escaped characters are treated as part of the word
40            current_word.push(ch);
41            continue;
42        }
43
44        if in_single_quote {
45            if ch == '\'' {
46                in_single_quote = false;
47            } else {
48                current_word.push(ch);
49            }
50            continue;
51        }
52
53        if in_double_quote {
54            if ch == '"' {
55                in_double_quote = false;
56            } else if ch == '\\' {
57                escaped = true;
58            } else {
59                current_word.push(ch);
60            }
61            continue;
62        }
63
64        match ch {
65            '#' => {
66                if current_word.is_empty() {
67                    return false; // Comment starts at word boundary
68                }
69                current_word.push(ch); // # inside word, treat as literal
70            }
71            '\'' => {
72                in_single_quote = true;
73                current_word.push(ch);
74            }
75            '"' => {
76                in_double_quote = true;
77                current_word.push(ch);
78            }
79            '\\' => escaped = true,
80            ' ' | '\t' | '\n' | ';' | '|' | '&' | '(' | ')' | '{' | '}' => {
81                if current_word == keyword {
82                    return true;
83                }
84                current_word.clear();
85            }
86            _ => current_word.push(ch),
87        }
88    }
89
90    // Check last word
91    current_word == keyword
92}
93
94/// Determine whether the first token of a line equals the given keyword, ignoring leading spaces and tabs.
95///
96/// Returns `true` if the first token is equal to `keyword`, `false` otherwise.
97///
98/// # Examples
99///
100/// ```
101/// use rush_sh::script_engine::starts_with_keyword;
102/// assert!(starts_with_keyword("  if condition", "if"));
103/// assert!(!starts_with_keyword("echo if", "if"));
104/// ```
105pub fn starts_with_keyword(line: &str, keyword: &str) -> bool {
106    let mut chars = line.chars().peekable();
107    let mut current_word = String::new();
108
109    // Skip leading whitespace
110    while let Some(&ch) = chars.peek() {
111        if ch == ' ' || ch == '\t' {
112            chars.next();
113        } else {
114            break;
115        }
116    }
117
118    while let Some(ch) = chars.next() {
119        match ch {
120            ' ' | '\t' | '\n' | ';' | '|' | '&' | '(' | ')' | '{' | '}' => {
121                return current_word == keyword;
122            }
123            _ => current_word.push(ch),
124        }
125    }
126
127    current_word == keyword
128}
129
130/// Process and execute a single shell command line.
131///
132/// This performs lexical analysis, alias expansion, brace expansion, parsing, and execution
133/// in sequence; prints errors (using the configured color scheme when enabled) and updates
134/// the shell state (including the last exit code and, on certain lex errors, exit request).
135///
136/// # Parameters
137///
138/// - `line`: the input command line to process.
139/// - `shell_state`: mutable shell state used for options (e.g., verbose, colors), color output,
140///   and to store execution results such as the last exit code and exit-request flag.
141///
142/// # Examples
143///
144/// ```ignore
145/// // Example usage (requires a configured ShellState):
146/// let mut shell_state = state::ShellState::new();
147/// execute_line("echo hello", &mut shell_state);
148/// assert_eq!(shell_state.last_exit_code(), 0);
149/// ```
150pub fn execute_line(line: &str, shell_state: &mut state::ShellState) {
151    // Print input line if verbose option (-v) is enabled
152    if shell_state.options.verbose {
153        if shell_state.colors_enabled {
154            eprintln!("{}{}\x1b[0m", shell_state.color_scheme.builtin, line);
155        } else {
156            eprintln!("{}", line);
157        }
158    }
159    
160    match lexer::lex(line, shell_state) {
161        Ok(tokens) => match lexer::expand_aliases(tokens, shell_state, &mut HashSet::new()) {
162            Ok(expanded_tokens) => match brace_expansion::expand_braces(expanded_tokens) {
163                Ok(brace_expanded_tokens) => match parser::parse(brace_expanded_tokens) {
164                    Ok(ast) => {
165                        let exit_code = executor::execute(ast, shell_state);
166                        shell_state.set_last_exit_code(exit_code);
167                    }
168                    Err(e) => {
169                        if shell_state.colors_enabled {
170                            eprintln!(
171                                "{}Parse error: {}\x1b[0m",
172                                shell_state.color_scheme.error, e
173                            );
174                        } else {
175                            eprintln!("Parse error: {}", e);
176                        }
177                        shell_state.set_last_exit_code(1);
178                    }
179                },
180                Err(e) => {
181                    if shell_state.colors_enabled {
182                        eprintln!(
183                            "{}Brace expansion error: {}\x1b[0m",
184                            shell_state.color_scheme.error, e
185                        );
186                    } else {
187                        eprintln!("Brace expansion error: {}", e);
188                    }
189                    shell_state.set_last_exit_code(1);
190                }
191            },
192            Err(e) => {
193                if shell_state.colors_enabled {
194                    eprintln!(
195                        "{}Alias expansion error: {}\x1b[0m",
196                        shell_state.color_scheme.error, e
197                    );
198                } else {
199                    eprintln!("Alias expansion error: {}", e);
200                }
201                shell_state.set_last_exit_code(1);
202            }
203        },
204        Err(e) => {
205            if shell_state.colors_enabled {
206                eprintln!("{}Lex error: {}\x1b[0m", shell_state.color_scheme.error, e);
207            } else {
208                eprintln!("Lex error: {}", e);
209            }
210            shell_state.set_last_exit_code(1);
211            
212            // Check if this is a nounset error - if so, request shell exit
213            if e.contains("unbound variable") {
214                shell_state.exit_requested = true;
215                shell_state.exit_code = 1;
216            }
217        }
218    }
219}
220
221pub fn execute_script(
222    content: &str,
223    shell_state: &mut state::ShellState,
224    shutdown_flag: Option<&AtomicBool>,
225) {
226    let mut current_block = String::new();
227    let mut in_if_block = false;
228    let mut if_depth = 0;
229    let mut in_case_block = false;
230    let mut in_function_block = false;
231    let mut in_group_block = false;
232    let mut brace_depth = 0;
233    let mut in_for_block = false;
234    let mut for_depth = 0;
235    let mut in_while_block = false;
236    let mut while_depth = 0;
237    let mut in_until_block = false;
238    let mut until_depth = 0;
239
240    // Track quote state across lines to handle multiline strings correctly
241    let mut in_double_quote = false;
242    let mut in_single_quote = false;
243
244    let lines: Vec<&str> = content.lines().collect();
245    let mut i = 0;
246
247    while i < lines.len() {
248        let line = lines[i];
249        // Process pending signals at the start of each line
250        state::process_pending_signals(shell_state);
251
252        // Check for shutdown signal
253        if let Some(flag) = shutdown_flag {
254            if flag.load(Ordering::Relaxed) {
255                eprintln!("Script interrupted by SIGTERM");
256                break;
257            }
258        }
259
260        // Check if exit was requested (e.g., from trap handler)
261        if shell_state.exit_requested {
262            break;
263        }
264
265        // Skip shebang lines
266        if line.starts_with("#!") {
267            i += 1;
268            continue;
269        }
270
271        // Update quote state based on this line
272        let mut chars = line.chars().peekable();
273        let mut escaped = false;
274
275        while let Some(ch) = chars.next() {
276            if escaped {
277                escaped = false;
278                continue;
279            }
280
281            if in_single_quote {
282                if ch == '\'' {
283                    in_single_quote = false;
284                }
285                continue;
286            }
287
288            if in_double_quote {
289                if ch == '"' {
290                    in_double_quote = false;
291                } else if ch == '\\' {
292                    escaped = true;
293                }
294                continue;
295            }
296
297            match ch {
298                '#' => break, // Comment starts
299                '\'' => in_single_quote = true,
300                '"' => in_double_quote = true,
301                '\\' => escaped = true,
302                _ => {}
303            }
304        }
305
306        let trimmed = line.trim();
307        if !in_double_quote && !in_single_quote && (trimmed.is_empty() || trimmed.starts_with("#"))
308        {
309            i += 1;
310            continue;
311        }
312
313        let keywords_active = !in_double_quote && !in_single_quote;
314
315        if keywords_active && !in_function_block {
316            if starts_with_keyword(line, "if") {
317                in_if_block = true;
318                if_depth += 1;
319            } else if starts_with_keyword(line, "case") {
320                in_case_block = true;
321            } else if starts_with_keyword(line, "for") {
322                in_for_block = true;
323                for_depth += 1;
324            } else if starts_with_keyword(line, "while") {
325                in_while_block = true;
326                while_depth += 1;
327            } else if starts_with_keyword(line, "until") {
328                in_until_block = true;
329                until_depth += 1;
330            } else if {
331                let trimmed = line.trim();
332                trimmed == "{" || trimmed.starts_with("{ ") || trimmed.starts_with("{\t")
333            } {
334                in_group_block = true;
335                brace_depth += line.matches('{').count() as i32;
336                brace_depth -= line.matches('}').count() as i32;
337            }
338        }
339
340        if keywords_active
341            && (line.contains("() {") || (trimmed.ends_with("()") && !in_function_block))
342        {
343            in_function_block = true;
344            brace_depth += line.matches('{').count() as i32;
345            brace_depth -= line.matches('}').count() as i32;
346        } else if in_function_block || in_group_block {
347            brace_depth += line.matches('{').count() as i32;
348            brace_depth -= line.matches('}').count() as i32;
349        }
350
351        if !current_block.is_empty() {
352            current_block.push('\n');
353        }
354        current_block.push_str(line);
355
356        if keywords_active {
357            if (in_function_block || in_group_block) && brace_depth == 0 {
358                in_function_block = false;
359                in_group_block = false;
360                execute_line(&current_block, shell_state);
361                current_block.clear();
362
363                if shell_state.exit_requested {
364                    break;
365                }
366            } else if in_if_block && contains_keyword(line, "fi") {
367                if_depth -= 1;
368                if if_depth == 0 {
369                    in_if_block = false;
370                    // Only execute if we're not inside a loop or other block
371                    if !in_for_block && !in_while_block && !in_until_block && !in_function_block && !in_group_block && !in_case_block {
372                        execute_line(&current_block, shell_state);
373                        current_block.clear();
374
375                        if shell_state.exit_requested {
376                            break;
377                        }
378                    }
379                }
380            } else if in_for_block && contains_keyword(line, "done") {
381                for_depth -= 1;
382                if for_depth == 0 {
383                    in_for_block = false;
384                    execute_line(&current_block, shell_state);
385                    current_block.clear();
386
387                    if shell_state.exit_requested {
388                        break;
389                    }
390                }
391            } else if in_while_block && contains_keyword(line, "done") {
392                while_depth -= 1;
393                if while_depth == 0 {
394                    in_while_block = false;
395                    execute_line(&current_block, shell_state);
396                    current_block.clear();
397
398                    if shell_state.exit_requested {
399                        break;
400                    }
401                }
402            } else if in_until_block && contains_keyword(line, "done") {
403                until_depth -= 1;
404                if until_depth == 0 {
405                    in_until_block = false;
406                    execute_line(&current_block, shell_state);
407                    current_block.clear();
408
409                    if shell_state.exit_requested {
410                        break;
411                    }
412                }
413            } else if in_case_block && contains_keyword(line, "esac") {
414                in_case_block = false;
415                execute_line(&current_block, shell_state);
416                current_block.clear();
417
418                if shell_state.exit_requested {
419                    break;
420                }
421            } else if !in_if_block
422                && !in_case_block
423                && !in_function_block
424                && !in_group_block
425                && !in_for_block
426                && !in_while_block
427                && !in_until_block
428            {
429                if let Some(delimiter) = line_contains_heredoc(&current_block, shell_state) {
430                    i += 1;
431                    let mut heredoc_content = String::new();
432                    while i < lines.len() {
433                        let content_line = lines[i];
434                        if content_line.trim() == delimiter.trim() {
435                            break;
436                        }
437                        if !heredoc_content.is_empty() {
438                            heredoc_content.push('\n');
439                        }
440                        heredoc_content.push_str(content_line);
441                        i += 1;
442                    }
443                    shell_state.pending_heredoc_content = Some(heredoc_content);
444                    execute_line(&current_block, shell_state);
445                    current_block.clear();
446                } else if !in_single_quote
447                    && !in_double_quote
448                    && (line.ends_with(';') || !line.trim_end().ends_with('\\'))
449                {
450                    execute_line(&current_block, shell_state);
451                    current_block.clear();
452                }
453            }
454        }
455        i += 1;
456    }
457}