1use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11use serde::Serialize;
12
13use crate::fs::Fs;
14use crate::packs::Pack;
15use crate::Result;
16
17#[derive(Debug, Clone, Serialize)]
21pub struct Rule {
22 pub pattern: String,
25
26 pub handler: String,
28
29 pub priority: i32,
31
32 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
34 pub options: HashMap<String, String>,
35}
36
37#[derive(Debug, Clone)]
39pub struct PackEntry {
40 pub relative_path: PathBuf,
42 pub absolute_path: PathBuf,
44 pub is_dir: bool,
46}
47
48#[derive(Debug, Clone, Serialize)]
50pub struct RuleMatch {
51 pub relative_path: PathBuf,
53
54 pub absolute_path: PathBuf,
56
57 pub pack: String,
59
60 pub handler: String,
62
63 pub is_dir: bool,
65
66 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
68 pub options: HashMap<String, String>,
69
70 #[serde(skip_serializing_if = "Option::is_none")]
73 pub preprocessor_source: Option<PathBuf>,
74}
75
76pub fn group_by_handler(matches: &[RuleMatch]) -> HashMap<String, Vec<RuleMatch>> {
80 let mut groups: HashMap<String, Vec<RuleMatch>> = HashMap::new();
81 for m in matches {
82 groups.entry(m.handler.clone()).or_default().push(m.clone());
83 }
84 groups
85}
86
87pub fn handler_execution_order(
100 groups: &HashMap<String, Vec<RuleMatch>>,
101 registry: &HashMap<String, Box<dyn crate::handlers::Handler + '_>>,
102) -> Vec<String> {
103 let mut names: Vec<String> = groups.keys().cloned().collect();
104 names.sort_by(|a, b| {
105 let pa = registry.get(a).map(|h| h.phase());
106 let pb = registry.get(b).map(|h| h.phase());
107 match (pa, pb) {
108 (Some(x), Some(y)) => x.cmp(&y),
109 (Some(_), None) => std::cmp::Ordering::Less,
110 (None, Some(_)) => std::cmp::Ordering::Greater,
111 (None, None) => a.cmp(b),
112 }
113 });
114 names
115}
116
117#[derive(Debug)]
121enum CompiledPattern {
122 Exact(String),
124 Glob(glob::Pattern),
126 Directory(String),
129}
130
131#[derive(Debug)]
133struct CompiledRule {
134 pattern: CompiledPattern,
135 is_exclusion: bool,
136 handler: String,
137 priority: i32,
138 options: HashMap<String, String>,
139}
140
141fn compile_rules(rules: &[Rule]) -> Vec<CompiledRule> {
142 rules
143 .iter()
144 .map(|rule| {
145 let (raw_pattern, is_exclusion) = if let Some(rest) = rule.pattern.strip_prefix('!') {
146 (rest.to_string(), true)
147 } else {
148 (rule.pattern.clone(), false)
149 };
150
151 let pattern = if raw_pattern.ends_with('/') {
152 let dir_name = raw_pattern.trim_end_matches('/').to_string();
154 CompiledPattern::Directory(dir_name)
155 } else if raw_pattern.contains('*')
156 || raw_pattern.contains('?')
157 || raw_pattern.contains('[')
158 {
159 match glob::Pattern::new(&raw_pattern) {
161 Ok(p) => CompiledPattern::Glob(p),
162 Err(_) => CompiledPattern::Exact(raw_pattern),
163 }
164 } else {
165 CompiledPattern::Exact(raw_pattern)
166 };
167
168 CompiledRule {
169 pattern,
170 is_exclusion,
171 handler: rule.handler.clone(),
172 priority: rule.priority,
173 options: rule.options.clone(),
174 }
175 })
176 .collect()
177}
178
179fn matches_entry(pattern: &CompiledPattern, filename: &str, is_dir: bool) -> bool {
180 match pattern {
181 CompiledPattern::Exact(name) => filename == name,
182 CompiledPattern::Glob(glob) => glob.matches(filename),
183 CompiledPattern::Directory(dir_name) => is_dir && filename == dir_name,
184 }
185}
186
187pub const SPECIAL_FILES: &[&str] = &[".dodot.toml", ".dodotignore"];
191
192pub fn should_skip_entry(name: &str, ignore_patterns: &[String]) -> bool {
200 SPECIAL_FILES.contains(&name) || is_ignored(name, ignore_patterns)
201}
202
203pub struct Scanner<'a> {
205 fs: &'a dyn Fs,
206}
207
208impl<'a> Scanner<'a> {
209 pub fn new(fs: &'a dyn Fs) -> Self {
210 Self { fs }
211 }
212
213 pub fn scan_pack(
223 &self,
224 pack: &Pack,
225 rules: &[Rule],
226 pack_ignore: &[String],
227 ) -> Result<Vec<RuleMatch>> {
228 let entries = self.walk_pack(&pack.path, pack_ignore)?;
229 Ok(self.match_entries(&entries, rules, &pack.name))
230 }
231
232 pub fn walk_pack(
249 &self,
250 pack_path: &Path,
251 ignore_patterns: &[String],
252 ) -> Result<Vec<PackEntry>> {
253 let mut results = Vec::new();
254 self.list_top_level(pack_path, ignore_patterns, &mut results)?;
255 Ok(results)
256 }
257
258 pub fn walk_pack_recursive(
260 &self,
261 pack_path: &Path,
262 ignore_patterns: &[String],
263 ) -> Result<Vec<PackEntry>> {
264 let mut results = Vec::new();
265 self.walk_dir(pack_path, pack_path, ignore_patterns, &mut results)?;
266 Ok(results)
267 }
268
269 pub fn match_entries(
275 &self,
276 entries: &[PackEntry],
277 rules: &[Rule],
278 pack_name: &str,
279 ) -> Vec<RuleMatch> {
280 let compiled = compile_rules(rules);
281 let mut matches = Vec::new();
282
283 for entry in entries {
284 let filename = entry
285 .relative_path
286 .file_name()
287 .map(|n| n.to_string_lossy().to_string())
288 .unwrap_or_default();
289
290 if let Some(rule_match) = match_file(
291 &compiled,
292 &filename,
293 entry.is_dir,
294 &entry.relative_path,
295 &entry.absolute_path,
296 pack_name,
297 ) {
298 matches.push(rule_match);
299 }
300 }
301
302 matches.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
303 matches
304 }
305
306 fn list_top_level(
309 &self,
310 pack_path: &Path,
311 ignore_patterns: &[String],
312 results: &mut Vec<PackEntry>,
313 ) -> Result<()> {
314 let entries = self.fs.read_dir(pack_path)?;
315
316 for entry in entries {
317 let name = &entry.name;
318
319 if name.starts_with('.') && name != ".config" {
320 continue;
321 }
322 if SPECIAL_FILES.contains(&name.as_str()) {
323 continue;
324 }
325 if is_ignored(name, ignore_patterns) {
326 continue;
327 }
328
329 let rel_path = entry
330 .path
331 .strip_prefix(pack_path)
332 .unwrap_or(&entry.path)
333 .to_path_buf();
334
335 results.push(PackEntry {
336 relative_path: rel_path,
337 absolute_path: entry.path.clone(),
338 is_dir: entry.is_dir,
339 });
340 }
341
342 Ok(())
343 }
344
345 fn walk_dir(
346 &self,
347 base: &Path,
348 dir: &Path,
349 ignore_patterns: &[String],
350 results: &mut Vec<PackEntry>,
351 ) -> Result<()> {
352 let entries = self.fs.read_dir(dir)?;
353
354 for entry in entries {
355 let name = &entry.name;
356
357 if name.starts_with('.') && name != ".config" {
359 continue;
360 }
361
362 if SPECIAL_FILES.contains(&name.as_str()) {
364 continue;
365 }
366
367 if is_ignored(name, ignore_patterns) {
369 continue;
370 }
371
372 let rel_path = entry
373 .path
374 .strip_prefix(base)
375 .unwrap_or(&entry.path)
376 .to_path_buf();
377
378 if entry.is_dir {
379 results.push(PackEntry {
381 relative_path: rel_path.clone(),
382 absolute_path: entry.path.clone(),
383 is_dir: true,
384 });
385 self.walk_dir(base, &entry.path, ignore_patterns, results)?;
387 } else {
388 results.push(PackEntry {
389 relative_path: rel_path,
390 absolute_path: entry.path.clone(),
391 is_dir: false,
392 });
393 }
394 }
395
396 Ok(())
397 }
398}
399
400fn match_file(
405 compiled: &[CompiledRule],
406 filename: &str,
407 is_dir: bool,
408 rel_path: &Path,
409 abs_path: &Path,
410 pack: &str,
411) -> Option<RuleMatch> {
412 for rule in compiled {
414 if rule.is_exclusion && matches_entry(&rule.pattern, filename, is_dir) {
415 return None;
416 }
417 }
418
419 let mut inclusion_rules: Vec<&CompiledRule> =
422 compiled.iter().filter(|r| !r.is_exclusion).collect();
423 inclusion_rules.sort_by(|a, b| b.priority.cmp(&a.priority));
424
425 for rule in inclusion_rules {
426 if matches_entry(&rule.pattern, filename, is_dir) {
427 return Some(RuleMatch {
428 relative_path: rel_path.to_path_buf(),
429 absolute_path: abs_path.to_path_buf(),
430 pack: pack.to_string(),
431 handler: rule.handler.clone(),
432 is_dir,
433 options: rule.options.clone(),
434 preprocessor_source: None,
435 });
436 }
437 }
438
439 None
440}
441
442fn is_ignored(name: &str, patterns: &[String]) -> bool {
443 for pattern in patterns {
444 if let Ok(glob) = glob::Pattern::new(pattern) {
445 if glob.matches(name) {
446 return true;
447 }
448 }
449 if name == pattern {
451 return true;
452 }
453 }
454 false
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use crate::handlers::HandlerConfig;
461 use crate::testing::TempEnvironment;
462
463 fn make_pack(name: &str, path: PathBuf) -> Pack {
464 Pack {
465 name: name.into(),
466 path,
467 config: HandlerConfig::default(),
468 }
469 }
470
471 fn default_rules() -> Vec<Rule> {
472 vec![
473 Rule {
474 pattern: "bin/".into(),
475 handler: "path".into(),
476 priority: 10,
477 options: HashMap::new(),
478 },
479 Rule {
480 pattern: "install.sh".into(),
481 handler: "install".into(),
482 priority: 10,
483 options: HashMap::new(),
484 },
485 Rule {
486 pattern: "aliases.sh".into(),
487 handler: "shell".into(),
488 priority: 10,
489 options: HashMap::new(),
490 },
491 Rule {
492 pattern: "profile.sh".into(),
493 handler: "shell".into(),
494 priority: 10,
495 options: HashMap::new(),
496 },
497 Rule {
498 pattern: "Brewfile".into(),
499 handler: "homebrew".into(),
500 priority: 10,
501 options: HashMap::new(),
502 },
503 Rule {
504 pattern: "*".into(),
505 handler: "symlink".into(),
506 priority: 0,
507 options: HashMap::new(),
508 },
509 ]
510 }
511
512 #[test]
515 fn exact_match() {
516 let compiled = compile_rules(&[Rule {
517 pattern: "install.sh".into(),
518 handler: "install".into(),
519 priority: 0,
520 options: HashMap::new(),
521 }]);
522 assert!(matches_entry(&compiled[0].pattern, "install.sh", false));
523 assert!(!matches_entry(&compiled[0].pattern, "other.sh", false));
524 }
525
526 #[test]
527 fn glob_match() {
528 let compiled = compile_rules(&[Rule {
529 pattern: "*.sh".into(),
530 handler: "shell".into(),
531 priority: 0,
532 options: HashMap::new(),
533 }]);
534 assert!(matches_entry(&compiled[0].pattern, "aliases.sh", false));
535 assert!(matches_entry(&compiled[0].pattern, "profile.sh", false));
536 assert!(!matches_entry(&compiled[0].pattern, "vimrc", false));
537 }
538
539 #[test]
540 fn directory_match() {
541 let compiled = compile_rules(&[Rule {
542 pattern: "bin/".into(),
543 handler: "path".into(),
544 priority: 0,
545 options: HashMap::new(),
546 }]);
547 assert!(matches_entry(&compiled[0].pattern, "bin", true));
548 assert!(!matches_entry(&compiled[0].pattern, "bin", false));
549 assert!(!matches_entry(&compiled[0].pattern, "lib", true));
550 }
551
552 #[test]
553 fn exclusion_prefix() {
554 let compiled = compile_rules(&[Rule {
555 pattern: "!*.tmp".into(),
556 handler: "exclude".into(),
557 priority: 100,
558 options: HashMap::new(),
559 }]);
560 assert!(compiled[0].is_exclusion);
561 assert!(matches_entry(&compiled[0].pattern, "scratch.tmp", false));
562 }
563
564 #[test]
565 fn catchall_matches_everything() {
566 let compiled = compile_rules(&[Rule {
567 pattern: "*".into(),
568 handler: "symlink".into(),
569 priority: 0,
570 options: HashMap::new(),
571 }]);
572 assert!(matches_entry(&compiled[0].pattern, "anything", false));
573 assert!(matches_entry(&compiled[0].pattern, "vimrc", false));
574 }
575
576 #[test]
579 fn scan_pack_basic() {
580 let env = TempEnvironment::builder()
581 .pack("vim")
582 .file("vimrc", "set nocompatible")
583 .file("gvimrc", "set guifont=Mono")
584 .file("aliases.sh", "alias vi=vim")
585 .file("install.sh", "#!/bin/sh\necho setup")
586 .done()
587 .build();
588
589 let scanner = Scanner::new(env.fs.as_ref());
590 let pack = make_pack("vim", env.dotfiles_root.join("vim"));
591 let rules = default_rules();
592
593 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
594
595 let handler_map: HashMap<String, Vec<String>> = {
596 let mut m: HashMap<String, Vec<String>> = HashMap::new();
597 for rm in &matches {
598 m.entry(rm.handler.clone())
599 .or_default()
600 .push(rm.relative_path.to_string_lossy().to_string());
601 }
602 m
603 };
604
605 assert_eq!(handler_map["install"], vec!["install.sh"]);
606 assert_eq!(handler_map["shell"], vec!["aliases.sh"]);
607 assert!(handler_map["symlink"].contains(&"gvimrc".to_string()));
608 assert!(handler_map["symlink"].contains(&"vimrc".to_string()));
609 }
610
611 #[test]
612 fn scan_pack_skips_hidden_files() {
613 let env = TempEnvironment::builder()
614 .pack("test")
615 .file("visible", "yes")
616 .file(".hidden", "no")
617 .done()
618 .build();
619
620 let scanner = Scanner::new(env.fs.as_ref());
621 let pack = make_pack("test", env.dotfiles_root.join("test"));
622 let rules = default_rules();
623
624 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
625 let names: Vec<String> = matches
626 .iter()
627 .map(|m| m.relative_path.to_string_lossy().to_string())
628 .collect();
629
630 assert!(names.contains(&"visible".to_string()));
631 assert!(!names.contains(&".hidden".to_string()));
632 }
633
634 #[test]
635 fn scan_pack_skips_special_files() {
636 let env = TempEnvironment::builder()
637 .pack("test")
638 .file("normal", "yes")
639 .config("[pack]\nignore = []")
640 .done()
641 .build();
642
643 let pack_dir = env.dotfiles_root.join("test");
645 env.fs
646 .write_file(&pack_dir.join(".dodotignore"), b"")
647 .unwrap();
648
649 let scanner = Scanner::new(env.fs.as_ref());
650 let pack = make_pack("test", pack_dir);
651 let rules = default_rules();
652
653 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
654 let names: Vec<String> = matches
655 .iter()
656 .map(|m| m.relative_path.to_string_lossy().to_string())
657 .collect();
658
659 assert!(names.contains(&"normal".to_string()));
660 assert!(!names.contains(&".dodot.toml".to_string()));
661 assert!(!names.contains(&".dodotignore".to_string()));
662 }
663
664 #[test]
665 fn scan_pack_with_ignore_patterns() {
666 let env = TempEnvironment::builder()
667 .pack("test")
668 .file("keep.txt", "yes")
669 .file("skip.bak", "no")
670 .file("other.bak", "no")
671 .done()
672 .build();
673
674 let scanner = Scanner::new(env.fs.as_ref());
675 let pack = make_pack("test", env.dotfiles_root.join("test"));
676 let rules = default_rules();
677
678 let matches = scanner
679 .scan_pack(&pack, &rules, &["*.bak".to_string()])
680 .unwrap();
681 let names: Vec<String> = matches
682 .iter()
683 .map(|m| m.relative_path.to_string_lossy().to_string())
684 .collect();
685
686 assert!(names.contains(&"keep.txt".to_string()));
687 assert!(!names.contains(&"skip.bak".to_string()));
688 assert!(!names.contains(&"other.bak".to_string()));
689 }
690
691 #[test]
692 fn scan_pack_exclusion_rules_override_catchall() {
693 let env = TempEnvironment::builder()
694 .pack("test")
695 .file("good.txt", "yes")
696 .file("bad.tmp", "no")
697 .done()
698 .build();
699
700 let scanner = Scanner::new(env.fs.as_ref());
701 let pack = make_pack("test", env.dotfiles_root.join("test"));
702
703 let rules = vec![
704 Rule {
705 pattern: "!*.tmp".into(),
706 handler: "exclude".into(),
707 priority: 100,
708 options: HashMap::new(),
709 },
710 Rule {
711 pattern: "*".into(),
712 handler: "symlink".into(),
713 priority: 0,
714 options: HashMap::new(),
715 },
716 ];
717
718 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
719 let names: Vec<String> = matches
720 .iter()
721 .map(|m| m.relative_path.to_string_lossy().to_string())
722 .collect();
723
724 assert!(names.contains(&"good.txt".to_string()));
725 assert!(!names.contains(&"bad.tmp".to_string()));
726 }
727
728 #[test]
729 fn scan_pack_priority_ordering() {
730 let env = TempEnvironment::builder()
731 .pack("test")
732 .file("aliases.sh", "# shell")
733 .done()
734 .build();
735
736 let scanner = Scanner::new(env.fs.as_ref());
737 let pack = make_pack("test", env.dotfiles_root.join("test"));
738
739 let rules = vec![
741 Rule {
742 pattern: "*.sh".into(),
743 handler: "generic-shell".into(),
744 priority: 5,
745 options: HashMap::new(),
746 },
747 Rule {
748 pattern: "aliases.sh".into(),
749 handler: "specific-shell".into(),
750 priority: 10,
751 options: HashMap::new(),
752 },
753 Rule {
754 pattern: "*".into(),
755 handler: "symlink".into(),
756 priority: 0,
757 options: HashMap::new(),
758 },
759 ];
760
761 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
762 assert_eq!(matches.len(), 1);
763 assert_eq!(matches[0].handler, "specific-shell");
764 }
765
766 #[test]
767 fn scan_pack_directory_entry() {
768 let env = TempEnvironment::builder()
769 .pack("test")
770 .file("bin/my-script", "#!/bin/sh")
771 .file("normal", "x")
772 .done()
773 .build();
774
775 let scanner = Scanner::new(env.fs.as_ref());
776 let pack = make_pack("test", env.dotfiles_root.join("test"));
777 let rules = default_rules();
778
779 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
780
781 let bin_match = matches
782 .iter()
783 .find(|m| m.relative_path.to_string_lossy() == "bin");
784 assert!(bin_match.is_some(), "bin directory should match");
785 assert_eq!(bin_match.unwrap().handler, "path");
786 assert!(bin_match.unwrap().is_dir);
787 }
788
789 #[test]
790 fn nested_install_sh_is_not_matched_by_install_rule() {
791 let env = TempEnvironment::builder()
795 .pack("sneaky")
796 .file("config/install.sh", "echo boom")
797 .done()
798 .build();
799
800 let scanner = Scanner::new(env.fs.as_ref());
801 let pack = make_pack("sneaky", env.dotfiles_root.join("sneaky"));
802 let rules = default_rules();
803
804 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
805
806 assert!(
807 !matches.iter().any(|m| m.handler == "install"),
808 "nested install.sh should not route to install handler: {matches:?}"
809 );
810 }
811
812 #[test]
813 fn scan_pack_returns_only_top_level_entries() {
814 let env = TempEnvironment::builder()
818 .pack("nvim")
819 .file("nvim/init.lua", "require('config')")
820 .file("nvim/lua/plugins.lua", "return {}")
821 .done()
822 .build();
823
824 let scanner = Scanner::new(env.fs.as_ref());
825 let pack = make_pack("nvim", env.dotfiles_root.join("nvim"));
826 let rules = default_rules();
827
828 let matches = scanner.scan_pack(&pack, &rules, &[]).unwrap();
829
830 let relpaths: Vec<String> = matches
831 .iter()
832 .map(|m| m.relative_path.to_string_lossy().to_string())
833 .collect();
834
835 assert!(
836 relpaths.iter().any(|p| p == "nvim"),
837 "top-level nvim dir should match: {relpaths:?}"
838 );
839 assert!(
840 !relpaths.iter().any(|p| p.contains('/')),
841 "no nested paths expected: {relpaths:?}"
842 );
843 }
844
845 #[test]
848 fn group_by_handler_groups_correctly() {
849 let matches = vec![
850 RuleMatch {
851 relative_path: "vimrc".into(),
852 absolute_path: "/d/vim/vimrc".into(),
853 pack: "vim".into(),
854 handler: "symlink".into(),
855 is_dir: false,
856 options: HashMap::new(),
857 preprocessor_source: None,
858 },
859 RuleMatch {
860 relative_path: "aliases.sh".into(),
861 absolute_path: "/d/vim/aliases.sh".into(),
862 pack: "vim".into(),
863 handler: "shell".into(),
864 is_dir: false,
865 options: HashMap::new(),
866 preprocessor_source: None,
867 },
868 RuleMatch {
869 relative_path: "gvimrc".into(),
870 absolute_path: "/d/vim/gvimrc".into(),
871 pack: "vim".into(),
872 handler: "symlink".into(),
873 is_dir: false,
874 options: HashMap::new(),
875 preprocessor_source: None,
876 },
877 ];
878
879 let groups = group_by_handler(&matches);
880 assert_eq!(groups.len(), 2);
881 assert_eq!(groups["symlink"].len(), 2);
882 assert_eq!(groups["shell"].len(), 1);
883 }
884
885 #[test]
886 fn handler_execution_order_follows_phase_declaration() {
887 let mut groups = HashMap::new();
888 groups.insert("symlink".into(), vec![]);
889 groups.insert("install".into(), vec![]);
890 groups.insert("shell".into(), vec![]);
891 groups.insert("homebrew".into(), vec![]);
892 groups.insert("path".into(), vec![]);
893
894 let fs = crate::fs::OsFs::new();
895 let registry = crate::handlers::create_registry(&fs);
896 let order = handler_execution_order(&groups, ®istry);
897
898 assert_eq!(
902 order,
903 vec!["homebrew", "install", "path", "shell", "symlink"]
904 );
905 }
906
907 #[test]
908 fn handler_execution_order_places_unknown_handlers_last() {
909 let mut groups = HashMap::new();
910 groups.insert("symlink".into(), vec![]);
911 groups.insert("zzz-unknown".into(), vec![]);
912 groups.insert("homebrew".into(), vec![]);
913
914 let fs = crate::fs::OsFs::new();
915 let registry = crate::handlers::create_registry(&fs);
916 let order = handler_execution_order(&groups, ®istry);
917
918 assert_eq!(order, vec!["homebrew", "symlink", "zzz-unknown"]);
920 }
921
922 #[test]
923 fn rule_match_serializes() {
924 let m = RuleMatch {
925 relative_path: "vimrc".into(),
926 absolute_path: "/dots/vim/vimrc".into(),
927 pack: "vim".into(),
928 handler: "symlink".into(),
929 is_dir: false,
930 options: HashMap::new(),
931 preprocessor_source: None,
932 };
933 let json = serde_json::to_string(&m).unwrap();
934 assert!(json.contains("vimrc"));
935 assert!(json.contains("symlink"));
936 assert!(!json.contains("options"));
937 }
938}