Skip to main content

shuck_linter/facts/
conditional_portability.rs

1use rustc_hash::FxHashSet;
2use shuck_ast::{ConditionalBinaryOp, ConditionalUnaryOp, Span, Word, static_word_text};
3
4use super::{
5    CommandFact, ConditionalFact, ConditionalNodeFact, DenseCommandIdSet, ExpansionContext,
6    FactSpan, SimpleTestFact, SimpleTestSyntax, WordNode, WordOccurrence, word_spans,
7};
8use crate::facts::occurrence_word;
9
10#[derive(Debug, Clone, Default)]
11pub struct ConditionalPortabilityFacts {
12    double_bracket_in_sh: Vec<Span>,
13    test_equality_operator: Vec<Span>,
14    if_elif_bash_test: Vec<Span>,
15    extglob_in_sh: Vec<Span>,
16    caret_negation_in_bracket: Vec<Span>,
17    array_subscript_test: Vec<Span>,
18    array_subscript_condition: Vec<Span>,
19    extglob_in_test: Vec<Span>,
20    lexical_comparison_in_double_bracket: Vec<Span>,
21    regex_match_in_sh: Vec<Span>,
22    v_test_in_sh: Vec<Span>,
23    a_test_in_sh: Vec<Span>,
24    option_test_in_sh: Vec<Span>,
25    sticky_bit_test_in_sh: Vec<Span>,
26    ownership_test_in_sh: Vec<Span>,
27}
28
29impl ConditionalPortabilityFacts {
30    pub fn double_bracket_in_sh(&self) -> &[Span] {
31        &self.double_bracket_in_sh
32    }
33
34    pub fn test_equality_operator(&self) -> &[Span] {
35        &self.test_equality_operator
36    }
37
38    pub fn if_elif_bash_test(&self) -> &[Span] {
39        &self.if_elif_bash_test
40    }
41
42    pub fn extglob_in_sh(&self) -> &[Span] {
43        &self.extglob_in_sh
44    }
45
46    pub fn caret_negation_in_bracket(&self) -> &[Span] {
47        &self.caret_negation_in_bracket
48    }
49
50    pub fn array_subscript_test(&self) -> &[Span] {
51        &self.array_subscript_test
52    }
53
54    pub fn array_subscript_condition(&self) -> &[Span] {
55        &self.array_subscript_condition
56    }
57
58    pub fn extglob_in_test(&self) -> &[Span] {
59        &self.extglob_in_test
60    }
61
62    pub fn lexical_comparison_in_double_bracket(&self) -> &[Span] {
63        &self.lexical_comparison_in_double_bracket
64    }
65
66    pub fn regex_match_in_sh(&self) -> &[Span] {
67        &self.regex_match_in_sh
68    }
69
70    pub fn v_test_in_sh(&self) -> &[Span] {
71        &self.v_test_in_sh
72    }
73
74    pub fn a_test_in_sh(&self) -> &[Span] {
75        &self.a_test_in_sh
76    }
77
78    pub fn option_test_in_sh(&self) -> &[Span] {
79        &self.option_test_in_sh
80    }
81
82    pub fn sticky_bit_test_in_sh(&self) -> &[Span] {
83        &self.sticky_bit_test_in_sh
84    }
85
86    pub fn ownership_test_in_sh(&self) -> &[Span] {
87        &self.ownership_test_in_sh
88    }
89}
90
91pub(crate) struct ConditionalPortabilityInputs<'a> {
92    pub word_nodes: &'a [WordNode<'a>],
93    pub word_occurrences: &'a [WordOccurrence],
94    pub pattern_exactly_one_extglob_spans: &'a [Span],
95    pub pattern_charclass_spans: &'a [Span],
96    pub parameter_pattern_spans: &'a [Span],
97    pub nested_pattern_charclass_spans: &'a FxHashSet<FactSpan>,
98}
99
100#[cfg_attr(shuck_profiling, inline(never))]
101pub(crate) fn build_conditional_portability_facts<'a>(
102    commands: &[CommandFact<'a>],
103    elif_condition_command_ids: &DenseCommandIdSet,
104    inputs: ConditionalPortabilityInputs<'a>,
105    source: &str,
106) -> ConditionalPortabilityFacts {
107    let mut facts = ConditionalPortabilityFacts::default();
108
109    for command in commands {
110        if let Some(conditional) = command.conditional() {
111            facts.double_bracket_in_sh.push(command.span());
112
113            if elif_condition_command_ids.contains(command.id()) {
114                facts.if_elif_bash_test.push(command.span());
115            }
116
117            if let Some(span) =
118                word_spans::conditional_extglob_span(conditional.expression(), source)
119            {
120                facts.extglob_in_test.push(span);
121            }
122
123            if let Some(span) =
124                word_spans::conditional_array_subscript_span(conditional.expression(), source)
125            {
126                facts.array_subscript_condition.push(span);
127            }
128
129            collect_conditional_portability_spans(conditional, source, &mut facts);
130        }
131
132        if let Some(simple_test) = command.simple_test() {
133            facts.extglob_in_test.extend(
134                simple_test
135                    .operands()
136                    .iter()
137                    .filter_map(|word| word_spans::word_extglob_span(word, source)),
138            );
139            collect_simple_test_portability_spans(command, simple_test, source, &mut facts);
140        }
141    }
142
143    facts
144        .extglob_in_sh
145        .extend(inputs.pattern_exactly_one_extglob_spans.iter().copied());
146
147    facts.caret_negation_in_bracket.extend(
148        inputs
149            .pattern_charclass_spans
150            .iter()
151            .filter(|span| !span_is_within_any(**span, inputs.parameter_pattern_spans))
152            .filter(|span| {
153                !inputs
154                    .nested_pattern_charclass_spans
155                    .contains(&FactSpan::new(**span))
156            })
157            .filter(|span| word_spans::text_looks_like_caret_negated_bracket(span.slice(source)))
158            .copied(),
159    );
160
161    for fact in inputs.word_occurrences {
162        let expansion_context = match fact.context {
163            super::WordFactContext::Expansion(context) => Some(context),
164            super::WordFactContext::CaseSubject
165            | super::WordFactContext::ArithmeticCommand
166            | super::WordFactContext::ParameterOperand => None,
167        };
168        let word = occurrence_word(inputs.word_nodes, fact);
169        if supports_extglob_portability_context(expansion_context)
170            && let Some(span) = word_spans::word_exactly_one_extglob_span(word, source)
171        {
172            facts.extglob_in_sh.push(span);
173        }
174
175        if supports_bracket_glob_portability_context(expansion_context) {
176            facts
177                .caret_negation_in_bracket
178                .extend(word_spans::word_caret_negated_bracket_spans(word, source));
179        }
180    }
181
182    facts
183}
184
185fn span_is_within_any(span: Span, containers: &[Span]) -> bool {
186    containers.iter().any(|container| {
187        container.start.offset <= span.start.offset && span.end.offset <= container.end.offset
188    })
189}
190
191fn collect_conditional_portability_spans(
192    conditional: &ConditionalFact<'_>,
193    source: &str,
194    facts: &mut ConditionalPortabilityFacts,
195) {
196    for node in conditional.nodes() {
197        match node {
198            ConditionalNodeFact::Binary(binary) => match binary.op() {
199                ConditionalBinaryOp::PatternEq => {
200                    facts.test_equality_operator.push(binary.operator_span());
201                }
202                ConditionalBinaryOp::LexicalBefore | ConditionalBinaryOp::LexicalAfter => {
203                    facts
204                        .lexical_comparison_in_double_bracket
205                        .push(binary.operator_span());
206                }
207                ConditionalBinaryOp::RegexMatch => {
208                    facts.regex_match_in_sh.push(binary.operator_span());
209                }
210                _ => {}
211            },
212            ConditionalNodeFact::Unary(unary) => match unary.op() {
213                ConditionalUnaryOp::VariableSet => {
214                    facts.v_test_in_sh.push(unary.operator_span());
215                }
216                ConditionalUnaryOp::Exists if unary.operator_span().slice(source) == "-a" => {
217                    facts.a_test_in_sh.push(unary.operator_span());
218                }
219                ConditionalUnaryOp::OptionSet => {
220                    facts.option_test_in_sh.push(unary.operator_span());
221                }
222                _ => {}
223            },
224            ConditionalNodeFact::BareWord(_) | ConditionalNodeFact::Other(_) => {}
225        }
226    }
227}
228
229fn collect_simple_test_portability_spans(
230    command: &CommandFact<'_>,
231    fact: &SimpleTestFact<'_>,
232    source: &str,
233    facts: &mut ConditionalPortabilityFacts,
234) {
235    let operands = fact.operands();
236    let operand_texts = operands
237        .iter()
238        .map(|word| static_word_text(word, source))
239        .collect::<Vec<_>>();
240
241    let mut has_eqeq = false;
242    let mut has_sticky_bit = false;
243    let mut has_ownership = false;
244    let mut index = 0usize;
245
246    while index < operands.len() {
247        while index < operands.len() && is_simple_test_separator(operand_texts[index].as_deref()) {
248            index += 1;
249        }
250        while index < operands.len() && operand_texts[index].as_deref() == Some("!") {
251            index += 1;
252        }
253
254        if index >= operands.len() {
255            break;
256        }
257
258        if index + 2 < operands.len()
259            && operand_texts[index + 1]
260                .as_deref()
261                .is_some_and(is_simple_test_binary_operator)
262        {
263            match operand_texts[index + 1].as_deref() {
264                Some("=" | "==" | "!=") => {
265                    if let Some(span) = reportable_simple_test_glob_span(operands[index], source) {
266                        facts.array_subscript_test.push(span);
267                    }
268                }
269                Some(
270                    "<" | ">" | "-eq" | "-ne" | "-lt" | "-le" | "-gt" | "-ge" | "-ef" | "-nt"
271                    | "-ot",
272                ) => {
273                    if let Some(span) = reportable_simple_test_glob_span(operands[index], source) {
274                        facts.array_subscript_test.push(span);
275                    }
276                    if let Some(span) =
277                        reportable_simple_test_glob_span(operands[index + 2], source)
278                    {
279                        facts.array_subscript_test.push(span);
280                    }
281                }
282                _ => {}
283            }
284
285            if operand_texts[index + 1].as_deref() == Some("==") {
286                match fact.syntax() {
287                    SimpleTestSyntax::Test => has_eqeq = true,
288                    SimpleTestSyntax::Bracket => {
289                        facts.test_equality_operator.push(operands[index + 1].span);
290                    }
291                }
292            }
293            index += 3;
294            continue;
295        }
296
297        if index + 1 < operands.len()
298            && operand_texts[index]
299                .as_deref()
300                .is_some_and(is_simple_test_unary_operator)
301        {
302            if matches!(operand_texts[index].as_deref(), Some("-n" | "-z"))
303                && let Some(span) = reportable_simple_test_glob_span(operands[index + 1], source)
304            {
305                facts.array_subscript_test.push(span);
306            }
307
308            match operand_texts[index].as_deref() {
309                Some("-k") => match fact.syntax() {
310                    SimpleTestSyntax::Test => has_sticky_bit = true,
311                    SimpleTestSyntax::Bracket => {
312                        facts.sticky_bit_test_in_sh.push(operands[index].span);
313                    }
314                },
315                Some("-O") => match fact.syntax() {
316                    SimpleTestSyntax::Test => has_ownership = true,
317                    SimpleTestSyntax::Bracket => {
318                        facts.ownership_test_in_sh.push(operands[index].span);
319                    }
320                },
321                _ => {}
322            }
323
324            index += 2;
325            continue;
326        }
327
328        if let Some(span) = reportable_simple_test_glob_span(operands[index], source) {
329            facts.array_subscript_test.push(span);
330        }
331        index += 1;
332    }
333
334    if fact.syntax() == SimpleTestSyntax::Test {
335        let Some(command_span) = simple_test_command_span(command, fact) else {
336            return;
337        };
338
339        if has_eqeq {
340            facts.test_equality_operator.push(command_span);
341        }
342        if has_sticky_bit {
343            facts.sticky_bit_test_in_sh.push(command_span);
344        }
345        if has_ownership {
346            facts.ownership_test_in_sh.push(command_span);
347        }
348    }
349}
350
351fn supports_extglob_portability_context(context: Option<ExpansionContext>) -> bool {
352    matches!(
353        context,
354        Some(
355            ExpansionContext::CommandName
356                | ExpansionContext::CommandArgument
357                | ExpansionContext::ForList
358                | ExpansionContext::SelectList
359        )
360    )
361}
362
363fn reportable_simple_test_glob_span(word: &Word, source: &str) -> Option<Span> {
364    (!word_spans::word_unquoted_glob_pattern_spans(word, source).is_empty()).then_some(word.span)
365}
366
367fn supports_bracket_glob_portability_context(context: Option<ExpansionContext>) -> bool {
368    matches!(
369        context,
370        Some(
371            ExpansionContext::CommandArgument
372                | ExpansionContext::ForList
373                | ExpansionContext::SelectList
374        )
375    )
376}
377
378fn is_simple_test_separator(token: Option<&str>) -> bool {
379    matches!(token, Some("-a" | "-o" | "(" | ")" | "\\(" | "\\)"))
380}
381
382fn is_simple_test_binary_operator(token: &str) -> bool {
383    matches!(
384        token,
385        "=" | "=="
386            | "!="
387            | "<"
388            | ">"
389            | "-eq"
390            | "-ne"
391            | "-lt"
392            | "-le"
393            | "-gt"
394            | "-ge"
395            | "-ef"
396            | "-nt"
397            | "-ot"
398    )
399}
400
401fn is_simple_test_unary_operator(token: &str) -> bool {
402    matches!(
403        token,
404        "-a" | "-b"
405            | "-c"
406            | "-d"
407            | "-e"
408            | "-f"
409            | "-g"
410            | "-h"
411            | "-k"
412            | "-L"
413            | "-n"
414            | "-N"
415            | "-O"
416            | "-p"
417            | "-r"
418            | "-s"
419            | "-S"
420            | "-t"
421            | "-u"
422            | "-v"
423            | "-w"
424            | "-x"
425            | "-z"
426    )
427}
428
429fn simple_test_command_span(command: &CommandFact<'_>, fact: &SimpleTestFact<'_>) -> Option<Span> {
430    let name = command.body_name_word()?;
431    let end = fact
432        .operands()
433        .last()
434        .map(|word| word.span.end)
435        .unwrap_or(name.span.end);
436    Some(Span::from_positions(name.span.start, end))
437}