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 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    for ch in chars {
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    for ch in chars {
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            && flag.load(Ordering::Relaxed)
255        {
256            eprintln!("Script interrupted by SIGTERM");
257            break;
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 chars = line.chars().peekable();
273        let mut escaped = false;
274
275        for ch in chars {
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 {
331                let is_group_start = {
332                    let trimmed = line.trim();
333                    trimmed == "{" || trimmed.starts_with("{ ") || trimmed.starts_with("{\t")
334                };
335                if is_group_start {
336                    in_group_block = true;
337                    brace_depth += line.matches('{').count() as i32;
338                    brace_depth -= line.matches('}').count() as i32;
339                }
340            }
341        }
342
343        if keywords_active
344            && (line.contains("() {") || (trimmed.ends_with("()") && !in_function_block))
345        {
346            in_function_block = true;
347            brace_depth += line.matches('{').count() as i32;
348            brace_depth -= line.matches('}').count() as i32;
349        } else if in_function_block || in_group_block {
350            brace_depth += line.matches('{').count() as i32;
351            brace_depth -= line.matches('}').count() as i32;
352        }
353
354        if !current_block.is_empty() {
355            current_block.push('\n');
356        }
357        current_block.push_str(line);
358
359        if keywords_active {
360            if (in_function_block || in_group_block) && brace_depth == 0 {
361                in_function_block = false;
362                in_group_block = false;
363                execute_line(&current_block, shell_state);
364                current_block.clear();
365
366                if shell_state.exit_requested {
367                    break;
368                }
369            } else if in_if_block && contains_keyword(line, "fi") {
370                if_depth -= 1;
371                if if_depth == 0 {
372                    in_if_block = false;
373                    // Only execute if we're not inside a loop or other block
374                    if !in_for_block
375                        && !in_while_block
376                        && !in_until_block
377                        && !in_function_block
378                        && !in_group_block
379                        && !in_case_block
380                    {
381                        execute_line(&current_block, shell_state);
382                        current_block.clear();
383
384                        if shell_state.exit_requested {
385                            break;
386                        }
387                    }
388                }
389            } else if in_for_block && contains_keyword(line, "done") {
390                for_depth -= 1;
391                if for_depth == 0 {
392                    in_for_block = false;
393                    execute_line(&current_block, shell_state);
394                    current_block.clear();
395
396                    if shell_state.exit_requested {
397                        break;
398                    }
399                }
400            } else if in_while_block && contains_keyword(line, "done") {
401                while_depth -= 1;
402                if while_depth == 0 {
403                    in_while_block = false;
404                    execute_line(&current_block, shell_state);
405                    current_block.clear();
406
407                    if shell_state.exit_requested {
408                        break;
409                    }
410                }
411            } else if in_until_block && contains_keyword(line, "done") {
412                until_depth -= 1;
413                if until_depth == 0 {
414                    in_until_block = false;
415                    execute_line(&current_block, shell_state);
416                    current_block.clear();
417
418                    if shell_state.exit_requested {
419                        break;
420                    }
421                }
422            } else if in_case_block && contains_keyword(line, "esac") {
423                in_case_block = false;
424                execute_line(&current_block, shell_state);
425                current_block.clear();
426
427                if shell_state.exit_requested {
428                    break;
429                }
430            } else if !in_if_block
431                && !in_case_block
432                && !in_function_block
433                && !in_group_block
434                && !in_for_block
435                && !in_while_block
436                && !in_until_block
437            {
438                if let Some(delimiter) = line_contains_heredoc(&current_block, shell_state) {
439                    i += 1;
440                    let mut heredoc_content = String::new();
441                    while i < lines.len() {
442                        let content_line = lines[i];
443                        if content_line.trim() == delimiter.trim() {
444                            break;
445                        }
446                        if !heredoc_content.is_empty() {
447                            heredoc_content.push('\n');
448                        }
449                        heredoc_content.push_str(content_line);
450                        i += 1;
451                    }
452                    shell_state.pending_heredoc_content = Some(heredoc_content);
453                    execute_line(&current_block, shell_state);
454                    current_block.clear();
455                } else if !in_single_quote
456                    && !in_double_quote
457                    && (line.ends_with(';') || !line.trim_end().ends_with('\\'))
458                {
459                    execute_line(&current_block, shell_state);
460                    current_block.clear();
461                }
462            }
463        }
464        i += 1;
465    }
466}