1use std::path::{Path, PathBuf};
14
15use tracing::{debug, trace};
16
17use crate::interpreter::environment::Environment;
18use crate::interpreter::value::{RError, RErrorKind, RValue, Vector};
19use crate::interpreter::Interpreter;
20
21use super::description::PackageDescription;
22use super::namespace::PackageNamespace;
23
24#[derive(Debug, Clone)]
26pub struct LoadedNamespace {
27 pub name: String,
29 pub lib_path: PathBuf,
31 pub description: PackageDescription,
33 pub namespace: PackageNamespace,
35 pub namespace_env: Environment,
37 pub exports_env: Environment,
39}
40
41#[derive(Debug, Clone)]
44pub struct SearchPathEntry {
45 pub name: String,
47 pub env: Environment,
49}
50
51impl Interpreter {
52 pub(crate) fn find_package_dir(&self, pkg_name: &str) -> Option<PathBuf> {
57 let lib_paths = self.get_lib_paths();
58 for lib_path in &lib_paths {
59 let pkg_dir = Path::new(lib_path).join(pkg_name);
60 if pkg_dir.join("DESCRIPTION").is_file() {
62 return Some(pkg_dir);
63 }
64 }
65 None
66 }
67
68 pub(crate) fn get_lib_paths(&self) -> Vec<String> {
75 let mut paths: Vec<String> = Vec::new();
76 let sep = if cfg!(windows) { ';' } else { ':' };
77
78 if let Some(r_libs) = self.get_env_var("R_LIBS") {
79 for p in r_libs.split(sep) {
80 let p = p.trim();
81 if !p.is_empty() {
82 let resolved = self.resolve_path(p);
83 if resolved.is_dir() {
84 paths.push(resolved.to_string_lossy().to_string());
85 }
86 }
87 }
88 }
89
90 if let Some(r_libs_user) = self.get_env_var("R_LIBS_USER") {
91 for p in r_libs_user.split(sep) {
92 let p = p.trim();
93 if !p.is_empty() {
94 let resolved = self.resolve_path(p);
95 if resolved.is_dir() {
96 paths.push(resolved.to_string_lossy().to_string());
97 }
98 }
99 }
100 }
101
102 let default_lib = self.default_library_path();
106 if !paths.contains(&default_lib) {
107 paths.push(default_lib);
108 }
109
110 paths
111 }
112
113 pub(crate) fn default_library_path(&self) -> String {
118 let data_dir = {
119 #[cfg(feature = "dirs-support")]
120 {
121 if let Some(data) = dirs::data_dir() {
122 data.join("miniR").to_string_lossy().to_string()
123 } else {
124 self.fallback_data_dir()
125 }
126 }
127 #[cfg(not(feature = "dirs-support"))]
128 {
129 self.fallback_data_dir()
130 }
131 };
132 format!("{}/library", data_dir)
133 }
134
135 fn fallback_data_dir(&self) -> String {
137 self.get_env_var("HOME")
138 .or_else(|| self.get_env_var("USERPROFILE"))
139 .map(|h| format!("{}/.miniR", h))
140 .unwrap_or_else(|| "/tmp/miniR".to_string())
141 }
142
143 pub(crate) fn is_base_package(name: &str) -> bool {
158 matches!(
159 name,
160 "base"
161 | "stats"
162 | "stats4"
163 | "utils"
164 | "methods"
165 | "grDevices"
166 | "graphics"
167 | "datasets"
168 | "tools"
169 | "compiler"
170 | "grid"
171 | "splines"
172 | "parallel"
173 | "tcltk"
174 | "translations"
175 )
176 }
177
178 pub(crate) fn load_namespace(&self, pkg_name: &str) -> Result<Environment, RError> {
179 debug!(pkg = pkg_name, "loading namespace");
180
181 if let Some(ns) = self.loaded_namespaces.borrow().get(pkg_name) {
183 debug!(pkg = pkg_name, "namespace already loaded");
184 return Ok(ns.namespace_env.clone());
185 }
186
187 if Self::is_base_package(pkg_name) {
190 debug!(
191 pkg = pkg_name,
192 "base package — registering synthetic namespace"
193 );
194 let base = self.base_env().clone();
195 let ns = LoadedNamespace {
196 name: pkg_name.to_string(),
197 lib_path: PathBuf::from("<builtin>"),
198 description: PackageDescription {
199 package: pkg_name.to_string(),
200 version: "0.0.0".to_string(),
201 title: Some(format!("miniR built-in: {}", pkg_name)),
202 depends: vec![],
203 imports: vec![],
204 suggests: vec![],
205 linking_to: vec![],
206 fields: std::collections::HashMap::new(),
207 },
208 namespace: PackageNamespace::export_all(),
209 namespace_env: base.clone(),
210 exports_env: base.clone(),
211 };
212 self.loaded_namespaces
213 .borrow_mut()
214 .insert(pkg_name.to_string(), ns);
215 return Ok(base);
216 }
217
218 let pkg_dir = self.find_package_dir(pkg_name).ok_or_else(|| {
219 RError::new(
220 RErrorKind::Other,
221 format!(
222 "there is no package called '{pkg_name}'\n \
223 Hint: check that the package is installed in one of the library paths \
224 returned by .libPaths()"
225 ),
226 )
227 })?;
228
229 debug!(pkg = pkg_name, path = %pkg_dir.display(), "found package");
230 self.load_namespace_from_dir(pkg_name, &pkg_dir)
231 }
232
233 fn load_namespace_from_dir(
235 &self,
236 pkg_name: &str,
237 pkg_dir: &Path,
238 ) -> Result<Environment, RError> {
239 let desc_path = pkg_dir.join("DESCRIPTION");
241 let desc_text = std::fs::read_to_string(&desc_path).map_err(|e| {
242 RError::other(format!(
243 "cannot read DESCRIPTION for package '{}': {}",
244 pkg_name, e
245 ))
246 })?;
247 let description = PackageDescription::parse(&desc_text).map_err(|e| {
248 RError::other(format!(
249 "cannot parse DESCRIPTION for package '{}': {}",
250 pkg_name, e
251 ))
252 })?;
253
254 let ns_path = pkg_dir.join("NAMESPACE");
256 let namespace = if ns_path.is_file() {
257 let ns_text = std::fs::read_to_string(&ns_path).map_err(|e| {
258 RError::other(format!(
259 "cannot read NAMESPACE for package '{}': {}",
260 pkg_name, e
261 ))
262 })?;
263 PackageNamespace::parse(&ns_text).map_err(|e| {
264 RError::other(format!(
265 "cannot parse NAMESPACE for package '{}': {}",
266 pkg_name, e
267 ))
268 })?
269 } else {
270 PackageNamespace::default()
272 };
273
274 for dep in &description.imports {
276 if dep.package == "R" || Self::is_base_package(&dep.package) {
277 continue;
278 }
279 self.load_namespace(&dep.package)?;
282 }
283
284 for dep in &description.depends {
286 if dep.package == "R" || Self::is_base_package(&dep.package) {
287 continue;
288 }
289 self.load_namespace(&dep.package)?;
290 }
291
292 let base_env = self.base_env();
297 let namespace_env = Environment::new_child(&base_env);
298 namespace_env.set_name(format!("namespace:{}", pkg_name));
299 self.loaded_namespaces.borrow_mut().insert(
304 pkg_name.to_string(),
305 LoadedNamespace {
306 name: pkg_name.to_string(),
307 lib_path: pkg_dir.to_path_buf(),
308 description: description.clone(),
309 namespace: namespace.clone(),
310 namespace_env: namespace_env.clone(),
311 exports_env: namespace_env.clone(), },
313 );
314
315 namespace_env.set(
317 ".packageName".to_string(),
318 RValue::vec(Vector::Character(vec![Some(pkg_name.to_string())].into())),
319 );
320
321 namespace_env.set(
323 ".__rlang_hook__.".to_string(),
324 RValue::List(crate::interpreter::value::RList::new(vec![])),
325 );
326
327 self.populate_imports(&namespace, &namespace_env)?;
329
330 #[cfg(feature = "native")]
333 {
334 let base = self.base_env();
335 crate::interpreter::native::runtime::init_global_envs(&base, &self.global_env);
336 }
337
338 #[cfg(feature = "native")]
341 if !namespace.use_dyn_libs.is_empty() {
342 debug!(pkg = pkg_name, "loading native code");
343 self.load_package_native_code(pkg_name, pkg_dir, &namespace.use_dyn_libs)?;
344
345 for directive in &namespace.use_dyn_libs {
347 let fixes_prefix = directive
348 .registrations
349 .iter()
350 .find(|r| r.contains(".fixes"))
351 .and_then(|r| {
352 r.split('=')
353 .nth(1)
354 .map(|v| v.trim().trim_matches('"').trim_matches('\'').to_string())
355 })
356 .unwrap_or_default();
357
358 let mut bound_any = false;
359 for reg in &directive.registrations {
360 let sym_name = reg.trim();
361 if sym_name.is_empty() {
362 continue;
363 }
364 if sym_name.contains('=') && !sym_name.starts_with('.') {
365 continue;
366 }
367 if sym_name.starts_with('.') {
368 if reg.contains("registration") {
369 let dlls = self.loaded_dlls.borrow();
370 for dll in dlls.iter() {
371 for name in dll.registered_calls.keys() {
373 let r_name = format!("{fixes_prefix}{name}");
374 bind_native_symbol(&namespace_env, &r_name, name, pkg_name);
375 }
376 for name in dll.registered_c_methods.keys() {
378 let r_name = format!("{fixes_prefix}{name}");
379 bind_native_symbol(&namespace_env, &r_name, name, pkg_name);
380 }
381 }
382 bound_any = true;
383 }
384 continue;
385 }
386 bind_native_symbol(&namespace_env, sym_name, sym_name, pkg_name);
387 bound_any = true;
388 }
389 if !bound_any {
390 let dlls = self.loaded_dlls.borrow();
391 for dll in dlls.iter() {
392 for name in dll.registered_calls.keys() {
393 bind_native_symbol(&namespace_env, name, name, pkg_name);
394 }
395 for name in dll.registered_c_methods.keys() {
396 bind_native_symbol(&namespace_env, name, name, pkg_name);
397 }
398 }
399 }
400 }
401 debug!(pkg = pkg_name, "native code loaded");
402 }
403
404 let r_dir = pkg_dir.join("R");
407 let sysdata_path = r_dir.join("sysdata.rda");
408 if sysdata_path.is_file() {
409 debug!(pkg = pkg_name, "loading sysdata.rda");
410 match std::fs::read(&sysdata_path) {
411 Ok(raw_bytes) => {
412 match crate::interpreter::builtins::io::try_load_binary_rdata(
413 &raw_bytes,
414 &namespace_env,
415 ) {
416 Ok(Some(names)) => {
417 debug!(pkg = pkg_name, count = names.len(), "sysdata.rda loaded");
418 }
419 Ok(None) => {
420 tracing::warn!(
421 pkg = pkg_name,
422 "sysdata.rda: not a recognized binary format"
423 );
424 }
425 Err(e) => {
426 tracing::warn!(
427 pkg = pkg_name,
428 err = %e,
429 "sysdata.rda: failed to load"
430 );
431 }
432 }
433 }
434 Err(e) => {
435 tracing::warn!(pkg = pkg_name, err = %e, "sysdata.rda: failed to read");
436 }
437 }
438 }
439
440 if r_dir.is_dir() {
442 debug!(pkg = pkg_name, "sourcing R files");
443 let collate = description.fields.get("Collate").map(|s| s.as_str());
444 self.source_r_directory(&r_dir, &namespace_env, collate)?;
445 debug!(pkg = pkg_name, "R files sourced");
446 }
447
448 let exports_env = Environment::new_child(&base_env);
450 exports_env.set_name(format!("package:{}", pkg_name));
451 self.build_exports(&namespace, &namespace_env, &exports_env);
452
453 self.register_s3_methods(&namespace, &namespace_env);
455
456 if let Some(entry) = self.loaded_namespaces.borrow_mut().get_mut(pkg_name) {
458 entry.exports_env = exports_env;
459 }
460
461 debug!(pkg = pkg_name, "namespace loaded");
462
463 if let Some(on_load) = namespace_env.get(".onLoad") {
465 debug!(pkg = pkg_name, "calling .onLoad");
466 let lib_path_str = pkg_dir
467 .parent()
468 .unwrap_or(pkg_dir)
469 .to_string_lossy()
470 .to_string();
471 let lib_val = RValue::vec(Vector::Character(vec![Some(lib_path_str)].into()));
472 let pkg_val = RValue::vec(Vector::Character(vec![Some(pkg_name.to_string())].into()));
473 self.call_function(&on_load, &[lib_val, pkg_val], &[], &namespace_env)?;
474 }
475
476 Ok(namespace_env)
477 }
478
479 pub(crate) fn base_env(&self) -> Environment {
481 let mut current = self.global_env.clone();
482 while let Some(parent) = current.parent() {
483 current = parent;
484 }
485 current
486 }
487
488 fn populate_imports(
490 &self,
491 namespace: &PackageNamespace,
492 namespace_env: &Environment,
493 ) -> Result<(), RError> {
494 for pkg_name in &namespace.imports {
496 if Self::is_base_package(pkg_name) {
497 continue;
499 }
500 if let Some(ns) = self.loaded_namespaces.borrow().get(pkg_name) {
501 for name in ns.exports_env.ls() {
503 if let Some(val) = ns.exports_env.get(&name) {
504 namespace_env.set(name, val);
505 }
506 }
507 }
508 }
509
510 for (pkg_name, sym_name) in &namespace.imports_from {
512 if Self::is_base_package(pkg_name) {
513 let base = self.base_env();
515 if let Some(val) = base.get(sym_name) {
516 namespace_env.set(sym_name.clone(), val);
517 }
518 continue;
519 }
520 if let Some(ns) = self.loaded_namespaces.borrow().get(pkg_name) {
521 if let Some(val) = ns.exports_env.get(sym_name) {
523 namespace_env.set(sym_name.clone(), val);
524 } else if let Some(val) = ns.namespace_env.get(sym_name) {
525 namespace_env.set(sym_name.clone(), val);
526 }
527 }
528 }
529
530 Ok(())
531 }
532
533 fn source_r_directory(
541 &self,
542 r_dir: &Path,
543 env: &Environment,
544 collate: Option<&str>,
545 ) -> Result<(), RError> {
546 let mut all_files: Vec<PathBuf> = Vec::new();
548
549 let entries = std::fs::read_dir(r_dir).map_err(|e| {
550 RError::other(format!(
551 "cannot read R/ directory '{}': {}",
552 r_dir.display(),
553 e
554 ))
555 })?;
556
557 for entry in entries {
558 let entry = entry
559 .map_err(|e| RError::other(format!("error reading R/ directory entry: {}", e)))?;
560 let path = entry.path();
561 if let Some(ext) = path.extension() {
562 let ext_lower = ext.to_string_lossy().to_lowercase();
563 if ext_lower == "r" || ext_lower == "s" {
564 all_files.push(path);
565 }
566 }
567 }
568
569 all_files.sort();
571
572 let r_files = if let Some(collate_str) = collate {
574 let collate_names = parse_collate_field(collate_str);
575 let mut ordered: Vec<PathBuf> = Vec::new();
576
577 for name in &collate_names {
579 let path = r_dir.join(name);
580 if path.is_file() {
581 ordered.push(path);
582 }
583 }
586
587 let collate_set: std::collections::HashSet<&str> =
589 collate_names.iter().map(|s| s.as_str()).collect();
590 for path in &all_files {
591 if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
592 if !collate_set.contains(file_name) {
593 ordered.push(path.clone());
594 }
595 }
596 }
597
598 ordered
599 } else {
600 all_files
601 };
602
603 debug!(count = r_files.len(), "sourcing R files");
604 for r_file in &r_files {
605 trace!(file = %r_file.display(), "sourcing R file");
606 if let Err(e) = self.source_file_into(r_file, env) {
607 tracing::warn!(file = %r_file.display(), error = %e, "error sourcing R file");
609 }
610 }
611
612 Ok(())
613 }
614
615 fn source_file_into(&self, path: &Path, env: &Environment) -> Result<(), RError> {
617 trace!(path = %path.display(), "source_file_into start");
618 let source = match std::fs::read_to_string(path) {
619 Ok(s) => s,
620 Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
621 let bytes = std::fs::read(path).map_err(|e2| {
622 RError::other(format!("cannot read '{}': {}", path.display(), e2))
623 })?;
624 String::from_utf8_lossy(&bytes).into_owned()
625 }
626 Err(e) => {
627 return Err(RError::other(format!(
628 "cannot read '{}': {}",
629 path.display(),
630 e
631 )));
632 }
633 };
634
635 let ast = crate::parser::parse_program(&source)
636 .map_err(|e| RError::other(format!("parse error in '{}': {}", path.display(), e)))?;
637
638 use crate::parser::ast::Expr;
644 match &ast {
645 Expr::Program(exprs) => {
646 for expr in exprs {
647 if let Err(e) = self.eval_in(expr, env) {
648 tracing::trace!(
649 path = %path.display(),
650 error = %crate::interpreter::value::RError::from(e),
651 "top-level expression error (continuing)"
652 );
653 }
654 }
655 }
656 other => {
657 self.eval_in(other, env).map_err(RError::from)?;
658 }
659 }
660 trace!(path = %path.display(), "source_file_into done");
661 Ok(())
662 }
663
664 fn build_exports(
666 &self,
667 namespace: &PackageNamespace,
668 namespace_env: &Environment,
669 exports_env: &Environment,
670 ) {
671 for name in &namespace.exports {
673 if let Some(val) = namespace_env.get(name) {
674 exports_env.set(name.clone(), val);
675 }
676 }
677
678 let patterns: Vec<regex::Regex> = namespace
680 .export_patterns
681 .iter()
682 .filter_map(|pat| regex::Regex::new(pat).ok())
683 .collect();
684
685 if !patterns.is_empty() {
686 for name in namespace_env.ls() {
687 if patterns.iter().any(|pat| pat.is_match(&name)) {
688 if let Some(val) = namespace_env.get(&name) {
689 exports_env.set(name, val);
690 }
691 }
692 }
693 }
694
695 if namespace.exports.is_empty() && namespace.export_patterns.is_empty() {
698 for name in namespace_env.ls() {
699 if let Some(val) = namespace_env.get(&name) {
700 exports_env.set(name, val);
701 }
702 }
703 }
704 }
705
706 fn register_s3_methods(&self, namespace: &PackageNamespace, namespace_env: &Environment) {
709 for reg in &namespace.s3_methods {
710 let method_name = reg
711 .method
712 .clone()
713 .unwrap_or_else(|| format!("{}.{}", reg.generic, reg.class));
714
715 if let Some(method_fn) = namespace_env.get(&method_name) {
717 self.register_s3_method(reg.generic.clone(), reg.class.clone(), method_fn);
718 }
719 }
720 }
721
722 pub(crate) fn attach_package(&self, pkg_name: &str) -> Result<(), RError> {
727 if Self::is_base_package(pkg_name) {
729 return Ok(());
730 }
731
732 let loaded = self
733 .loaded_namespaces
734 .borrow()
735 .get(pkg_name)
736 .cloned()
737 .ok_or_else(|| {
738 RError::other(format!(
739 "namespace '{}' is not loaded — cannot attach",
740 pkg_name
741 ))
742 })?;
743
744 let entry_name = format!("package:{}", pkg_name);
745
746 {
748 let sp = self.search_path.borrow();
749 if sp.iter().any(|e| e.name == entry_name) {
750 return Ok(());
751 }
752 }
753
754 let current_parent = self.global_env.parent();
757 loaded.exports_env.set_parent(current_parent);
758 self.global_env.set_parent(Some(loaded.exports_env.clone()));
759
760 self.search_path.borrow_mut().insert(
764 0,
765 SearchPathEntry {
766 name: entry_name,
767 env: loaded.exports_env.clone(),
768 },
769 );
770
771 let namespace_env = loaded.namespace_env.clone();
773 let lib_path = loaded.lib_path.clone();
774 if let Some(on_attach) = namespace_env.get(".onAttach") {
775 let lib_path_str = lib_path
776 .parent()
777 .unwrap_or(&lib_path)
778 .to_string_lossy()
779 .to_string();
780 let lib_val = RValue::vec(Vector::Character(vec![Some(lib_path_str)].into()));
781 let pkg_val = RValue::vec(Vector::Character(vec![Some(pkg_name.to_string())].into()));
782 self.call_function(&on_attach, &[lib_val, pkg_val], &[], &namespace_env)?;
784 }
785
786 Ok(())
787 }
788
789 pub(crate) fn detach_package(&self, entry_name: &str) -> Result<(), RError> {
791 let mut sp = self.search_path.borrow_mut();
792 let idx = sp
793 .iter()
794 .position(|e| e.name == entry_name)
795 .ok_or_else(|| {
796 RError::new(
797 RErrorKind::Argument,
798 format!(
799 "invalid 'name' argument: '{}' not found on search path",
800 entry_name
801 ),
802 )
803 })?;
804
805 let entry = sp.remove(idx);
806
807 let detached_parent = entry.env.parent();
810
811 let mut current = self.global_env.clone();
813 loop {
814 let parent = current.parent();
815 match parent {
816 Some(ref p) if p.ptr_eq(&entry.env) => {
817 current.set_parent(detached_parent);
818 break;
819 }
820 Some(p) => current = p,
821 None => break,
822 }
823 }
824
825 Ok(())
826 }
827
828 pub(crate) fn get_search_path(&self) -> Vec<String> {
830 let mut result = vec![".GlobalEnv".to_string()];
831 for entry in self.search_path.borrow().iter() {
832 result.push(entry.name.clone());
833 }
834 result.push("package:base".to_string());
835 result
836 }
837}
838
839fn parse_collate_field(collate: &str) -> Vec<String> {
846 let mut names = Vec::new();
847 let mut chars = collate.chars().peekable();
848
849 while let Some(&ch) = chars.peek() {
850 if ch.is_whitespace() {
852 chars.next();
853 continue;
854 }
855
856 if ch == '\'' || ch == '"' {
858 let quote = ch;
859 chars.next(); let mut name = String::new();
861 for c in chars.by_ref() {
862 if c == quote {
863 break;
864 }
865 name.push(c);
866 }
867 if !name.is_empty() {
868 names.push(name);
869 }
870 continue;
871 }
872
873 let mut name = String::new();
875 while let Some(&c) = chars.peek() {
876 if c.is_whitespace() {
877 break;
878 }
879 name.push(c);
880 chars.next();
881 }
882 if !name.is_empty() {
883 names.push(name);
884 }
885 }
886
887 names
888}
889
890#[cfg(feature = "native")]
892fn bind_native_symbol(
893 env: &crate::interpreter::environment::Environment,
894 r_name: &str,
895 c_name: &str,
896 pkg_name: &str,
897) {
898 let info = RValue::List(crate::interpreter::value::RList::new(vec![
899 (
900 Some("name".to_string()),
901 RValue::vec(Vector::Character(vec![Some(c_name.to_string())].into())),
902 ),
903 (
904 Some("package".to_string()),
905 RValue::vec(Vector::Character(vec![Some(pkg_name.to_string())].into())),
906 ),
907 ]));
908 env.set(r_name.to_string(), info);
909}
910
911#[cfg(test)]
912mod tests {
913 use super::*;
914
915 #[test]
916 fn parse_collate_simple() {
917 let collate = "aaa.R bbb.R ccc.R";
918 let result = parse_collate_field(collate);
919 assert_eq!(result, vec!["aaa.R", "bbb.R", "ccc.R"]);
920 }
921
922 #[test]
923 fn parse_collate_multiline() {
924 let collate = "aaa.R bbb.R\nccc.R\nddd.R";
926 let result = parse_collate_field(collate);
927 assert_eq!(result, vec!["aaa.R", "bbb.R", "ccc.R", "ddd.R"]);
928 }
929
930 #[test]
931 fn parse_collate_quoted_filenames() {
932 let collate = r#"'aaa.R' "bbb.R" ccc.R"#;
933 let result = parse_collate_field(collate);
934 assert_eq!(result, vec!["aaa.R", "bbb.R", "ccc.R"]);
935 }
936
937 #[test]
938 fn parse_collate_quoted_with_spaces() {
939 let collate = r#"'file with spaces.R' normal.R"#;
940 let result = parse_collate_field(collate);
941 assert_eq!(result, vec!["file with spaces.R", "normal.R"]);
942 }
943
944 #[test]
945 fn parse_collate_extra_whitespace() {
946 let collate = " aaa.R bbb.R \n ccc.R ";
947 let result = parse_collate_field(collate);
948 assert_eq!(result, vec!["aaa.R", "bbb.R", "ccc.R"]);
949 }
950
951 #[test]
952 fn parse_collate_empty() {
953 let result = parse_collate_field("");
954 assert!(result.is_empty());
955 }
956
957 #[test]
958 fn parse_collate_whitespace_only() {
959 let result = parse_collate_field(" \n \n ");
960 assert!(result.is_empty());
961 }
962}