Skip to main content

shuck_linter/facts/
functions.rs

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}