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}