Skip to main content

shuck_ast/
command_resolution.rs

1use std::borrow::Cow;
2
3use crate::{
4    Assignment, BuiltinCommand, Command, CompoundCommand, DeclClause, DeclOperand, Position,
5    Redirect, SimpleCommand, Span, StaticCommandWrapperTarget, Word, static_command_name_text,
6    static_command_wrapper_target_index, static_word_text,
7};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum WrapperKind {
11    Noglob,
12    Command,
13    Builtin,
14    Exec,
15    Busybox,
16    FindExec,
17    FindExecDir,
18    SudoFamily,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum DeclarationKind {
23    Export,
24    Local,
25    Declare,
26    Typeset,
27    Other(String),
28}
29
30impl DeclarationKind {
31    pub fn as_str(&self) -> &str {
32        match self {
33            Self::Export => "export",
34            Self::Local => "local",
35            Self::Declare => "declare",
36            Self::Typeset => "typeset",
37            Self::Other(name) => name.as_str(),
38        }
39    }
40}
41
42#[derive(Debug, Clone)]
43pub struct NormalizedDeclaration<'a> {
44    pub kind: DeclarationKind,
45    pub readonly_flag: bool,
46    pub span: Span,
47    pub head_span: Span,
48    pub redirects: &'a [Redirect],
49    pub assignments: &'a [Assignment],
50    pub operands: &'a [DeclOperand],
51    pub assignment_operands: Vec<&'a Assignment>,
52}
53
54#[derive(Debug, Clone)]
55pub struct NormalizedCommand<'a> {
56    pub literal_name: Option<Cow<'a, str>>,
57    pub effective_name: Option<Cow<'a, str>>,
58    pub wrappers: Vec<WrapperKind>,
59    pub body_span: Span,
60    pub body_word_span: Option<Span>,
61    pub body_words: Vec<&'a Word>,
62    pub declaration: Option<NormalizedDeclaration<'a>>,
63}
64
65impl<'a> NormalizedCommand<'a> {
66    pub fn effective_or_literal_name(&self) -> Option<&str> {
67        self.effective_name
68            .as_deref()
69            .or(self.literal_name.as_deref())
70    }
71
72    pub fn effective_name_is(&self, name: &str) -> bool {
73        self.effective_name.as_deref() == Some(name)
74    }
75
76    pub fn effective_basename_is(&self, name: &str) -> bool {
77        self.effective_name
78            .as_deref()
79            .is_some_and(|effective_name| command_basename(effective_name) == name)
80    }
81
82    pub fn has_wrapper(&self, wrapper: WrapperKind) -> bool {
83        self.wrappers.contains(&wrapper)
84    }
85
86    pub fn body_name_word(&self) -> Option<&'a Word> {
87        self.body_words.first().copied()
88    }
89
90    pub fn body_word_span(&self) -> Option<Span> {
91        self.body_word_span
92    }
93
94    pub fn body_args(&self) -> &[&'a Word] {
95        self.body_words.split_first().map_or(&[], |(_, rest)| rest)
96    }
97}
98
99pub fn normalize_command<'a>(command: &'a Command, source: &'a str) -> NormalizedCommand<'a> {
100    match command {
101        Command::Simple(command) => normalize_simple_command(command, source),
102        Command::Decl(command) => normalize_decl_command(command, source),
103        Command::Builtin(command) => NormalizedCommand {
104            literal_name: Some(Cow::Borrowed(builtin_name(command))),
105            effective_name: Some(Cow::Borrowed(builtin_name(command))),
106            wrappers: Vec::new(),
107            body_span: builtin_span(command),
108            body_word_span: None,
109            body_words: Vec::new(),
110            declaration: None,
111        },
112        Command::Binary(command) => empty_normalized_command(command.span),
113        Command::Compound(command) => empty_normalized_command(compound_span(command)),
114        Command::Function(command) => empty_normalized_command(command.span),
115        Command::AnonymousFunction(command) => empty_normalized_command(command.span),
116    }
117}
118
119fn normalize_simple_command<'a>(
120    command: &'a SimpleCommand,
121    source: &'a str,
122) -> NormalizedCommand<'a> {
123    let words = std::iter::once(&command.name)
124        .chain(command.args.iter())
125        .collect::<Vec<_>>();
126    let Some(normalized) = normalize_command_words(words.as_slice(), source) else {
127        unreachable!("simple commands always have a name");
128    };
129    normalized
130}
131
132pub fn normalize_command_words<'a>(
133    words: &[&'a Word],
134    source: &'a str,
135) -> Option<NormalizedCommand<'a>> {
136    let first_word = words.first().copied()?;
137    let literal_name = static_command_name_text(first_word, source);
138    let mut effective_name = literal_name.clone();
139    let mut wrappers = Vec::new();
140    let mut body_span = first_word.span;
141    let mut body_word_span = Some(first_word.span);
142    let mut body_start = literal_name.as_ref().map(|_| 0usize);
143    let mut current_index = 0usize;
144
145    while let Some(current_name) = effective_name.as_deref() {
146        let Some(resolution) =
147            resolve_command_resolution(words, current_index, current_name, source)
148        else {
149            break;
150        };
151
152        match resolution {
153            CommandResolution::Alias {
154                effective_name: resolved_name,
155                body_index,
156            } => {
157                body_span = words[body_index].span;
158                body_word_span = Some(words[body_index].span);
159                body_start = Some(body_index);
160                effective_name = Some(Cow::Owned(resolved_name));
161                break;
162            }
163            CommandResolution::Wrapper { kind, target_index } => {
164                wrappers.push(kind);
165
166                let Some(target_index) = target_index else {
167                    effective_name = None;
168                    body_word_span = None;
169                    body_start = None;
170                    break;
171                };
172
173                body_span = words[target_index].span;
174                body_word_span = Some(words[target_index].span);
175                effective_name = static_command_name_text(words[target_index], source);
176                body_start = effective_name.as_ref().map(|_| target_index);
177                current_index = target_index;
178
179                if effective_name.is_none() {
180                    break;
181                }
182            }
183        }
184    }
185
186    Some(NormalizedCommand {
187        literal_name,
188        effective_name,
189        wrappers,
190        body_span,
191        body_word_span,
192        body_words: body_start.map_or_else(Vec::new, |start| words[start..].to_vec()),
193        declaration: None,
194    })
195}
196
197fn normalize_decl_command<'a>(command: &'a DeclClause, source: &'a str) -> NormalizedCommand<'a> {
198    let raw_kind = command.variant.as_ref();
199    let assignment_operands = command
200        .operands
201        .iter()
202        .filter_map(|operand| match operand {
203            DeclOperand::Assignment(assignment) => Some(assignment),
204            DeclOperand::Flag(_) | DeclOperand::Name(_) | DeclOperand::Dynamic(_) => None,
205        })
206        .collect::<Vec<_>>();
207
208    NormalizedCommand {
209        literal_name: Some(Cow::Borrowed(raw_kind)),
210        effective_name: Some(Cow::Borrowed(raw_kind)),
211        wrappers: Vec::new(),
212        body_span: command.variant_span,
213        body_word_span: None,
214        body_words: Vec::new(),
215        declaration: Some(NormalizedDeclaration {
216            kind: declaration_kind(raw_kind),
217            readonly_flag: declaration_has_readonly_flag(command, source),
218            span: command.span,
219            head_span: declaration_head_span(command),
220            redirects: &[],
221            assignments: &command.assignments,
222            operands: &command.operands,
223            assignment_operands,
224        }),
225    }
226}
227
228fn declaration_kind(raw_kind: &str) -> DeclarationKind {
229    match raw_kind {
230        "export" => DeclarationKind::Export,
231        "local" => DeclarationKind::Local,
232        "declare" => DeclarationKind::Declare,
233        "typeset" | "integer" => DeclarationKind::Typeset,
234        _ => DeclarationKind::Other(raw_kind.to_owned()),
235    }
236}
237
238fn declaration_has_readonly_flag(command: &DeclClause, source: &str) -> bool {
239    matches!(
240        command.variant.as_ref(),
241        "local" | "declare" | "typeset" | "integer"
242    ) && command.operands.iter().any(|operand| {
243        let DeclOperand::Flag(word) = operand else {
244            return false;
245        };
246
247        static_word_text(word, source)
248            .is_some_and(|text| text.starts_with('-') && text.contains('r'))
249    })
250}
251
252fn declaration_head_span(command: &DeclClause) -> Span {
253    let end = command
254        .operands
255        .last()
256        .map_or(command.variant_span.end, declaration_operand_head_end);
257    Span::from_positions(command.variant_span.start, end)
258}
259
260fn declaration_operand_head_end(operand: &DeclOperand) -> Position {
261    match operand {
262        DeclOperand::Flag(word) | DeclOperand::Dynamic(word) => word.span.end,
263        DeclOperand::Name(name) => name.span.end,
264        DeclOperand::Assignment(assignment) => assignment.target.name_span.end,
265    }
266}
267
268fn empty_normalized_command<'a>(span: Span) -> NormalizedCommand<'a> {
269    NormalizedCommand {
270        literal_name: None,
271        effective_name: None,
272        wrappers: Vec::new(),
273        body_span: span,
274        body_word_span: None,
275        body_words: Vec::new(),
276        declaration: None,
277    }
278}
279
280fn compound_span(command: &CompoundCommand) -> Span {
281    match command {
282        CompoundCommand::If(command) => command.span,
283        CompoundCommand::For(command) => command.span,
284        CompoundCommand::Repeat(command) => command.span,
285        CompoundCommand::Foreach(command) => command.span,
286        CompoundCommand::ArithmeticFor(command) => command.span,
287        CompoundCommand::While(command) => command.span,
288        CompoundCommand::Until(command) => command.span,
289        CompoundCommand::Case(command) => command.span,
290        CompoundCommand::Select(command) => command.span,
291        CompoundCommand::Subshell(commands) | CompoundCommand::BraceGroup(commands) => {
292            commands.span
293        }
294        CompoundCommand::Arithmetic(command) => command.span,
295        CompoundCommand::Time(command) => command.span,
296        CompoundCommand::Conditional(command) => command.span,
297        CompoundCommand::Coproc(command) => command.span,
298        CompoundCommand::Always(command) => command.span,
299    }
300}
301
302enum CommandResolution {
303    Alias {
304        effective_name: String,
305        body_index: usize,
306    },
307    Wrapper {
308        kind: WrapperKind,
309        target_index: Option<usize>,
310    },
311}
312
313fn resolve_command_resolution(
314    words: &[&Word],
315    current_index: usize,
316    current_name: &str,
317    source: &str,
318) -> Option<CommandResolution> {
319    match current_name {
320        "noglob" | "command" | "builtin" | "exec" => Some(resolve_shared_wrapper(
321            words,
322            current_index,
323            current_name,
324            source,
325        )),
326        "busybox" => Some(CommandResolution::Wrapper {
327            kind: WrapperKind::Busybox,
328            target_index: words.get(current_index + 1).map(|_| current_index + 1),
329        }),
330        "find" => {
331            find_exec_target_index(words, current_index, source).map(|(kind, target_index)| {
332                CommandResolution::Wrapper {
333                    kind,
334                    target_index: Some(target_index),
335                }
336            })
337        }
338        "sudo" | "doas" | "run0" => Some(CommandResolution::Wrapper {
339            kind: WrapperKind::SudoFamily,
340            target_index: sudo_family_target_index(words, current_index, source),
341        }),
342        "git" => git_filter_branch_resolution(words, current_index, source),
343        "mumps" => mumps_run_resolution(words, current_index, source),
344        _ => None,
345    }
346}
347
348fn resolve_shared_wrapper(
349    words: &[&Word],
350    current_index: usize,
351    current_name: &str,
352    source: &str,
353) -> CommandResolution {
354    let kind = match current_name {
355        "noglob" => WrapperKind::Noglob,
356        "command" => WrapperKind::Command,
357        "builtin" => WrapperKind::Builtin,
358        "exec" => WrapperKind::Exec,
359        _ => unreachable!("caller only passes shared wrappers"),
360    };
361    let StaticCommandWrapperTarget::Wrapper { target_index } =
362        static_command_wrapper_target_index(words.len(), current_index, current_name, |index| {
363            static_word_text(words[index], source)
364        })
365    else {
366        unreachable!("shared wrapper name should resolve to a wrapper target");
367    };
368
369    CommandResolution::Wrapper { kind, target_index }
370}
371
372fn find_exec_target_index(
373    words: &[&Word],
374    current_index: usize,
375    source: &str,
376) -> Option<(WrapperKind, usize)> {
377    let mut fallback = None;
378
379    for index in current_index + 1..words.len() {
380        let Some(arg) = static_word_text(words[index], source) else {
381            continue;
382        };
383        match arg.as_ref() {
384            "-exec" | "-ok" => {
385                if fallback.is_none() {
386                    fallback = words
387                        .get(index + 1)
388                        .map(|_| (WrapperKind::FindExec, index + 1));
389                }
390            }
391            "-execdir" => {
392                return words
393                    .get(index + 1)
394                    .map(|_| (WrapperKind::FindExecDir, index + 1));
395            }
396            "-okdir" => {
397                if fallback.is_none() {
398                    fallback = words
399                        .get(index + 1)
400                        .map(|_| (WrapperKind::FindExec, index + 1));
401                }
402            }
403            _ => {}
404        }
405    }
406
407    fallback
408}
409
410fn sudo_family_target_index(words: &[&Word], current_index: usize, source: &str) -> Option<usize> {
411    let mut index = current_index + 1;
412
413    while index < words.len() {
414        let Some(arg) = static_word_text(words[index], source) else {
415            return Some(index);
416        };
417
418        if arg == "--" {
419            return words.get(index + 1).map(|_| index + 1);
420        }
421
422        if !arg.starts_with('-') || arg == "-" {
423            return Some(index);
424        }
425
426        if arg.len() == 2 && matches!(arg.as_ref(), "-l" | "-v" | "-V") {
427            return None;
428        }
429
430        if sudo_option_takes_value(arg.as_ref()) {
431            if arg.len() == 2 {
432                words.get(index + 1)?;
433                index += 2;
434            } else {
435                index += 1;
436            }
437            continue;
438        }
439
440        if arg.starts_with("--") && !matches!(arg.as_ref(), "--preserve-env" | "--login") {
441            return None;
442        }
443
444        index += 1;
445    }
446
447    None
448}
449
450fn sudo_option_takes_value(arg: &str) -> bool {
451    matches!(
452        arg.chars().nth(1),
453        Some('u' | 'g' | 'h' | 'p' | 'C' | 'D' | 'R' | 'T' | 'r' | 't')
454    )
455}
456
457fn git_filter_branch_resolution(
458    words: &[&Word],
459    current_index: usize,
460    source: &str,
461) -> Option<CommandResolution> {
462    (words
463        .get(current_index + 1)
464        .and_then(|word| static_word_text(word, source))
465        .as_deref()
466        == Some("filter-branch"))
467    .then(|| CommandResolution::Alias {
468        effective_name: "git filter-branch".to_owned(),
469        body_index: current_index + 1,
470    })
471}
472
473fn mumps_run_resolution(
474    words: &[&Word],
475    current_index: usize,
476    source: &str,
477) -> Option<CommandResolution> {
478    let run_flag = words
479        .get(current_index + 1)
480        .and_then(|word| static_word_text(word, source))?;
481    let entrypoint = words
482        .get(current_index + 2)
483        .and_then(|word| static_word_text(word, source))?;
484
485    if run_flag == "-run" && matches!(entrypoint.as_ref(), "%XCMD" | "LOOP%XCMD") {
486        Some(CommandResolution::Alias {
487            effective_name: format!("mumps -run {entrypoint}"),
488            body_index: current_index + 2,
489        })
490    } else {
491        None
492    }
493}
494
495fn builtin_name(command: &BuiltinCommand) -> &'static str {
496    match command {
497        BuiltinCommand::Break(_) => "break",
498        BuiltinCommand::Continue(_) => "continue",
499        BuiltinCommand::Return(_) => "return",
500        BuiltinCommand::Exit(_) => "exit",
501    }
502}
503
504fn builtin_span(command: &BuiltinCommand) -> Span {
505    match command {
506        BuiltinCommand::Break(command) => command.span,
507        BuiltinCommand::Continue(command) => command.span,
508        BuiltinCommand::Return(command) => command.span,
509        BuiltinCommand::Exit(command) => command.span,
510    }
511}
512
513fn command_basename(name: &str) -> &str {
514    name.rsplit('/').next().unwrap_or(name)
515}