1use crate::{OptionValue, ShellBehaviorAt, ShellDialect};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum FieldSplittingBehavior {
6 Never,
8 UnquotedOnly,
10 Ambiguous,
12}
13
14impl FieldSplittingBehavior {
15 pub fn unquoted_results_can_split(self) -> bool {
17 matches!(self, Self::UnquotedOnly | Self::Ambiguous)
18 }
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum PathnameExpansionBehavior {
24 Disabled,
26 LiteralGlobsOnly,
28 LiteralGlobsOnlyOrDisabled,
30 SubstitutionResultsWhenUnquoted,
32 Ambiguous,
34}
35
36impl PathnameExpansionBehavior {
37 pub fn literal_globs_can_expand(self) -> bool {
39 !matches!(self, Self::Disabled)
40 }
41
42 pub fn unquoted_substitution_results_can_glob(self) -> bool {
44 matches!(
45 self,
46 Self::SubstitutionResultsWhenUnquoted | Self::Ambiguous
47 )
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum FileExpansionOrderBehavior {
54 BeforeParameterExpansion,
56 AfterParameterExpansion,
58 Ambiguous,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum GlobFailureBehavior {
65 ErrorOnNoMatch,
67 KeepLiteralOnNoMatch,
69 DropUnmatchedPattern,
71 CshNullGlob,
73 Ambiguous,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum GlobDotBehavior {
80 ExplicitDotRequired,
82 DotfilesIncluded,
84 Ambiguous,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum PatternOperatorBehavior {
91 Disabled,
93 Enabled,
95 Ambiguous,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum BraceCharacterClassBehavior {
102 Disabled,
104 Enabled,
106 Ambiguous,
108}
109
110impl BraceCharacterClassBehavior {
111 pub fn can_expand(self) -> bool {
113 matches!(self, Self::Enabled | Self::Ambiguous)
114 }
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub struct GlobPatternBehavior {
120 extended_glob: PatternOperatorBehavior,
121 ksh_glob: PatternOperatorBehavior,
122 sh_glob: PatternOperatorBehavior,
123}
124
125impl GlobPatternBehavior {
126 pub const fn from_parts(
128 extended_glob: PatternOperatorBehavior,
129 ksh_glob: PatternOperatorBehavior,
130 sh_glob: PatternOperatorBehavior,
131 ) -> Self {
132 Self {
133 extended_glob,
134 ksh_glob,
135 sh_glob,
136 }
137 }
138
139 pub fn extended_glob(self) -> PatternOperatorBehavior {
141 self.extended_glob
142 }
143
144 pub fn ksh_glob(self) -> PatternOperatorBehavior {
146 self.ksh_glob
147 }
148
149 pub fn sh_glob(self) -> PatternOperatorBehavior {
151 self.sh_glob
152 }
153}
154
155impl ShellBehaviorAt<'_> {
156 pub fn field_splitting(&self) -> FieldSplittingBehavior {
158 if self.shell != ShellDialect::Zsh {
159 return FieldSplittingBehavior::UnquotedOnly;
160 }
161
162 match self
163 .effective_zsh_options()
164 .map(|options| options.sh_word_split)
165 {
166 Some(OptionValue::Off) => FieldSplittingBehavior::Never,
167 Some(OptionValue::Unknown) => FieldSplittingBehavior::Ambiguous,
168 Some(OptionValue::On) | None => FieldSplittingBehavior::UnquotedOnly,
169 }
170 }
171
172 pub fn pathname_expansion(&self) -> PathnameExpansionBehavior {
174 if self.shell != ShellDialect::Zsh {
175 return PathnameExpansionBehavior::SubstitutionResultsWhenUnquoted;
176 }
177
178 let Some(options) = self.effective_zsh_options() else {
179 return PathnameExpansionBehavior::LiteralGlobsOnly;
180 };
181
182 match options.glob {
183 OptionValue::Off => PathnameExpansionBehavior::Disabled,
184 OptionValue::Unknown => match options.glob_subst {
185 OptionValue::Off => PathnameExpansionBehavior::LiteralGlobsOnlyOrDisabled,
186 OptionValue::On | OptionValue::Unknown => PathnameExpansionBehavior::Ambiguous,
187 },
188 OptionValue::On => match options.glob_subst {
189 OptionValue::Off => PathnameExpansionBehavior::LiteralGlobsOnly,
190 OptionValue::On => PathnameExpansionBehavior::SubstitutionResultsWhenUnquoted,
191 OptionValue::Unknown => PathnameExpansionBehavior::Ambiguous,
192 },
193 }
194 }
195
196 pub fn file_expansion_order(&self) -> FileExpansionOrderBehavior {
198 if self.shell != ShellDialect::Zsh {
199 return FileExpansionOrderBehavior::BeforeParameterExpansion;
200 }
201
202 match self
203 .effective_zsh_options()
204 .map(|options| options.sh_file_expansion)
205 {
206 Some(OptionValue::On) => FileExpansionOrderBehavior::BeforeParameterExpansion,
207 Some(OptionValue::Unknown) => FileExpansionOrderBehavior::Ambiguous,
208 Some(OptionValue::Off) | None => FileExpansionOrderBehavior::AfterParameterExpansion,
209 }
210 }
211
212 pub fn glob_failure(&self) -> GlobFailureBehavior {
214 if self.shell != ShellDialect::Zsh {
215 return GlobFailureBehavior::KeepLiteralOnNoMatch;
216 }
217
218 let Some(options) = self.effective_zsh_options() else {
219 return GlobFailureBehavior::ErrorOnNoMatch;
220 };
221
222 match options.glob {
223 OptionValue::Off => GlobFailureBehavior::KeepLiteralOnNoMatch,
224 OptionValue::Unknown => GlobFailureBehavior::Ambiguous,
225 OptionValue::On => match options.csh_null_glob {
226 OptionValue::On => GlobFailureBehavior::CshNullGlob,
227 OptionValue::Unknown => GlobFailureBehavior::Ambiguous,
228 OptionValue::Off => match options.null_glob {
229 OptionValue::On => GlobFailureBehavior::DropUnmatchedPattern,
230 OptionValue::Unknown => GlobFailureBehavior::Ambiguous,
231 OptionValue::Off => match options.nomatch {
232 OptionValue::On => GlobFailureBehavior::ErrorOnNoMatch,
233 OptionValue::Off => GlobFailureBehavior::KeepLiteralOnNoMatch,
234 OptionValue::Unknown => GlobFailureBehavior::Ambiguous,
235 },
236 },
237 },
238 }
239 }
240
241 pub fn glob_dot_matching(&self) -> GlobDotBehavior {
243 if self.shell != ShellDialect::Zsh {
244 return GlobDotBehavior::ExplicitDotRequired;
245 }
246
247 match self
248 .effective_zsh_options()
249 .map(|options| options.glob_dots)
250 {
251 Some(OptionValue::On) => GlobDotBehavior::DotfilesIncluded,
252 Some(OptionValue::Unknown) => GlobDotBehavior::Ambiguous,
253 Some(OptionValue::Off) | None => GlobDotBehavior::ExplicitDotRequired,
254 }
255 }
256
257 pub fn glob_pattern(&self) -> GlobPatternBehavior {
259 if self.shell != ShellDialect::Zsh {
260 return GlobPatternBehavior::from_parts(
261 PatternOperatorBehavior::Disabled,
262 PatternOperatorBehavior::Disabled,
263 PatternOperatorBehavior::Disabled,
264 );
265 }
266
267 let Some(options) = self.effective_zsh_options() else {
268 return GlobPatternBehavior::from_parts(
269 PatternOperatorBehavior::Disabled,
270 PatternOperatorBehavior::Disabled,
271 PatternOperatorBehavior::Disabled,
272 );
273 };
274
275 GlobPatternBehavior::from_parts(
276 pattern_operator_behavior(options.extended_glob),
277 pattern_operator_behavior(options.ksh_glob),
278 pattern_operator_behavior(options.sh_glob),
279 )
280 }
281
282 pub fn brace_character_classes(&self) -> BraceCharacterClassBehavior {
284 if self.shell != ShellDialect::Zsh {
285 return BraceCharacterClassBehavior::Disabled;
286 }
287
288 match self
289 .effective_zsh_options()
290 .map(|options| options.brace_ccl)
291 {
292 Some(OptionValue::On) => BraceCharacterClassBehavior::Enabled,
293 Some(OptionValue::Unknown) => BraceCharacterClassBehavior::Ambiguous,
294 Some(OptionValue::Off) | None => BraceCharacterClassBehavior::Disabled,
295 }
296 }
297}
298
299fn pattern_operator_behavior(value: OptionValue) -> PatternOperatorBehavior {
300 match value {
301 OptionValue::Off => PatternOperatorBehavior::Disabled,
302 OptionValue::On => PatternOperatorBehavior::Enabled,
303 OptionValue::Unknown => PatternOperatorBehavior::Ambiguous,
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use crate::{SemanticBuildOptions, SemanticModel, ShellProfile};
311 use shuck_indexer::Indexer;
312 use shuck_parser::parser::Parser;
313
314 fn model_with_profile(source: &str, profile: ShellProfile) -> SemanticModel {
315 let output = Parser::with_profile(source, profile.clone())
316 .parse()
317 .unwrap();
318 let indexer = Indexer::new(source, &output);
319 SemanticModel::build_with_options(
320 &output.file,
321 source,
322 &indexer,
323 SemanticBuildOptions {
324 shell_profile: Some(profile),
325 ..SemanticBuildOptions::default()
326 },
327 )
328 }
329
330 #[test]
331 fn tracks_field_splitting_by_offset() {
332 for (source, expected) in [
333 ("print $name\n", FieldSplittingBehavior::Never),
334 (
335 "setopt sh_word_split\nprint $name\n",
336 FieldSplittingBehavior::UnquotedOnly,
337 ),
338 (
339 "opt=sh_word_split\nsetopt \"$opt\"\nprint $name\n",
340 FieldSplittingBehavior::Ambiguous,
341 ),
342 ] {
343 let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
344 let offset = source.find("print").expect("expected print offset");
345
346 assert_eq!(
347 model.shell_behavior_at(offset).field_splitting(),
348 expected,
349 "{source}"
350 );
351 }
352 }
353
354 #[test]
355 fn uses_non_zsh_default_behaviors() {
356 let source = "printf '%s\\n' $name *.txt\n";
357 let model = model_with_profile(source, ShellProfile::native(ShellDialect::Bash));
358 let offset = source.find("printf").expect("expected printf offset");
359 let behavior = model.shell_behavior_at(offset);
360
361 assert_eq!(
362 behavior.field_splitting(),
363 FieldSplittingBehavior::UnquotedOnly
364 );
365 assert_eq!(
366 behavior.pathname_expansion(),
367 PathnameExpansionBehavior::SubstitutionResultsWhenUnquoted
368 );
369 assert_eq!(
370 behavior.glob_failure(),
371 GlobFailureBehavior::KeepLiteralOnNoMatch
372 );
373 assert_eq!(
374 behavior.glob_dot_matching(),
375 GlobDotBehavior::ExplicitDotRequired
376 );
377 let pattern = behavior.glob_pattern();
378 assert_eq!(pattern.extended_glob(), PatternOperatorBehavior::Disabled);
379 assert_eq!(pattern.ksh_glob(), PatternOperatorBehavior::Disabled);
380 assert_eq!(pattern.sh_glob(), PatternOperatorBehavior::Disabled);
381 assert_eq!(
382 behavior.brace_character_classes(),
383 BraceCharacterClassBehavior::Disabled
384 );
385 }
386
387 #[test]
388 fn tracks_brace_character_classes_by_offset() {
389 for (source, expected) in [
390 ("print {app}\n", BraceCharacterClassBehavior::Disabled),
391 (
392 "setopt brace_ccl\nprint {app}\n",
393 BraceCharacterClassBehavior::Enabled,
394 ),
395 (
396 "setopt brace_ccl\nunsetopt brace_ccl\nprint {app}\n",
397 BraceCharacterClassBehavior::Disabled,
398 ),
399 (
400 "opt=brace_ccl\nsetopt \"$opt\"\nprint {app}\n",
401 BraceCharacterClassBehavior::Ambiguous,
402 ),
403 ] {
404 let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
405 let offset = source.find("print").expect("expected print offset");
406
407 assert_eq!(
408 model.shell_behavior_at(offset).brace_character_classes(),
409 expected,
410 "{source}"
411 );
412 }
413 }
414
415 #[test]
416 fn tracks_pathname_expansion_by_offset() {
417 for (source, expected) in [
418 ("print $name\n", PathnameExpansionBehavior::LiteralGlobsOnly),
419 (
420 "setopt glob_subst\nprint $name\n",
421 PathnameExpansionBehavior::SubstitutionResultsWhenUnquoted,
422 ),
423 (
424 "setopt no_glob\nprint $name\n",
425 PathnameExpansionBehavior::Disabled,
426 ),
427 (
428 "opt=glob_subst\nsetopt \"$opt\"\nprint $name\n",
429 PathnameExpansionBehavior::Ambiguous,
430 ),
431 (
432 "if cond; then setopt no_glob; fi\nprint $name\n",
433 PathnameExpansionBehavior::LiteralGlobsOnlyOrDisabled,
434 ),
435 ] {
436 let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
437 let offset = source.find("print").expect("expected print offset");
438
439 assert_eq!(
440 model.shell_behavior_at(offset).pathname_expansion(),
441 expected,
442 "{source}"
443 );
444 }
445 }
446
447 #[test]
448 fn pathname_expansion_respects_no_glob_precedence() {
449 for (source, expected) in [
450 (
451 "setopt no_glob glob_subst\nprint $name\n",
452 PathnameExpansionBehavior::Disabled,
453 ),
454 (
455 "setopt glob_subst\nprint $name\n",
456 PathnameExpansionBehavior::SubstitutionResultsWhenUnquoted,
457 ),
458 (
459 "unsetopt glob_subst\nprint $name\n",
460 PathnameExpansionBehavior::LiteralGlobsOnly,
461 ),
462 ] {
463 let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
464 let offset = source.find("print").expect("expected print offset");
465
466 assert_eq!(
467 model.shell_behavior_at(offset).pathname_expansion(),
468 expected,
469 "{source}"
470 );
471 }
472 }
473
474 #[test]
475 fn file_expansion_order_tracks_sh_file_expansion_by_offset() {
476 let source = "\
477print ~$USER
478setopt sh_file_expansion
479print ~$USER
480unsetopt sh_file_expansion
481print ~$USER
482opt=sh_file_expansion
483setopt \"$opt\"
484print ~$USER
485";
486 let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
487
488 let first = source.find("print ~$USER").unwrap();
489 let enabled = source.find("print ~$USER\nunsetopt").unwrap();
490 let disabled = source.find("print ~$USER\nopt=").unwrap();
491 let ambiguous = source.rfind("print ~$USER").unwrap();
492
493 assert_eq!(
494 model.shell_behavior_at(first).file_expansion_order(),
495 FileExpansionOrderBehavior::AfterParameterExpansion,
496 );
497 assert_eq!(
498 model.shell_behavior_at(enabled).file_expansion_order(),
499 FileExpansionOrderBehavior::BeforeParameterExpansion,
500 );
501 assert_eq!(
502 model.shell_behavior_at(disabled).file_expansion_order(),
503 FileExpansionOrderBehavior::AfterParameterExpansion,
504 );
505 assert_eq!(
506 model.shell_behavior_at(ambiguous).file_expansion_order(),
507 FileExpansionOrderBehavior::Ambiguous,
508 );
509 }
510
511 #[test]
512 fn pathname_expansion_keeps_glob_subst_off_when_glob_is_flow_merged() {
513 let source = "\
514if cond; then
515 setopt no_glob
516fi
517print $name
518";
519 let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
520 let offset = source.find("print").expect("expected print offset");
521 let behavior = model.shell_behavior_at(offset).pathname_expansion();
522
523 assert_eq!(
524 behavior,
525 PathnameExpansionBehavior::LiteralGlobsOnlyOrDisabled
526 );
527 assert!(behavior.literal_globs_can_expand());
528 assert!(!behavior.unquoted_substitution_results_can_glob());
529 }
530
531 #[test]
532 fn tracks_function_leaked_option_effects_by_offset() {
533 let source = "\
534fn() {
535 setopt sh_word_split glob_subst
536}
537fn
538print $name
539";
540 let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
541 let offset = source.rfind("print").expect("expected print offset");
542
543 assert_eq!(
544 model.shell_behavior_at(offset).field_splitting(),
545 FieldSplittingBehavior::UnquotedOnly
546 );
547 assert_eq!(
548 model.shell_behavior_at(offset).pathname_expansion(),
549 PathnameExpansionBehavior::SubstitutionResultsWhenUnquoted
550 );
551 assert_eq!(
552 model.shell_behavior_at(offset).glob_failure(),
553 GlobFailureBehavior::ErrorOnNoMatch
554 );
555 }
556
557 #[test]
558 fn tracks_glob_failure_modes_by_offset() {
559 for (source, expected) in [
560 ("print *\n", GlobFailureBehavior::ErrorOnNoMatch),
561 (
562 "setopt no_nomatch\nprint *\n",
563 GlobFailureBehavior::KeepLiteralOnNoMatch,
564 ),
565 (
566 "setopt null_glob\nprint *\n",
567 GlobFailureBehavior::DropUnmatchedPattern,
568 ),
569 (
570 "setopt csh_null_glob\nprint *\n",
571 GlobFailureBehavior::CshNullGlob,
572 ),
573 (
574 "opt=no_nomatch\nsetopt \"$opt\"\nprint *\n",
575 GlobFailureBehavior::Ambiguous,
576 ),
577 ] {
578 let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
579 let offset = source.find("print").expect("expected print offset");
580
581 assert_eq!(
582 model.shell_behavior_at(offset).glob_failure(),
583 expected,
584 "{source}"
585 );
586 }
587 }
588
589 #[test]
590 fn glob_failure_respects_option_precedence() {
591 for (source, expected) in [
592 (
593 "setopt no_glob null_glob csh_null_glob\nprint *\n",
594 GlobFailureBehavior::KeepLiteralOnNoMatch,
595 ),
596 (
597 "setopt null_glob csh_null_glob\nprint *\n",
598 GlobFailureBehavior::CshNullGlob,
599 ),
600 (
601 "setopt null_glob\nunsetopt csh_null_glob\nprint *\n",
602 GlobFailureBehavior::DropUnmatchedPattern,
603 ),
604 (
605 "setopt no_nomatch null_glob csh_null_glob\nprint *\n",
606 GlobFailureBehavior::CshNullGlob,
607 ),
608 ] {
609 let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
610 let offset = source.find("print").expect("expected print offset");
611
612 assert_eq!(
613 model.shell_behavior_at(offset).glob_failure(),
614 expected,
615 "{source}"
616 );
617 }
618 }
619
620 #[test]
621 fn tracks_glob_dot_matching_by_offset() {
622 for (source, expected) in [
623 ("print *\n", GlobDotBehavior::ExplicitDotRequired),
624 (
625 "setopt glob_dots\nprint *\n",
626 GlobDotBehavior::DotfilesIncluded,
627 ),
628 (
629 "setopt glob_dots\nunsetopt glob_dots\nprint *\n",
630 GlobDotBehavior::ExplicitDotRequired,
631 ),
632 (
633 "opt=glob_dots\nsetopt \"$opt\"\nprint *\n",
634 GlobDotBehavior::Ambiguous,
635 ),
636 ] {
637 let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
638 let offset = source.find("print").expect("expected print offset");
639
640 assert_eq!(
641 model.shell_behavior_at(offset).glob_dot_matching(),
642 expected,
643 "{source}"
644 );
645 }
646 }
647
648 #[test]
649 fn tracks_glob_pattern_operator_families_by_offset() {
650 let source = "\
651print *
652setopt extended_glob ksh_glob sh_glob
653print *
654unsetopt extended_glob
655print *
656unsetopt ksh_glob
657print *
658unsetopt sh_glob
659print *
660opt=extended_glob
661unsetopt \"$opt\"
662print *
663";
664 let model = model_with_profile(source, ShellProfile::native(ShellDialect::Zsh));
665 let mut print_offsets = source.match_indices("print").map(|(offset, _)| offset);
666
667 let native = model
668 .shell_behavior_at(print_offsets.next().expect("expected native print"))
669 .glob_pattern();
670 assert_eq!(native.extended_glob(), PatternOperatorBehavior::Disabled);
671 assert_eq!(native.ksh_glob(), PatternOperatorBehavior::Disabled);
672 assert_eq!(native.sh_glob(), PatternOperatorBehavior::Disabled);
673
674 let enabled = model
675 .shell_behavior_at(print_offsets.next().expect("expected enabled print"))
676 .glob_pattern();
677 assert_eq!(enabled.extended_glob(), PatternOperatorBehavior::Enabled);
678 assert_eq!(enabled.ksh_glob(), PatternOperatorBehavior::Enabled);
679 assert_eq!(enabled.sh_glob(), PatternOperatorBehavior::Enabled);
680
681 let only_ksh_and_sh = model
682 .shell_behavior_at(print_offsets.next().expect("expected partial print"))
683 .glob_pattern();
684 assert_eq!(
685 only_ksh_and_sh.extended_glob(),
686 PatternOperatorBehavior::Disabled
687 );
688 assert_eq!(only_ksh_and_sh.ksh_glob(), PatternOperatorBehavior::Enabled);
689 assert_eq!(only_ksh_and_sh.sh_glob(), PatternOperatorBehavior::Enabled);
690
691 let only_sh = model
692 .shell_behavior_at(print_offsets.next().expect("expected sh print"))
693 .glob_pattern();
694 assert_eq!(only_sh.extended_glob(), PatternOperatorBehavior::Disabled);
695 assert_eq!(only_sh.ksh_glob(), PatternOperatorBehavior::Disabled);
696 assert_eq!(only_sh.sh_glob(), PatternOperatorBehavior::Enabled);
697
698 let disabled_again = model
699 .shell_behavior_at(print_offsets.next().expect("expected disabled print"))
700 .glob_pattern();
701 assert_eq!(
702 disabled_again.extended_glob(),
703 PatternOperatorBehavior::Disabled
704 );
705 assert_eq!(disabled_again.ksh_glob(), PatternOperatorBehavior::Disabled);
706 assert_eq!(disabled_again.sh_glob(), PatternOperatorBehavior::Disabled);
707
708 let ambiguous = model
709 .shell_behavior_at(print_offsets.next().expect("expected ambiguous print"))
710 .glob_pattern();
711 assert_eq!(
712 ambiguous.extended_glob(),
713 PatternOperatorBehavior::Ambiguous
714 );
715 assert_eq!(ambiguous.ksh_glob(), PatternOperatorBehavior::Ambiguous);
716 assert_eq!(ambiguous.sh_glob(), PatternOperatorBehavior::Ambiguous);
717 }
718}