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}