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    // Reset line number for script execution
227    shell_state.current_line_number = 1;
228    
229    let mut current_block = String::new();
230    let mut in_if_block = false;
231    let mut if_depth = 0;
232    let mut in_case_block = false;
233    let mut in_function_block = false;
234    let mut in_group_block = false;
235    let mut brace_depth = 0;
236    let mut in_for_block = false;
237    let mut for_depth = 0;
238    let mut in_while_block = false;
239    let mut while_depth = 0;
240    let mut in_until_block = false;
241    let mut until_depth = 0;
242
243    // Track quote state across lines to handle multiline strings correctly
244    let mut in_double_quote = false;
245    let mut in_single_quote = false;
246
247    let lines: Vec<&str> = content.lines().collect();
248    let mut i = 0;
249
250    while i < lines.len() {
251        let line = lines[i];
252        
253        // Update current line number for $LINENO - must be before any continue statements
254        shell_state.current_line_number = i + 1;
255        
256        // Process pending signals at the start of each line
257        state::process_pending_signals(shell_state);
258
259        // Check for shutdown signal
260        if let Some(flag) = shutdown_flag
261            && flag.load(Ordering::Relaxed)
262        {
263            eprintln!("Script interrupted by SIGTERM");
264            break;
265        }
266
267        // Check if exit was requested (e.g., from trap handler)
268        if shell_state.exit_requested {
269            break;
270        }
271
272        // Skip shebang lines
273        if line.starts_with("#!") {
274            i += 1;
275            continue;
276        }
277
278        // Update quote state based on this line
279        let chars = line.chars().peekable();
280        let mut escaped = false;
281
282        for ch in chars {
283            if escaped {
284                escaped = false;
285                continue;
286            }
287
288            if in_single_quote {
289                if ch == '\'' {
290                    in_single_quote = false;
291                }
292                continue;
293            }
294
295            if in_double_quote {
296                if ch == '"' {
297                    in_double_quote = false;
298                } else if ch == '\\' {
299                    escaped = true;
300                }
301                continue;
302            }
303
304            match ch {
305                '#' => break, // Comment starts
306                '\'' => in_single_quote = true,
307                '"' => in_double_quote = true,
308                '\\' => escaped = true,
309                _ => {}
310            }
311        }
312
313        let trimmed = line.trim();
314        if !in_double_quote && !in_single_quote && (trimmed.is_empty() || trimmed.starts_with("#"))
315        {
316            i += 1;
317            continue;
318        }
319
320        let keywords_active = !in_double_quote && !in_single_quote;
321
322        if keywords_active && !in_function_block {
323            if starts_with_keyword(line, "if") {
324                in_if_block = true;
325                if_depth += 1;
326            } else if starts_with_keyword(line, "case") {
327                in_case_block = true;
328            } else if starts_with_keyword(line, "for") {
329                in_for_block = true;
330                for_depth += 1;
331            } else if starts_with_keyword(line, "while") {
332                in_while_block = true;
333                while_depth += 1;
334            } else if starts_with_keyword(line, "until") {
335                in_until_block = true;
336                until_depth += 1;
337            } else {
338                let is_group_start = {
339                    let trimmed = line.trim();
340                    trimmed == "{" || trimmed.starts_with("{ ") || trimmed.starts_with("{\t")
341                };
342                if is_group_start {
343                    in_group_block = true;
344                    brace_depth += line.matches('{').count() as i32;
345                    brace_depth -= line.matches('}').count() as i32;
346                }
347            }
348        }
349
350        if keywords_active
351            && (line.contains("() {") || (trimmed.ends_with("()") && !in_function_block))
352        {
353            in_function_block = true;
354            brace_depth += line.matches('{').count() as i32;
355            brace_depth -= line.matches('}').count() as i32;
356        } else if in_function_block || in_group_block {
357            brace_depth += line.matches('{').count() as i32;
358            brace_depth -= line.matches('}').count() as i32;
359        }
360
361        if !current_block.is_empty() {
362            current_block.push('\n');
363        }
364        current_block.push_str(line);
365
366        if keywords_active {
367            if (in_function_block || in_group_block) && brace_depth == 0 {
368                in_function_block = false;
369                in_group_block = false;
370                execute_line(&current_block, shell_state);
371                current_block.clear();
372
373                if shell_state.exit_requested {
374                    break;
375                }
376            } else if in_if_block && contains_keyword(line, "fi") {
377                if_depth -= 1;
378                if if_depth == 0 {
379                    in_if_block = false;
380                    // Only execute if we're not inside a loop or other block
381                    if !in_for_block
382                        && !in_while_block
383                        && !in_until_block
384                        && !in_function_block
385                        && !in_group_block
386                        && !in_case_block
387                    {
388                        execute_line(&current_block, shell_state);
389                        current_block.clear();
390
391                        if shell_state.exit_requested {
392                            break;
393                        }
394                    }
395                }
396            } else if in_for_block && contains_keyword(line, "done") {
397                for_depth -= 1;
398                if for_depth == 0 {
399                    in_for_block = false;
400                    execute_line(&current_block, shell_state);
401                    current_block.clear();
402
403                    if shell_state.exit_requested {
404                        break;
405                    }
406                }
407            } else if in_while_block && contains_keyword(line, "done") {
408                while_depth -= 1;
409                if while_depth == 0 {
410                    in_while_block = false;
411                    execute_line(&current_block, shell_state);
412                    current_block.clear();
413
414                    if shell_state.exit_requested {
415                        break;
416                    }
417                }
418            } else if in_until_block && contains_keyword(line, "done") {
419                until_depth -= 1;
420                if until_depth == 0 {
421                    in_until_block = false;
422                    execute_line(&current_block, shell_state);
423                    current_block.clear();
424
425                    if shell_state.exit_requested {
426                        break;
427                    }
428                }
429            } else if in_case_block && contains_keyword(line, "esac") {
430                in_case_block = false;
431                execute_line(&current_block, shell_state);
432                current_block.clear();
433
434                if shell_state.exit_requested {
435                    break;
436                }
437            } else if !in_if_block
438                && !in_case_block
439                && !in_function_block
440                && !in_group_block
441                && !in_for_block
442                && !in_while_block
443                && !in_until_block
444            {
445                if let Some(delimiter) = line_contains_heredoc(&current_block, shell_state) {
446                    i += 1;
447                    let mut heredoc_content = String::new();
448                    while i < lines.len() {
449                        let content_line = lines[i];
450                        if content_line.trim() == delimiter.trim() {
451                            break;
452                        }
453                        if !heredoc_content.is_empty() {
454                            heredoc_content.push('\n');
455                        }
456                        heredoc_content.push_str(content_line);
457                        i += 1;
458                    }
459                    shell_state.pending_heredoc_content = Some(heredoc_content);
460                    execute_line(&current_block, shell_state);
461                    current_block.clear();
462                } else if !in_single_quote
463                    && !in_double_quote
464                    && (line.ends_with(';') || !line.trim_end().ends_with('\\'))
465                {
466                    execute_line(&current_block, shell_state);
467                    current_block.clear();
468                }
469            }
470        }
471        i += 1;
472    }
473}