1use super::*;
2
3#[derive(Debug, Clone)]
4pub struct FunctionHeaderFact<'a> {
5 command_id: CommandId,
6 function: &'a FunctionDef,
7 binding_id: Option<BindingId>,
8 scope_id: Option<ScopeId>,
9 call_arity: FunctionCallArityFacts,
10}
11
12impl<'a> FunctionHeaderFact<'a> {
13 pub fn command_id(&self) -> CommandId {
14 self.command_id
15 }
16
17 pub fn function(&self) -> &'a FunctionDef {
18 self.function
19 }
20
21 pub fn static_name_entry(&self) -> Option<(&'a Name, Span)> {
22 self.function.static_name_entries().next()
23 }
24
25 pub fn binding_id(&self) -> Option<BindingId> {
26 self.binding_id
27 }
28
29 pub fn function_scope(&self) -> Option<ScopeId> {
30 self.scope_id
31 }
32
33 pub fn call_arity(&self) -> &FunctionCallArityFacts {
34 &self.call_arity
35 }
36
37 pub fn function_span_in_source(&self, source: &str) -> Span {
38 trim_trailing_whitespace_span(self.function.span, source)
39 }
40
41 pub fn span_in_source(&self, source: &str) -> Span {
42 trim_trailing_whitespace_span(self.function.header.span(), source)
43 }
44
45 pub fn uses_function_keyword(&self) -> bool {
46 self.function.uses_function_keyword()
47 }
48
49 pub fn has_trailing_parens(&self) -> bool {
50 self.function.has_trailing_parens()
51 }
52
53 pub fn function_keyword_span(&self) -> Option<Span> {
54 self.function.header.function_keyword_span
55 }
56
57 pub fn trailing_parens_span(&self) -> Option<Span> {
58 self.function.header.trailing_parens_span
59 }
60}
61
62#[derive(Debug, Clone, Default)]
63pub struct FunctionCallArityFacts {
64 call_count: usize,
65 min_arg_count: usize,
66 max_arg_count: usize,
67 zero_arg_call_spans: Vec<Span>,
68 zero_arg_diagnostic_spans: Vec<Span>,
69}
70
71impl FunctionCallArityFacts {
72 pub fn call_count(&self) -> usize {
73 self.call_count
74 }
75
76 pub fn min_arg_count(&self) -> Option<usize> {
77 (self.call_count != 0).then_some(self.min_arg_count)
78 }
79
80 pub fn max_arg_count(&self) -> Option<usize> {
81 (self.call_count != 0).then_some(self.max_arg_count)
82 }
83
84 pub fn called_only_without_args(&self) -> bool {
85 self.call_count != 0 && self.max_arg_count == 0
86 }
87
88 pub fn zero_arg_call_spans(&self) -> &[Span] {
89 &self.zero_arg_call_spans
90 }
91
92 pub fn zero_arg_diagnostic_spans(&self) -> &[Span] {
93 &self.zero_arg_diagnostic_spans
94 }
95
96 fn record_call(&mut self, arg_count: usize, name_span: Span, diagnostic_span: Span) {
97 if self.call_count == 0 {
98 self.min_arg_count = arg_count;
99 self.max_arg_count = arg_count;
100 } else {
101 self.min_arg_count = self.min_arg_count.min(arg_count);
102 self.max_arg_count = self.max_arg_count.max(arg_count);
103 }
104 if arg_count == 0 {
105 self.zero_arg_call_spans.push(name_span);
106 self.zero_arg_diagnostic_spans.push(diagnostic_span);
107 }
108 self.call_count += 1;
109 }
110}
111
112#[derive(Debug, Clone, Copy, Default)]
113pub(crate) struct FunctionCliDispatchFacts {
114 exported_from_case_cli: bool,
115 dispatcher_span: Option<Span>,
116}
117
118impl FunctionCliDispatchFacts {
119 pub fn exported_from_case_cli(self) -> bool {
120 self.exported_from_case_cli
121 }
122
123 #[cfg(test)]
124 pub fn dispatcher_span(self) -> Option<Span> {
125 self.dispatcher_span
126 }
127
128 fn record_dispatch(&mut self, span: Span) {
129 self.exported_from_case_cli = true;
130 self.dispatcher_span.get_or_insert(span);
131 }
132}
133
134#[derive(Debug, Clone, Copy)]
135pub(crate) struct FunctionFactInput<'a> {
136 pub(crate) command_id: CommandId,
137 pub(crate) function: &'a FunctionDef,
138}
139
140#[cfg_attr(shuck_profiling, inline(never))]
141pub(crate) fn build_function_header_facts<'a>(
142 semantic: &SemanticModel,
143 semantic_analysis: &SemanticAnalysis<'_>,
144 functions: &[FunctionFactInput<'a>],
145 commands: &[CommandFact<'a>],
146 command_fact_indices_by_id: &[Option<usize>],
147 source: &str,
148) -> Vec<FunctionHeaderFact<'a>> {
149 let call_arity_by_binding = build_function_call_arity_facts(
150 semantic_analysis,
151 &functions
152 .iter()
153 .map(|input| input.function)
154 .collect::<Vec<_>>(),
155 commands,
156 command_fact_indices_by_id,
157 source,
158 );
159 functions
160 .iter()
161 .copied()
162 .map(|input| {
163 let binding_id = semantic.function_definition_binding_for_command_span(
164 semantic.command_span(input.command_id),
165 );
166 let scope_id = binding_id
167 .and_then(|binding_id| semantic_analysis.function_scope_for_binding(binding_id));
168 let call_arity = binding_id
169 .and_then(|binding_id| call_arity_by_binding.get(&binding_id).cloned())
170 .unwrap_or_default();
171
172 FunctionHeaderFact {
173 command_id: input.command_id,
174 function: input.function,
175 binding_id,
176 scope_id,
177 call_arity,
178 }
179 })
180 .collect()
181}
182
183pub(crate) fn build_function_doc_content_facts<'a>(
184 semantic: &SemanticModel,
185 headers: &[FunctionHeaderFact<'a>],
186 commands: &[CommandFact<'a>],
187 positional_parameter_facts: &FxHashMap<ScopeId, FunctionPositionalParameterFacts>,
188 source: &str,
189 line_index: &LineIndex,
190 comment_index: &CommentIndex,
191) -> Vec<FunctionDocContentFact> {
192 let mut summaries = build_function_doc_body_summaries(semantic, commands, source);
193 let scopes_using_any_positional_parameters = build_function_scopes_using_positional_parameters(
194 semantic,
195 commands,
196 positional_parameter_facts,
197 );
198
199 headers
200 .iter()
201 .filter_map(|header| {
202 let (name, name_span) = header.static_name_entry()?;
203 let function_scope = header.function_scope()?;
204 let function_span = header.function_span_in_source(source);
205 let comment_block = leading_function_doc_block(
206 source,
207 line_index,
208 comment_index,
209 function_span.start.line,
210 );
211 let summary = summaries.remove(&function_scope).unwrap_or_default();
212 let uses_positional_parameters = positional_parameter_facts
213 .get(&function_scope)
214 .is_some_and(|facts| facts.uses_positional_parameters());
215 let uses_any_positional_parameters =
216 scopes_using_any_positional_parameters.contains(&function_scope);
217
218 Some(FunctionDocContentFact::new(
219 name,
220 name_span,
221 span_line_count(header.function().body.span),
222 comment_block.map(|block| block.span),
223 comment_block
224 .map(|block| block.sections)
225 .unwrap_or_default(),
226 FunctionDocBodyBehavior::new(
227 summary.uses_global_variables,
228 uses_positional_parameters,
229 uses_any_positional_parameters,
230 summary.writes_stdout,
231 summary.has_explicit_return,
232 ),
233 ))
234 })
235 .collect()
236}
237
238#[derive(Debug, Clone, Copy, Default)]
239struct FunctionDocBodySummary {
240 uses_global_variables: bool,
241 writes_stdout: bool,
242 has_explicit_return: bool,
243}
244
245fn build_function_doc_body_summaries(
246 semantic: &SemanticModel,
247 commands: &[CommandFact<'_>],
248 source: &str,
249) -> FxHashMap<ScopeId, FunctionDocBodySummary> {
250 let mut summaries = FxHashMap::<ScopeId, FunctionDocBodySummary>::default();
251 let reference_bindings = reference_binding_index(semantic);
252 let global_reference_skip_spans = function_doc_global_reference_skip_spans(commands, source);
253
254 for reference in semantic.references() {
255 if !reference_can_represent_global(reference, &global_reference_skip_spans) {
256 continue;
257 }
258 let Some(function_scope) = semantic.enclosing_function_scope(reference.scope) else {
259 continue;
260 };
261 if reference_is_global_variable_use(
262 semantic,
263 reference,
264 function_scope,
265 &reference_bindings,
266 ) {
267 summaries
268 .entry(function_scope)
269 .or_default()
270 .uses_global_variables = true;
271 }
272 }
273
274 for binding in semantic.bindings() {
275 let Some(function_scope) = semantic.enclosing_function_scope(binding.scope) else {
276 continue;
277 };
278 if !binding_can_represent_global_write(semantic, binding, function_scope) {
279 continue;
280 }
281 summaries
282 .entry(function_scope)
283 .or_default()
284 .uses_global_variables = true;
285 }
286
287 for command in commands {
288 let Some(function_scope) = command.enclosing_function_scope() else {
289 continue;
290 };
291 let summary = summaries.entry(function_scope).or_default();
292 summary.writes_stdout |= function_body_command_writes_stdout(command, source);
293 summary.has_explicit_return |=
294 !command.is_nested_word_command() && command_has_explicit_return(command);
295 }
296
297 summaries
298}
299
300fn reference_binding_index(semantic: &SemanticModel) -> FxHashMap<ReferenceId, BindingId> {
301 let mut index = FxHashMap::default();
302 for binding in semantic.bindings() {
303 for reference in &binding.references {
304 index.insert(*reference, binding.id);
305 }
306 }
307 index
308}
309
310fn reference_can_represent_global(reference: &Reference, skip_spans: &FxHashSet<FactSpan>) -> bool {
311 !matches!(reference.kind, ReferenceKind::DeclarationName)
312 && !is_special_or_positional_parameter_name(reference.name.as_str())
313 && !skip_spans.contains(&FactSpan::new(reference.span))
314}
315
316fn reference_is_global_variable_use(
317 semantic: &SemanticModel,
318 reference: &Reference,
319 function_scope: ScopeId,
320 reference_bindings: &FxHashMap<ReferenceId, BindingId>,
321) -> bool {
322 let Some(binding_id) = reference_bindings.get(&reference.id).copied() else {
323 return true;
324 };
325 let binding = semantic.binding(binding_id);
326 if matches!(binding.kind, BindingKind::FunctionDefinition) {
327 return false;
328 }
329
330 !binding.attributes.contains(BindingAttributes::LOCAL)
331 || semantic.enclosing_function_scope(binding.scope) != Some(function_scope)
332}
333
334fn binding_can_represent_global_write(
335 semantic: &SemanticModel,
336 binding: &Binding,
337 function_scope: ScopeId,
338) -> bool {
339 !binding.attributes.contains(BindingAttributes::LOCAL)
340 && !matches!(
341 binding.kind,
342 BindingKind::FunctionDefinition | BindingKind::Imported
343 )
344 && !is_special_or_positional_parameter_name(binding.name.as_str())
345 && !binding_targets_prior_local(semantic, binding, function_scope)
346}
347
348fn binding_targets_prior_local(
349 semantic: &SemanticModel,
350 binding: &Binding,
351 function_scope: ScopeId,
352) -> bool {
353 semantic
354 .bindings_for(&binding.name)
355 .iter()
356 .any(|candidate| {
357 let candidate = semantic.binding(*candidate);
358 candidate.id != binding.id
359 && candidate.span.start.offset < binding.span.start.offset
360 && candidate.attributes.contains(BindingAttributes::LOCAL)
361 && semantic.enclosing_function_scope(candidate.scope) == Some(function_scope)
362 })
363}
364
365fn is_special_or_positional_parameter_name(name: &str) -> bool {
366 name.bytes().all(|byte| byte.is_ascii_digit())
367 || matches!(name, "@" | "*" | "#" | "?" | "-" | "$" | "!")
368}
369
370fn function_body_command_writes_stdout(command: &CommandFact<'_>, source: &str) -> bool {
371 if command.is_nested_word_command() || command_stdout_is_redirected(command) {
372 return false;
373 }
374
375 command.normalized().effective_basename_is("echo")
376 || (command.normalized().effective_basename_is("printf")
377 && (!command.effective_name_is("printf")
378 || !printf_assigns_to_variable(command, source)))
379}
380
381fn printf_assigns_to_variable(command: &CommandFact<'_>, source: &str) -> bool {
382 match command
383 .body_args()
384 .first()
385 .and_then(|word| static_word_text(word, source))
386 {
387 Some(text) if text == "-v" => command.body_args().len() >= 2,
388 Some(text) if text.starts_with("-v") && text.len() > 2 => true,
389 _ => false,
390 }
391}
392
393fn command_stdout_is_redirected(command: &CommandFact<'_>) -> bool {
394 command.redirects().iter().any(redirect_redirects_stdout)
395}
396
397fn redirect_redirects_stdout(redirect: &Redirect) -> bool {
398 if redirect.fd_var.is_some() {
399 return false;
400 }
401
402 match redirect.kind {
403 RedirectKind::Output
404 | RedirectKind::Clobber
405 | RedirectKind::Append
406 | RedirectKind::DupOutput => redirect.fd.unwrap_or(1) == 1,
407 RedirectKind::ReadWrite => redirect.fd == Some(1),
408 RedirectKind::OutputBoth => true,
409 RedirectKind::Input
410 | RedirectKind::HereDoc
411 | RedirectKind::HereDocStrip
412 | RedirectKind::HereString
413 | RedirectKind::DupInput => false,
414 }
415}
416
417fn command_has_explicit_return(command: &CommandFact<'_>) -> bool {
418 matches!(
419 command.command(),
420 Command::Builtin(BuiltinCommand::Return(command)) if command.code.is_some()
421 )
422}
423
424fn function_doc_global_reference_skip_spans(
425 commands: &[CommandFact<'_>],
426 source: &str,
427) -> FxHashSet<FactSpan> {
428 commands
429 .iter()
430 .filter_map(|command| printf_assignment_target_span(command, source))
431 .map(FactSpan::new)
432 .collect()
433}
434
435fn printf_assignment_target_span(command: &CommandFact<'_>, source: &str) -> Option<Span> {
436 if !command.effective_name_is("printf") {
437 return None;
438 }
439
440 let args = command.body_args();
441 match args.first().and_then(|word| static_word_text(word, source)) {
442 Some(text) if text == "-v" => args.get(1).map(|word| word.span),
443 Some(text) if text.starts_with("-v") && text.len() > 2 => Some(args[0].span),
444 _ => None,
445 }
446}
447
448#[derive(Debug, Clone, Copy)]
449struct LeadingFunctionDocBlock {
450 span: Span,
451 sections: FunctionDocSections,
452}
453
454fn leading_function_doc_block(
455 source: &str,
456 line_index: &LineIndex,
457 comment_index: &CommentIndex,
458 function_start_line: usize,
459) -> Option<LeadingFunctionDocBlock> {
460 let mut line = function_start_line.checked_sub(1)?;
461 let mut block_start: Option<Position> = None;
462 let mut block_end: Option<Position> = None;
463 let mut sections = FunctionDocSections::default();
464 let mut has_doc_comment = false;
465
466 loop {
467 let Some(comment) = comment_index
468 .comments_on_line(line)
469 .iter()
470 .find(|comment| comment.is_own_line)
471 else {
472 break;
473 };
474 let line_start = usize::from(line_index.line_start(line)?);
475 let line_end = usize::from(line_index.line_range(line, source)?.end()).min(source.len());
476 let comment_start = usize::from(comment.range.start());
477 let comment_end = usize::from(comment.range.end()).min(line_end);
478 if comment_start < line_start || comment_start >= comment_end {
479 break;
480 }
481
482 let comment_text = &source[comment_start..comment_end];
483 let Some(comment_body) = function_doc_comment_body(comment_text) else {
484 break;
485 };
486
487 let line_start_position = Position {
488 line,
489 column: 1,
490 offset: line_start,
491 };
492 let comment_start_position =
493 line_start_position.advanced_by(&source[line_start..comment_start]);
494 let comment_end_position =
495 line_start_position.advanced_by(&source[line_start..comment_end]);
496 block_start = Some(comment_start_position);
497 block_end.get_or_insert(comment_end_position);
498
499 if is_function_doc_comment_body(comment_body) {
500 has_doc_comment = true;
501 sections.record_comment_body(comment_body);
502 }
503
504 let Some(previous_line) = line.checked_sub(1) else {
505 break;
506 };
507 line = previous_line;
508 }
509
510 if !has_doc_comment {
511 return None;
512 }
513
514 Some(LeadingFunctionDocBlock {
515 span: Span::from_positions(block_start?, block_end?),
516 sections,
517 })
518}
519
520fn function_doc_comment_body(comment_text: &str) -> Option<&str> {
521 let body = comment_text.strip_prefix('#')?.trim();
522 (!body.starts_with('!')).then_some(body)
523}
524
525fn is_function_doc_comment_body(body: &str) -> bool {
526 if body.is_empty() {
527 return false;
528 }
529
530 let lower = body.to_ascii_lowercase();
531 !lower.starts_with("shellcheck ") && !lower.starts_with("shuck:")
532}
533
534fn build_function_scopes_using_positional_parameters(
535 semantic: &SemanticModel,
536 commands: &[CommandFact<'_>],
537 positional_parameter_facts: &FxHashMap<ScopeId, FunctionPositionalParameterFacts>,
538) -> FxHashSet<ScopeId> {
539 let local_reset_offsets_by_scope = local_positional_reset_offsets_by_scope(semantic, commands);
540 let mut scopes = positional_parameter_facts
541 .iter()
542 .filter_map(|(&scope, facts)| facts.uses_positional_parameters().then_some(scope))
543 .collect::<FxHashSet<_>>();
544
545 for reference in semantic.references() {
546 if !semantic.is_guarded_parameter_reference(reference.id)
547 || !is_positional_parameter_reference_name(reference.name.as_str())
548 {
549 continue;
550 }
551 if reference_has_local_positional_reset(
552 semantic,
553 reference.scope,
554 reference.span.start.offset,
555 &local_reset_offsets_by_scope,
556 ) {
557 continue;
558 }
559 if let Some(scope) = semantic.enclosing_function_scope(reference.scope) {
560 scopes.insert(scope);
561 }
562 }
563
564 scopes
565}
566
567fn is_positional_parameter_reference_name(name: &str) -> bool {
568 matches!(name, "@" | "*" | "#")
569 || (!name.is_empty() && name != "0" && name.bytes().all(|byte| byte.is_ascii_digit()))
570}
571
572fn span_line_count(span: Span) -> usize {
573 span.end.line.saturating_sub(span.start.line) + 1
574}
575
576#[cfg_attr(shuck_profiling, inline(never))]
577pub(crate) fn build_function_cli_dispatch_facts(
578 dispatches: &[CaseCliDispatch],
579) -> FxHashMap<ScopeId, FunctionCliDispatchFacts> {
580 let mut facts = FxHashMap::<ScopeId, FunctionCliDispatchFacts>::default();
581 for dispatch in dispatches {
582 facts
583 .entry(dispatch.function_scope())
584 .or_default()
585 .record_dispatch(dispatch.dispatcher_span());
586 }
587 facts
588}
589
590pub(crate) fn build_function_parameter_fallback_spans(
591 commands: &[CommandFact<'_>],
592 command_fact_indices_by_id: &[Option<usize>],
593 structural_command_ids: &[CommandId],
594 source: &str,
595) -> Vec<Span> {
596 let structural_commands = structural_command_ids
597 .iter()
598 .copied()
599 .filter_map(|id| {
600 command_fact_indices_by_id
601 .get(id.index())
602 .copied()
603 .flatten()
604 .and_then(|index| commands.get(index))
605 })
606 .collect::<Vec<_>>();
607
608 structural_commands
609 .windows(2)
610 .filter_map(|pair| function_parameter_fallback_span(pair, source))
611 .chain(
612 commands
613 .iter()
614 .filter_map(named_coproc_subshell_fallback_span),
615 )
616 .collect()
617}
618
619pub(crate) fn build_completion_registered_function_command_flags(
620 commands: &[CommandFact<'_>],
621 registered_scopes: &FxHashSet<ScopeId>,
622) -> Vec<bool> {
623 let mut flags = vec![false; function_command_slot_count(commands)];
624 for command in commands {
625 flags[command.id().index()] = command
626 .enclosing_function_scope()
627 .is_some_and(|scope| registered_scopes.contains(&scope));
628 }
629 flags
630}
631
632pub(crate) fn build_completion_registered_function_scopes(
633 semantic: &SemanticModel,
634 semantic_analysis: &SemanticAnalysis<'_>,
635 commands: &[CommandFact<'_>],
636 command_fact_indices_by_id: &[Option<usize>],
637 lists: &[ListFact<'_>],
638 source: &str,
639) -> FxHashSet<ScopeId> {
640 let mut function_candidates = Vec::new();
641 function_candidates.resize_with(function_command_slot_count(commands), || None);
642 for command in commands {
643 function_candidates[command.id().index()] =
644 completion_registered_function_candidate(semantic, command);
645 }
646 let mut file_level_function_candidates = Vec::new();
647 file_level_function_candidates.resize_with(function_command_slot_count(commands), || None);
648 for command in commands {
649 file_level_function_candidates[command.id().index()] =
650 file_level_completion_function_candidate(semantic, command);
651 }
652 let top_level_candidate_scopes = function_candidates
653 .iter()
654 .flatten()
655 .map(|candidate| candidate.scope)
656 .collect::<FxHashSet<_>>();
657 let file_level_candidate_scopes = file_level_function_candidates
658 .iter()
659 .flatten()
660 .map(|candidate| candidate.scope)
661 .collect::<FxHashSet<_>>();
662 let mut scopes = FxHashSet::default();
663
664 for list in lists {
665 for (index, segment) in list.segments().iter().enumerate() {
666 let Some(candidate) = function_candidates[segment.command_id().index()].as_ref() else {
667 continue;
668 };
669
670 if list.segments()[index + 1..].iter().any(|later_segment| {
671 let later_command = command_fact(
672 commands,
673 command_fact_indices_by_id,
674 later_segment.command_id(),
675 );
676 is_unconditional_completion_registration(semantic, later_command)
677 && command_registers_completion_function(later_command, source, &candidate.name)
678 }) {
679 scopes.insert(candidate.scope);
680 }
681 }
682 }
683
684 for list in lists {
685 for (index, segment) in list.segments().iter().enumerate() {
686 let Some(candidate) =
687 file_level_function_candidates[segment.command_id().index()].as_ref()
688 else {
689 continue;
690 };
691
692 if list.segments()[index + 1..].iter().any(|later_segment| {
693 let later_command = command_fact(
694 commands,
695 command_fact_indices_by_id,
696 later_segment.command_id(),
697 );
698 is_same_branch_completion_registration(semantic, later_command)
699 && command_registers_completion_function(later_command, source, &candidate.name)
700 }) {
701 scopes.insert(candidate.scope);
702 }
703 }
704 }
705
706 for command in commands {
707 let Some(candidate) = file_level_function_candidates[command.id().index()].as_ref() else {
708 continue;
709 };
710 let Some(parent) = nearest_structural_parent_command(semantic, command.id()) else {
711 continue;
712 };
713 if commands.iter().any(|later_command| {
714 later_command.span().start.offset > command.span().start.offset
715 && nearest_structural_parent_command(semantic, later_command.id()) == Some(parent)
716 && same_structural_branch_between(
717 source,
718 command.span().end.offset,
719 later_command.span().start.offset,
720 )
721 && is_same_branch_completion_registration(semantic, later_command)
722 && command_registers_completion_function(later_command, source, &candidate.name)
723 }) {
724 scopes.insert(candidate.scope);
725 }
726 }
727
728 for command in commands {
729 let Some(candidate) = function_candidates[command.id().index()].as_ref() else {
730 continue;
731 };
732 if commands.iter().any(|later_command| {
733 is_unconditional_completion_registration(semantic, later_command)
734 && later_command.effective_or_literal_name() == Some("compdef")
735 && command_registers_completion_function(later_command, source, &candidate.name)
736 }) {
737 scopes.insert(candidate.scope);
738 }
739 }
740
741 for scope in semantic.scopes() {
742 if !top_level_candidate_scopes.contains(&scope.id) {
743 continue;
744 }
745 let ScopeKind::Function(FunctionScopeKind::Named(names)) = &scope.kind else {
746 continue;
747 };
748 if commands.iter().any(|command| {
749 is_unconditional_completion_registration(semantic, command)
750 && command.effective_or_literal_name() == Some("compdef")
751 && names.iter().any(|name| {
752 command_registers_completion_function(command, source, name.as_str())
753 })
754 }) {
755 scopes.insert(scope.id);
756 }
757 }
758
759 extend_completion_registered_function_scopes_through_helpers(
760 semantic,
761 semantic_analysis,
762 commands,
763 &file_level_candidate_scopes,
764 source,
765 &mut scopes,
766 );
767
768 scopes
769}
770
771pub(crate) fn extend_completion_registered_function_scopes_through_helpers(
772 semantic: &SemanticModel,
773 semantic_analysis: &SemanticAnalysis<'_>,
774 commands: &[CommandFact<'_>],
775 candidate_scopes: &FxHashSet<ScopeId>,
776 source: &str,
777 scopes: &mut FxHashSet<ScopeId>,
778) {
779 let candidate_bindings = semantic_analysis
780 .function_bindings_by_scope()
781 .filter(|(scope, _)| candidate_scopes.contains(scope))
782 .flat_map(|(scope, bindings)| bindings.iter().map(move |binding| (scope, *binding)))
783 .collect::<Vec<_>>();
784
785 let mut changed = true;
786 while changed {
787 changed = false;
788 for (target_scope, binding_id) in &candidate_bindings {
789 if scopes.contains(target_scope) {
790 continue;
791 }
792 let name = semantic.binding(*binding_id).name.clone();
793 let called_from_completion_scope = semantic_analysis
794 .function_call_arity_sites(&name)
795 .any(|(site, resolved_binding)| {
796 resolved_binding == *binding_id
797 && semantic_analysis
798 .enclosing_function_scope_at(site.name_span.start.offset)
799 .is_some_and(|caller_scope| scopes.contains(&caller_scope))
800 });
801 let referenced_by_completion_arguments = commands.iter().any(|command| {
802 command.normalized().effective_or_literal_name() == Some("_arguments")
803 && command
804 .enclosing_function_scope()
805 .is_some_and(|caller_scope| scopes.contains(&caller_scope))
806 && command.body_args().iter().any(|word| {
807 static_word_text(word, source).is_some_and(|text| {
808 zsh_arguments_spec_invokes_function(
809 &text,
810 &semantic.binding(*binding_id).name,
811 )
812 })
813 })
814 });
815 if called_from_completion_scope || referenced_by_completion_arguments {
816 scopes.insert(*target_scope);
817 changed = true;
818 }
819 }
820 }
821}
822
823#[cfg_attr(shuck_profiling, inline(never))]
824pub(crate) fn build_external_entrypoint_function_scopes(
825 semantic: &SemanticModel,
826 commands: &[CommandFact<'_>],
827 command_fact_indices_by_id: &[Option<usize>],
828 lists: &[ListFact<'_>],
829 source: &str,
830) -> FxHashSet<ScopeId> {
831 let mut function_candidates = Vec::new();
832 function_candidates.resize_with(function_command_slot_count(commands), || None);
833 for command in commands {
834 function_candidates[command.id().index()] =
835 external_entrypoint_function_candidate(semantic, command);
836 }
837
838 let mut scopes = FxHashSet::default();
839 for candidate in function_candidates.iter().flatten() {
840 if is_zsh_special_hook_name(&candidate.name) {
841 scopes.insert(candidate.scope);
842 }
843 }
844
845 let mut zsh_widget_functions = FxHashMap::<Box<str>, Box<str>>::default();
846 let mut zsh_hook_targets = FxHashSet::<(Box<str>, ZshHookTarget)>::default();
847 for command in commands {
848 if !is_top_level_zsh_entrypoint_registration(semantic, command) {
849 continue;
850 }
851 match command_zsh_external_entrypoint_action(command, source) {
852 Some(ZshExternalEntrypointAction::RegisterWidget { widget, function }) => {
853 zsh_widget_functions.insert(widget, function);
854 }
855 Some(ZshExternalEntrypointAction::UnregisterWidgets { widgets }) => {
856 for widget in widgets {
857 zsh_widget_functions.remove(&widget);
858 }
859 }
860 Some(ZshExternalEntrypointAction::RegisterHookFunction { hook, function }) => {
861 zsh_hook_targets.insert((hook, ZshHookTarget::Function(function)));
862 }
863 Some(ZshExternalEntrypointAction::RegisterHookWidget { hook, widget }) => {
864 zsh_hook_targets.insert((hook, ZshHookTarget::Widget(widget)));
865 }
866 Some(ZshExternalEntrypointAction::UnregisterHookFunction { hook, function }) => {
867 zsh_hook_targets.remove(&(hook, ZshHookTarget::Function(function)));
868 }
869 Some(ZshExternalEntrypointAction::UnregisterHookWidget { hook, widget }) => {
870 zsh_hook_targets.remove(&(hook, ZshHookTarget::Widget(widget)));
871 }
872 Some(ZshExternalEntrypointAction::UnregisterHookFunctionPattern { hook, pattern }) => {
873 zsh_hook_targets.retain(|(registered_hook, registered_target)| {
874 registered_hook.as_ref() != hook.as_ref()
875 || !registered_target.is_function_matching_pattern(&pattern)
876 });
877 }
878 Some(ZshExternalEntrypointAction::UnregisterHookWidgetPattern { hook, pattern }) => {
879 zsh_hook_targets.retain(|(registered_hook, registered_target)| {
880 registered_hook.as_ref() != hook.as_ref()
881 || !registered_target.is_widget_matching_pattern(&pattern)
882 });
883 }
884 None => {}
885 }
886 }
887
888 for candidate in function_candidates.iter().flatten() {
889 if zsh_widget_functions
890 .values()
891 .any(|name| name.as_ref() == candidate.name.as_ref())
892 || zsh_hook_targets
893 .iter()
894 .any(|(_, target)| target.matches_function(&candidate.name, &zsh_widget_functions))
895 {
896 scopes.insert(candidate.scope);
897 }
898 }
899
900 for list in lists {
901 for (index, segment) in list.segments().iter().enumerate() {
902 let Some(candidate) = function_candidates[segment.command_id().index()].as_ref() else {
903 continue;
904 };
905
906 if list.segments()[index + 1..].iter().any(|later_segment| {
907 command_registers_completion_function(
908 command_fact(
909 commands,
910 command_fact_indices_by_id,
911 later_segment.command_id(),
912 ),
913 source,
914 &candidate.name,
915 )
916 }) {
917 scopes.insert(candidate.scope);
918 }
919 }
920 }
921
922 scopes
923}
924
925pub(crate) fn is_top_level_zsh_entrypoint_registration(
926 semantic: &SemanticModel,
927 command: &CommandFact<'_>,
928) -> bool {
929 semantic.scope(command.scope()).parent.is_none()
930 && !command.is_nested_word_command()
931 && !has_structural_parent_command(semantic, command.id())
932}
933
934pub(crate) fn has_structural_parent_command(semantic: &SemanticModel, id: CommandId) -> bool {
935 nearest_structural_parent_command(semantic, id).is_some()
936}
937
938pub(crate) fn nearest_structural_parent_command(
939 semantic: &SemanticModel,
940 id: CommandId,
941) -> Option<CommandId> {
942 let mut current = semantic.syntax_backed_command_parent_id(id);
943 while let Some(parent) = current {
944 if matches!(
945 semantic.command_kind(parent),
946 shuck_semantic::CommandKind::Compound(_)
947 | shuck_semantic::CommandKind::Function
948 | shuck_semantic::CommandKind::AnonymousFunction
949 ) {
950 return Some(parent);
951 }
952 current = semantic.syntax_backed_command_parent_id(parent);
953 }
954 None
955}
956
957pub(crate) fn same_structural_branch_between(source: &str, start: usize, end: usize) -> bool {
958 let Some(between) = source.get(start..end) else {
959 return false;
960 };
961 !contains_zsh_structural_branch_boundary(between)
962}
963
964pub(crate) fn contains_zsh_structural_branch_boundary(text: &str) -> bool {
965 #[derive(Clone, Copy, PartialEq, Eq)]
966 enum Quote {
967 None,
968 Single,
969 Double,
970 }
971
972 let mut chars = text.chars().peekable();
973 let mut quote = Quote::None;
974 let mut in_comment = false;
975 let mut escaped = false;
976 let mut word = String::new();
977 let mut at_word_start = true;
978
979 while let Some(ch) = chars.next() {
980 if in_comment {
981 if ch == '\n' {
982 in_comment = false;
983 at_word_start = true;
984 }
985 continue;
986 }
987
988 match quote {
989 Quote::Single => {
990 if ch == '\'' {
991 quote = Quote::None;
992 }
993 continue;
994 }
995 Quote::Double => {
996 if escaped {
997 escaped = false;
998 continue;
999 }
1000 if ch == '\\' {
1001 escaped = true;
1002 continue;
1003 }
1004 if ch == '"' {
1005 quote = Quote::None;
1006 }
1007 continue;
1008 }
1009 Quote::None => {}
1010 }
1011
1012 match ch {
1013 '#' => {
1014 if structural_boundary_word(&word) {
1015 return true;
1016 }
1017 word.clear();
1018 if at_word_start {
1019 in_comment = true;
1020 } else {
1021 at_word_start = false;
1022 }
1023 }
1024 '\'' => {
1025 if structural_boundary_word(&word) {
1026 return true;
1027 }
1028 word.clear();
1029 at_word_start = false;
1030 quote = Quote::Single;
1031 }
1032 '"' => {
1033 if structural_boundary_word(&word) {
1034 return true;
1035 }
1036 word.clear();
1037 at_word_start = false;
1038 quote = Quote::Double;
1039 }
1040 ';' => {
1041 if structural_boundary_word(&word) {
1042 return true;
1043 }
1044 word.clear();
1045 if matches!(chars.peek(), Some(';' | '&' | '|')) {
1046 return true;
1047 }
1048 at_word_start = true;
1049 }
1050 _ if ch.is_whitespace() => {
1051 if structural_boundary_word(&word) {
1052 return true;
1053 }
1054 word.clear();
1055 at_word_start = true;
1056 }
1057 _ if is_shell_identifier_char(ch) => {
1058 word.push(ch);
1059 at_word_start = false;
1060 }
1061 _ => {
1062 if structural_boundary_word(&word) {
1063 return true;
1064 }
1065 word.clear();
1066 at_word_start = false;
1067 }
1068 }
1069 }
1070
1071 structural_boundary_word(&word)
1072}
1073
1074pub(crate) fn structural_boundary_word(word: &str) -> bool {
1075 matches!(word, "else" | "elif")
1076}
1077
1078pub(crate) fn is_shell_identifier_char(ch: char) -> bool {
1079 ch == '_' || ch.is_ascii_alphanumeric()
1080}
1081
1082pub(crate) fn is_unconditional_completion_registration(
1083 semantic: &SemanticModel,
1084 command: &CommandFact<'_>,
1085) -> bool {
1086 if !is_top_level_zsh_entrypoint_registration(semantic, command) {
1087 return false;
1088 }
1089 command.effective_or_literal_name() != Some("compdef")
1090 || !has_binary_parent_command(semantic, command.id())
1091}
1092
1093pub(crate) fn is_same_branch_completion_registration(
1094 semantic: &SemanticModel,
1095 command: &CommandFact<'_>,
1096) -> bool {
1097 matches!(
1098 command.effective_or_literal_name(),
1099 Some("compdef" | "complete")
1100 ) && !has_binary_parent_command(semantic, command.id())
1101}
1102
1103pub(crate) fn has_binary_parent_command(semantic: &SemanticModel, id: CommandId) -> bool {
1104 let mut current = semantic.syntax_backed_command_parent_id(id);
1105 while let Some(parent) = current {
1106 if matches!(
1107 semantic.command_kind(parent),
1108 shuck_semantic::CommandKind::Binary
1109 ) {
1110 return true;
1111 }
1112 current = semantic.syntax_backed_command_parent_id(parent);
1113 }
1114 false
1115}
1116
1117pub(crate) fn function_command_slot_count(commands: &[CommandFact<'_>]) -> usize {
1118 commands
1119 .iter()
1120 .map(|command| command.id().index())
1121 .max()
1122 .map_or(0, |index| index + 1)
1123}
1124
1125pub(crate) fn completion_registered_function_candidate(
1126 semantic: &SemanticModel,
1127 command: &CommandFact<'_>,
1128) -> Option<CompletionRegisteredFunctionCandidate> {
1129 if !is_top_level_zsh_entrypoint_registration(semantic, command) {
1130 return None;
1131 }
1132 let Command::Function(function) = command.command() else {
1133 return None;
1134 };
1135 let (name, _) = function.static_name_entries().next()?;
1136 let scope = semantic.scope_at(function.body.span.start.offset);
1137
1138 Some(CompletionRegisteredFunctionCandidate {
1139 scope,
1140 name: name.as_str().to_owned().into_boxed_str(),
1141 })
1142}
1143
1144pub(crate) fn file_level_completion_function_candidate(
1145 semantic: &SemanticModel,
1146 command: &CommandFact<'_>,
1147) -> Option<CompletionRegisteredFunctionCandidate> {
1148 if command.is_nested_word_command()
1149 || semantic.enclosing_function_scope(command.scope()).is_some()
1150 {
1151 return None;
1152 }
1153 let Command::Function(function) = command.command() else {
1154 return None;
1155 };
1156 let (name, _) = function.static_name_entries().next()?;
1157 let scope = semantic.scope_at(function.body.span.start.offset);
1158
1159 Some(CompletionRegisteredFunctionCandidate {
1160 scope,
1161 name: name.as_str().to_owned().into_boxed_str(),
1162 })
1163}
1164
1165pub(crate) fn external_entrypoint_function_candidate(
1166 semantic: &SemanticModel,
1167 command: &CommandFact<'_>,
1168) -> Option<CompletionRegisteredFunctionCandidate> {
1169 let Command::Function(function) = command.command() else {
1170 return None;
1171 };
1172 let (name, _) = function.static_name_entries().next()?;
1173 let scope = semantic.scope_at(function.body.span.start.offset);
1174 let name_text = name.as_str();
1175
1176 Some(CompletionRegisteredFunctionCandidate {
1177 scope,
1178 name: name_text.to_owned().into_boxed_str(),
1179 })
1180}
1181
1182pub(crate) fn command_registers_completion_function(
1183 command: &CommandFact<'_>,
1184 source: &str,
1185 expected_name: &str,
1186) -> bool {
1187 let command_name = command.effective_or_literal_name();
1188
1189 if command_name == Some("compdef") {
1190 let mut index = 0usize;
1191 let args = command.body_args();
1192 while let Some(word) = args.get(index) {
1193 let Some(text) = static_word_text(word, source) else {
1194 return false;
1195 };
1196 if text == "--" {
1197 index += 1;
1198 break;
1199 }
1200 let Some(flags) = text.strip_prefix('-') else {
1201 break;
1202 };
1203 if flags.is_empty() || flags.starts_with('-') {
1204 break;
1205 }
1206 if flags.contains('d') || flags.contains('D') {
1207 return false;
1208 }
1209 index += 1;
1210 for flag in flags.chars() {
1211 if matches!(flag, 'p' | 'P') {
1212 if index >= args.len() {
1213 return false;
1214 }
1215 index += 1;
1216 }
1217 }
1218 }
1219
1220 if let Some(word) = args.get(index) {
1221 let Some(text) = static_word_text(word, source) else {
1222 return false;
1223 };
1224 if text.contains('=') {
1225 return false;
1226 }
1227 if text == expected_name {
1228 return true;
1229 }
1230 return false;
1231 }
1232 return false;
1233 }
1234
1235 if command_name == Some("complete") {
1236 let mut expects_function_name = false;
1237 for word in command.body_args() {
1238 let Some(text) = static_word_text(word, source) else {
1239 expects_function_name = false;
1240 continue;
1241 };
1242
1243 if expects_function_name {
1244 return text == expected_name;
1245 }
1246
1247 if text == "--" {
1248 return false;
1249 }
1250 if text == "-F" || text == "--function" {
1251 expects_function_name = true;
1252 continue;
1253 }
1254 if let Some(name) = text.strip_prefix("-F")
1255 && !name.is_empty()
1256 {
1257 return name == expected_name;
1258 }
1259 if let Some(name) = text.strip_prefix("--function=") {
1260 return name == expected_name;
1261 }
1262 }
1263 }
1264
1265 false
1266}
1267
1268pub(crate) fn zsh_arguments_spec_invokes_function(spec: &str, expected_name: &Name) -> bool {
1269 spec.split(':')
1270 .skip(1)
1271 .map(str::trim)
1272 .any(|segment| segment == expected_name.as_str())
1273}
1274
1275pub(crate) enum ZshExternalEntrypointAction {
1276 RegisterWidget {
1277 widget: Box<str>,
1278 function: Box<str>,
1279 },
1280 UnregisterWidgets {
1281 widgets: Vec<Box<str>>,
1282 },
1283 RegisterHookFunction {
1284 hook: Box<str>,
1285 function: Box<str>,
1286 },
1287 RegisterHookWidget {
1288 hook: Box<str>,
1289 widget: Box<str>,
1290 },
1291 UnregisterHookFunction {
1292 hook: Box<str>,
1293 function: Box<str>,
1294 },
1295 UnregisterHookWidget {
1296 hook: Box<str>,
1297 widget: Box<str>,
1298 },
1299 UnregisterHookFunctionPattern {
1300 hook: Box<str>,
1301 pattern: Box<str>,
1302 },
1303 UnregisterHookWidgetPattern {
1304 hook: Box<str>,
1305 pattern: Box<str>,
1306 },
1307}
1308
1309#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1310pub(crate) enum ZshHookTarget {
1311 Function(Box<str>),
1312 Widget(Box<str>),
1313}
1314
1315impl ZshHookTarget {
1316 fn matches_function(
1317 &self,
1318 function: &str,
1319 widget_functions: &FxHashMap<Box<str>, Box<str>>,
1320 ) -> bool {
1321 match self {
1322 Self::Function(name) => name.as_ref() == function,
1323 Self::Widget(widget) => {
1324 widget.as_ref() == function
1325 || widget_functions
1326 .get(widget)
1327 .is_some_and(|name| name.as_ref() == function)
1328 }
1329 }
1330 }
1331
1332 fn is_function_matching_pattern(&self, pattern: &str) -> bool {
1333 match self {
1334 Self::Function(name) => zsh_hook_function_pattern_matches(pattern, name),
1335 Self::Widget(_) => false,
1336 }
1337 }
1338
1339 fn is_widget_matching_pattern(&self, pattern: &str) -> bool {
1340 match self {
1341 Self::Function(_) => false,
1342 Self::Widget(name) => zsh_hook_function_pattern_matches(pattern, name),
1343 }
1344 }
1345}
1346
1347pub(crate) fn command_zsh_external_entrypoint_action(
1348 command: &CommandFact<'_>,
1349 source: &str,
1350) -> Option<ZshExternalEntrypointAction> {
1351 match command.effective_or_literal_name()? {
1352 "zle" => zle_external_entrypoint_action(command, source),
1353 "add-zsh-hook" => {
1354 add_zsh_hook_external_entrypoint_action(command, source, AddZshHookTargetKind::Function)
1355 }
1356 "add-zle-hook-widget" => {
1357 add_zsh_hook_external_entrypoint_action(command, source, AddZshHookTargetKind::Widget)
1358 }
1359 _ => None,
1360 }
1361}
1362
1363pub(crate) fn zle_external_entrypoint_action(
1364 command: &CommandFact<'_>,
1365 source: &str,
1366) -> Option<ZshExternalEntrypointAction> {
1367 let args = static_command_args(command, source)?;
1368
1369 if let Some(registration_index) = args.iter().position(|arg| arg == "-N") {
1370 let operands = args[registration_index + 1..]
1371 .iter()
1372 .filter(|arg| !arg.starts_with('-'))
1373 .collect::<Vec<_>>();
1374 return match operands.as_slice() {
1375 [widget] => Some(ZshExternalEntrypointAction::RegisterWidget {
1376 widget: widget.as_str().into(),
1377 function: widget.as_str().into(),
1378 }),
1379 [widget, function, ..] => Some(ZshExternalEntrypointAction::RegisterWidget {
1380 widget: widget.as_str().into(),
1381 function: function.as_str().into(),
1382 }),
1383 _ => None,
1384 };
1385 }
1386
1387 if let Some(removal_index) = args.iter().position(|arg| arg == "-D") {
1388 let widgets = args[removal_index + 1..]
1389 .iter()
1390 .filter(|arg| !arg.starts_with('-'))
1391 .map(|arg| arg.as_str().into())
1392 .collect::<Vec<_>>();
1393 if !widgets.is_empty() {
1394 return Some(ZshExternalEntrypointAction::UnregisterWidgets { widgets });
1395 }
1396 }
1397
1398 None
1399}
1400
1401pub(crate) fn add_zsh_hook_external_entrypoint_action(
1402 command: &CommandFact<'_>,
1403 source: &str,
1404 target_kind: AddZshHookTargetKind,
1405) -> Option<ZshExternalEntrypointAction> {
1406 let args = static_command_args(command, source)?;
1407 let removal_mode = add_zsh_hook_removal_mode(&args);
1408 let operands = args
1409 .iter()
1410 .filter(|arg| !arg.starts_with('-'))
1411 .collect::<Vec<_>>();
1412 match operands.as_slice() {
1413 [hook, function, ..] if removal_mode == Some(AddZshHookRemovalMode::Exact) => {
1414 match target_kind {
1415 AddZshHookTargetKind::Function => {
1416 Some(ZshExternalEntrypointAction::UnregisterHookFunction {
1417 hook: hook.as_str().into(),
1418 function: function.as_str().into(),
1419 })
1420 }
1421 AddZshHookTargetKind::Widget => {
1422 Some(ZshExternalEntrypointAction::UnregisterHookWidget {
1423 hook: hook.as_str().into(),
1424 widget: function.as_str().into(),
1425 })
1426 }
1427 }
1428 }
1429 [hook, pattern, ..] if removal_mode == Some(AddZshHookRemovalMode::Pattern) => {
1430 match target_kind {
1431 AddZshHookTargetKind::Function => {
1432 Some(ZshExternalEntrypointAction::UnregisterHookFunctionPattern {
1433 hook: hook.as_str().into(),
1434 pattern: pattern.as_str().into(),
1435 })
1436 }
1437 AddZshHookTargetKind::Widget => {
1438 Some(ZshExternalEntrypointAction::UnregisterHookWidgetPattern {
1439 hook: hook.as_str().into(),
1440 pattern: pattern.as_str().into(),
1441 })
1442 }
1443 }
1444 }
1445 [hook, function, ..] if removal_mode.is_none() => match target_kind {
1446 AddZshHookTargetKind::Function => {
1447 Some(ZshExternalEntrypointAction::RegisterHookFunction {
1448 hook: hook.as_str().into(),
1449 function: function.as_str().into(),
1450 })
1451 }
1452 AddZshHookTargetKind::Widget => Some(ZshExternalEntrypointAction::RegisterHookWidget {
1453 hook: hook.as_str().into(),
1454 widget: function.as_str().into(),
1455 }),
1456 },
1457 _ => None,
1458 }
1459}
1460
1461#[derive(Debug, Clone, Copy)]
1462pub(crate) enum AddZshHookTargetKind {
1463 Function,
1464 Widget,
1465}
1466
1467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1468pub(crate) enum AddZshHookRemovalMode {
1469 Exact,
1470 Pattern,
1471}
1472
1473pub(crate) fn add_zsh_hook_removal_mode(args: &[String]) -> Option<AddZshHookRemovalMode> {
1474 if args
1475 .iter()
1476 .any(|arg| add_zsh_hook_option_contains(arg, 'D'))
1477 {
1478 return Some(AddZshHookRemovalMode::Pattern);
1479 }
1480 args.iter()
1481 .any(|arg| add_zsh_hook_option_contains(arg, 'd'))
1482 .then_some(AddZshHookRemovalMode::Exact)
1483}
1484
1485pub(crate) fn add_zsh_hook_option_contains(arg: &str, flag: char) -> bool {
1486 arg.starts_with('-') && arg != "--" && arg.chars().skip(1).any(|c| c == flag)
1487}
1488
1489pub(crate) fn zsh_hook_function_pattern_matches(pattern: &str, function: &str) -> bool {
1490 if pattern_contains_unsupported_zsh_metachar(pattern) {
1491 return true;
1492 }
1493 zsh_simple_glob_pattern_matches(pattern.as_bytes(), function.as_bytes())
1494}
1495
1496pub(crate) fn zsh_simple_glob_pattern_matches(pattern: &[u8], text: &[u8]) -> bool {
1497 if pattern.is_empty() {
1498 return text.is_empty();
1499 }
1500
1501 match pattern[0] {
1502 b'*' => {
1503 zsh_simple_glob_pattern_matches(&pattern[1..], text)
1504 || (!text.is_empty() && zsh_simple_glob_pattern_matches(pattern, &text[1..]))
1505 }
1506 b'?' => !text.is_empty() && zsh_simple_glob_pattern_matches(&pattern[1..], &text[1..]),
1507 b'[' => zsh_bracket_pattern_matches(pattern, text),
1508 b'<' if pattern.starts_with(b"<->") => zsh_numeric_pattern_matches(&pattern[3..], text),
1509 literal => {
1510 text.first().is_some_and(|byte| *byte == literal)
1511 && zsh_simple_glob_pattern_matches(&pattern[1..], &text[1..])
1512 }
1513 }
1514}
1515
1516pub(crate) fn pattern_contains_unsupported_zsh_metachar(pattern: &str) -> bool {
1517 let mut in_bracket_class = false;
1518 for byte in pattern.bytes() {
1519 match byte {
1520 b'[' if !in_bracket_class => in_bracket_class = true,
1521 b']' if in_bracket_class => in_bracket_class = false,
1522 b'(' | b')' | b'|' | b'~' | b'#' if !in_bracket_class => return true,
1523 _ => {}
1524 }
1525 }
1526 false
1527}
1528
1529pub(crate) fn zsh_numeric_pattern_matches(pattern_after_numeric: &[u8], text: &[u8]) -> bool {
1530 let digit_count = text.iter().take_while(|byte| byte.is_ascii_digit()).count();
1531 (1..=digit_count)
1532 .any(|consumed| zsh_simple_glob_pattern_matches(pattern_after_numeric, &text[consumed..]))
1533}
1534
1535pub(crate) fn zsh_bracket_pattern_matches(pattern: &[u8], text: &[u8]) -> bool {
1536 let Some(&candidate) = text.first() else {
1537 return false;
1538 };
1539 let Some(close_index) = pattern.iter().position(|byte| *byte == b']') else {
1540 return candidate == b'[' && zsh_simple_glob_pattern_matches(&pattern[1..], &text[1..]);
1541 };
1542 if close_index == 1 {
1543 return candidate == b'[' && zsh_simple_glob_pattern_matches(&pattern[1..], &text[1..]);
1544 }
1545
1546 let class = &pattern[1..close_index];
1547 let (negated, class) = match class {
1548 [b'!' | b'^', rest @ ..] => (true, rest),
1549 _ => (false, class),
1550 };
1551 let matched = zsh_bracket_class_contains(class, candidate);
1552 if matched != negated {
1553 zsh_simple_glob_pattern_matches(&pattern[close_index + 1..], &text[1..])
1554 } else {
1555 false
1556 }
1557}
1558
1559pub(crate) fn zsh_bracket_class_contains(class: &[u8], candidate: u8) -> bool {
1560 let mut index = 0;
1561 while index < class.len() {
1562 if index + 2 < class.len() && class[index + 1] == b'-' {
1563 if class[index] <= candidate && candidate <= class[index + 2] {
1564 return true;
1565 }
1566 index += 3;
1567 } else {
1568 if class[index] == candidate {
1569 return true;
1570 }
1571 index += 1;
1572 }
1573 }
1574 false
1575}
1576
1577pub(crate) fn static_command_args(command: &CommandFact<'_>, source: &str) -> Option<Vec<String>> {
1578 command
1579 .body_args()
1580 .iter()
1581 .map(|word| static_word_text(word, source).map(|text| text.into_owned()))
1582 .collect()
1583}
1584
1585pub(crate) fn is_zsh_special_hook_name(name: &str) -> bool {
1586 matches!(
1587 name,
1588 "precmd"
1589 | "preexec"
1590 | "chpwd"
1591 | "periodic"
1592 | "zshaddhistory"
1593 | "zsh_directory_name"
1594 | "zshexit"
1595 )
1596}
1597
1598#[derive(Debug, Clone)]
1599pub(crate) struct CompletionRegisteredFunctionCandidate {
1600 scope: ScopeId,
1601 name: Box<str>,
1602}
1603
1604pub(crate) fn function_parameter_fallback_span(
1605 pair: &[&CommandFact<'_>],
1606 source: &str,
1607) -> Option<Span> {
1608 let [first, second] = pair else {
1609 return None;
1610 };
1611 let name = first.normalized().effective_or_literal_name()?;
1612 if !is_plausible_shell_function_name(name) || !first.normalized().body_args().is_empty() {
1613 return None;
1614 }
1615 if !matches!(first.command(), Command::Simple(_)) {
1616 return None;
1617 }
1618 let Command::Compound(CompoundCommand::Subshell(commands)) = second.command() else {
1619 return None;
1620 };
1621 if commands.is_empty() {
1622 return None;
1623 }
1624 if first.span().start.line != second.span().start.line {
1625 return None;
1626 }
1627 let tail = source.get(second.span().end.offset..)?;
1628 if !matches!(next_function_body_delimiter(tail), Some('{') | Some('(')) {
1629 return None;
1630 }
1631 let text = first.span().slice(source);
1632 let relative = text.find('(')?;
1633 let start = first.span().start.advanced_by(&text[..relative]);
1634 Some(Span::from_positions(start, start.advanced_by("(")))
1635}
1636
1637pub(crate) fn named_coproc_subshell_fallback_span(command: &CommandFact<'_>) -> Option<Span> {
1638 let Command::Compound(CompoundCommand::Coproc(coproc)) = command.command() else {
1639 return None;
1640 };
1641 coproc.name_span?;
1642 let Command::Compound(CompoundCommand::Subshell(commands)) = &coproc.body.command else {
1643 return None;
1644 };
1645 if commands.is_empty() {
1646 return None;
1647 }
1648 let body_start = coproc.body.span.start;
1649 if coproc.span.start.line != body_start.line {
1650 return None;
1651 }
1652 Some(Span::from_positions(body_start, body_start))
1653}
1654pub(crate) fn build_function_call_arity_facts<'a>(
1655 semantic_analysis: &SemanticAnalysis<'_>,
1656 functions: &[&FunctionDef],
1657 commands: &[CommandFact<'a>],
1658 command_fact_indices_by_id: &[Option<usize>],
1659 source: &str,
1660) -> FxHashMap<BindingId, FunctionCallArityFacts> {
1661 let mut facts = FxHashMap::<BindingId, FunctionCallArityFacts>::default();
1662 let mut seen_names = FxHashSet::default();
1663 let mut unique_function_names: Vec<&Name> = Vec::with_capacity(functions.len());
1664 for function in functions {
1665 let Some((name, _)) = function.static_name_entries().next() else {
1666 continue;
1667 };
1668 if seen_names.insert(name.clone()) {
1669 unique_function_names.push(name);
1670 }
1671 }
1672 if unique_function_names.is_empty() {
1673 return facts;
1674 }
1675
1676 let mut offsets = Vec::new();
1677 for name in &unique_function_names {
1678 for (site, _) in semantic_analysis.function_call_arity_sites(name) {
1679 offsets.push(site.name_span.start.offset);
1680 }
1681 }
1682 if offsets.is_empty() {
1683 return facts;
1684 }
1685
1686 let command_ids_by_offset = build_innermost_command_ids_by_offset(commands, offsets);
1687
1688 for name in unique_function_names {
1689 for (site, binding_id) in semantic_analysis.function_call_arity_sites(name) {
1690 let Some(command_id) = precomputed_command_id_for_offset(
1691 &command_ids_by_offset,
1692 site.name_span.start.offset,
1693 ) else {
1694 continue;
1695 };
1696 let command = command_fact(commands, command_fact_indices_by_id, command_id);
1697 if !command.wrappers().is_empty()
1698 || command.effective_or_literal_name() != Some(name.as_str())
1699 {
1700 continue;
1701 }
1702 let Some(name_word) = command.body_name_word() else {
1703 continue;
1704 };
1705 facts.entry(binding_id).or_default().record_call(
1706 site.arg_count,
1707 name_word.span,
1708 function_call_diagnostic_span(command, name_word.span, source),
1709 );
1710 }
1711 }
1712
1713 facts
1714}
1715
1716pub(crate) fn function_call_diagnostic_span(
1717 command: &CommandFact<'_>,
1718 name_span: Span,
1719 source: &str,
1720) -> Span {
1721 if command.redirects().is_empty() {
1722 return name_span;
1723 }
1724
1725 trim_trailing_whitespace_span(command.stmt().span, source)
1726}
1727
1728pub(crate) fn function_body_without_braces_span(function: &FunctionDef) -> Option<Span> {
1729 match &function.body.command {
1730 Command::Compound(
1731 CompoundCommand::BraceGroup(_)
1732 | CompoundCommand::Subshell(_)
1733 | CompoundCommand::Arithmetic(_),
1734 ) => None,
1735 Command::Compound(_) => Some(function.body.span),
1736 Command::Simple(_)
1737 | Command::Decl(_)
1738 | Command::Builtin(_)
1739 | Command::Binary(_)
1740 | Command::Function(_)
1741 | Command::AnonymousFunction(_) => None,
1742 }
1743}
1744
1745pub(crate) fn next_function_body_delimiter(text: &str) -> Option<char> {
1746 let mut tail = text;
1747
1748 loop {
1749 tail = trim_shell_layout_prefix(tail);
1750
1751 if let Some(rest) = tail.strip_prefix('#') {
1752 tail = rest.split_once('\n').map_or("", |(_, rest)| rest);
1753 continue;
1754 }
1755
1756 return tail.chars().next();
1757 }
1758}
1759
1760pub(crate) fn trim_shell_layout_prefix(text: &str) -> &str {
1761 let mut tail = text;
1762
1763 loop {
1764 tail = tail.trim_start_matches([' ', '\t', '\r', '\n']);
1765
1766 if let Some(rest) = tail
1767 .strip_prefix("\\\r\n")
1768 .or_else(|| tail.strip_prefix("\\\n"))
1769 {
1770 tail = rest;
1771 continue;
1772 }
1773
1774 return tail;
1775 }
1776}
1777
1778pub(crate) fn is_plausible_shell_function_name(name: &str) -> bool {
1779 let Some(first) = name.chars().next() else {
1780 return false;
1781 };
1782 if !matches!(first, 'a'..='z' | 'A'..='Z' | '_') {
1783 return false;
1784 }
1785 if !name
1786 .chars()
1787 .all(|ch| matches!(ch, 'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-'))
1788 {
1789 return false;
1790 }
1791 !matches!(
1792 name,
1793 "!" | "{"
1794 | "}"
1795 | "if"
1796 | "then"
1797 | "else"
1798 | "elif"
1799 | "fi"
1800 | "do"
1801 | "done"
1802 | "case"
1803 | "esac"
1804 | "for"
1805 | "in"
1806 | "while"
1807 | "until"
1808 | "time"
1809 | "[["
1810 | "]]"
1811 | "function"
1812 | "select"
1813 | "coproc"
1814 )
1815}
1816
1817pub(crate) fn build_function_positional_parameter_facts(
1818 semantic: &SemanticModel,
1819 commands: &[CommandFact<'_>],
1820 positional_parameter_fragments: &[PositionalParameterFragmentFact],
1821) -> FxHashMap<ScopeId, FunctionPositionalParameterFacts> {
1822 let local_reset_offsets_by_scope = local_positional_reset_offsets_by_scope(semantic, commands);
1823
1824 let mut facts = semantic
1825 .function_positional_reference_summary(&local_reset_offsets_by_scope)
1826 .into_iter()
1827 .map(|(scope, summary)| {
1828 (
1829 scope,
1830 FunctionPositionalParameterFacts {
1831 required_arg_count: summary.required_arg_count(),
1832 uses_unprotected_positional_parameters: summary
1833 .uses_unprotected_positional_parameters(),
1834 resets_positional_parameters: false,
1835 },
1836 )
1837 })
1838 .collect::<FxHashMap<_, _>>();
1839
1840 for fragment in positional_parameter_fragments {
1841 let fragment_offset = fragment.span().start.offset;
1842 let fragment_scope = semantic.scope_at(fragment_offset);
1843 if reference_has_local_positional_reset(
1844 semantic,
1845 fragment_scope,
1846 fragment_offset,
1847 &local_reset_offsets_by_scope,
1848 ) {
1849 continue;
1850 }
1851
1852 let Some(scope) = semantic.enclosing_function_scope(fragment_scope) else {
1853 continue;
1854 };
1855
1856 let entry = facts.entry(scope).or_default();
1857 if !fragment.is_guarded() {
1858 entry.uses_unprotected_positional_parameters = true;
1859 }
1860 }
1861
1862 for command in commands {
1863 let Some(scope) =
1864 semantic.enclosing_function_scope_without_transient_boundary(command.scope())
1865 else {
1866 continue;
1867 };
1868
1869 if command
1870 .options()
1871 .set()
1872 .is_some_and(|set| set.resets_positional_parameters())
1873 {
1874 facts.entry(scope).or_default().resets_positional_parameters = true;
1875 }
1876 }
1877
1878 facts
1879}
1880
1881fn local_positional_reset_offsets_by_scope(
1882 semantic: &SemanticModel,
1883 commands: &[CommandFact<'_>],
1884) -> FxHashMap<ScopeId, Vec<usize>> {
1885 let mut local_reset_offsets_by_scope: FxHashMap<ScopeId, Vec<usize>> = FxHashMap::default();
1886
1887 for command in commands {
1888 if !command
1889 .options()
1890 .set()
1891 .is_some_and(|set| set.resets_positional_parameters())
1892 {
1893 continue;
1894 }
1895
1896 let offset = command.span().start.offset;
1897 if let Some(scope) = semantic.innermost_transient_scope_within_function(command.scope()) {
1898 local_reset_offsets_by_scope
1899 .entry(scope)
1900 .or_default()
1901 .push(offset);
1902 }
1903 }
1904
1905 local_reset_offsets_by_scope
1906}
1907
1908pub(crate) fn reference_has_local_positional_reset(
1909 semantic: &SemanticModel,
1910 scope: ScopeId,
1911 offset: usize,
1912 local_reset_offsets_by_scope: &FxHashMap<ScopeId, Vec<usize>>,
1913) -> bool {
1914 semantic
1915 .transient_ancestor_scopes_within_function(scope)
1916 .any(|transient_scope| {
1917 local_reset_offsets_by_scope
1918 .get(&transient_scope)
1919 .is_some_and(|offsets| offsets.iter().any(|reset_offset| *reset_offset < offset))
1920 })
1921}