1use std::{collections::HashMap, fmt, sync::OnceLock};
33
34#[cfg(not(target_arch = "wasm32"))]
35use config::{Config, ConfigError};
36use regex::Regex;
37use serde::{Deserialize, Serialize};
38
39use crate::server::lsp::{SparqlEngine, base_types::LSPAny};
40
41#[derive(Debug, Serialize, Deserialize, Default, PartialEq)]
42#[serde(default)]
43pub struct BackendsSettings {
44 pub backends: HashMap<String, BackendConfiguration>,
45}
46
47#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
48#[serde(rename_all = "camelCase")]
49pub struct BackendConfiguration {
50 pub name: String,
51 pub url: String,
52 pub health_check_url: Option<String>,
53 pub engine: Option<SparqlEngine>,
54 pub request_method: Option<RequestMethod>,
55 #[serde(default)]
56 pub prefix_map: HashMap<String, String>,
57 #[serde(default)]
58 pub default: bool,
59 #[serde(default)]
60 pub queries: HashMap<CompletionTemplate, String>,
61 pub additional_data: Option<LSPAny>,
62}
63
64#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase", try_from = "String")]
66pub(crate) enum CompletionTemplate {
67 Hover,
68 SubjectCompletion,
69 PredicateCompletionContextSensitive,
70 PredicateCompletionContextInsensitive,
71 ObjectCompletionContextSensitive,
72 ObjectCompletionContextInsensitive,
73 ValuesCompletionContextSensitive,
74 ValuesCompletionContextInsensitive,
75}
76
77#[derive(Debug)]
78pub struct UnknownTemplateError(String);
79
80impl fmt::Display for UnknownTemplateError {
81 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
82 write!(f, "unknown completion query template \"{}\"", &self.0)
83 }
84}
85
86impl TryFrom<String> for CompletionTemplate {
87 type Error = UnknownTemplateError;
88
89 fn try_from(s: String) -> Result<Self, Self::Error> {
90 match s.as_str() {
91 "hover" => Ok(CompletionTemplate::Hover),
92 "subjectCompletion" => Ok(CompletionTemplate::SubjectCompletion),
93 "predicateCompletionContextInsensitive" => {
94 Ok(CompletionTemplate::PredicateCompletionContextInsensitive)
95 }
96 "predicateCompletionContextSensitive" => {
97 Ok(CompletionTemplate::PredicateCompletionContextSensitive)
98 }
99 "objectCompletionContextInsensitive" => {
100 Ok(CompletionTemplate::ObjectCompletionContextInsensitive)
101 }
102 "objectCompletionContextSensitive" => {
103 Ok(CompletionTemplate::ObjectCompletionContextSensitive)
104 }
105 "valuesCompletionContextSensitive" => {
106 Ok(CompletionTemplate::ValuesCompletionContextSensitive)
107 }
108 "valuesCompletionContextInsensitive" => {
109 Ok(CompletionTemplate::ValuesCompletionContextInsensitive)
110 }
111 _ => Err(UnknownTemplateError(s.to_string())),
112 }
113 }
114}
115
116impl fmt::Display for CompletionTemplate {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 match self {
119 CompletionTemplate::Hover => write!(f, "hover"),
120 CompletionTemplate::SubjectCompletion => write!(f, "subjectCompletion"),
121 CompletionTemplate::PredicateCompletionContextSensitive => {
122 write!(f, "predicateCompletionContextSensitive")
123 }
124 CompletionTemplate::PredicateCompletionContextInsensitive => {
125 write!(f, "predicateCompletionContextInsensitive")
126 }
127 CompletionTemplate::ObjectCompletionContextSensitive => {
128 write!(f, "objectCompletionContextSensitive")
129 }
130 CompletionTemplate::ObjectCompletionContextInsensitive => {
131 write!(f, "objectCompletionContextInsensitive")
132 }
133 CompletionTemplate::ValuesCompletionContextSensitive => {
134 write!(f, "valuesCompletionContextSensitive")
135 }
136 CompletionTemplate::ValuesCompletionContextInsensitive => {
137 write!(f, "valuesCompletionContextInsensitive")
138 }
139 }
140 }
141}
142
143#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
144#[allow(clippy::upper_case_acronyms)]
145pub enum RequestMethod {
146 GET,
147 POST,
148}
149
150#[derive(Debug, Serialize, Deserialize, PartialEq)]
151#[serde(default)]
152#[serde(rename_all = "camelCase")]
153pub struct CompletionSettings {
154 pub timeout_ms: u32,
155 pub result_size_limit: u32,
156 pub subject_completion_trigger_length: u32,
157 pub object_completion_suffix: bool,
158 pub variable_completion_limit: Option<u32>,
160 pub same_subject_semicolon: bool,
163}
164
165impl Default for CompletionSettings {
166 fn default() -> Self {
167 Self {
168 timeout_ms: 5000,
169 result_size_limit: 100,
170 subject_completion_trigger_length: 3,
171 object_completion_suffix: true,
172 variable_completion_limit: None,
173 same_subject_semicolon: true,
174 }
175 }
176}
177
178#[derive(Debug, Deserialize, Serialize, PartialEq, Clone)]
179#[serde(default)]
180#[serde(rename_all = "camelCase")]
181pub struct FormatSettings {
182 pub align_predicates: bool,
183 pub align_prefixes: bool,
184 pub separate_prologue: bool,
185 pub capitalize_keywords: bool,
186 pub insert_spaces: Option<bool>,
187 pub tab_size: Option<u8>,
188 pub where_new_line: bool,
189 pub filter_same_line: bool,
190 pub compact: Option<u32>,
191 pub line_length: u32,
192 pub contract_triples: bool,
193 pub keep_empty_lines: bool,
197}
198
199impl Default for FormatSettings {
200 fn default() -> Self {
201 Self {
202 align_predicates: true,
203 align_prefixes: false,
204 separate_prologue: false,
205 capitalize_keywords: true,
206 insert_spaces: Some(true),
207 tab_size: Some(2),
208 where_new_line: false,
209 filter_same_line: true,
210 compact: None,
211 line_length: 120,
212 contract_triples: false,
213 keep_empty_lines: false,
214 }
215 }
216}
217
218#[derive(Debug, Serialize, Deserialize, PartialEq)]
219#[serde(rename_all = "camelCase")]
220pub struct PrefixesSettings {
221 pub add_missing: Option<bool>,
222 pub remove_unused: Option<bool>,
223}
224
225impl Default for PrefixesSettings {
226 fn default() -> Self {
227 Self {
228 add_missing: Some(true),
229 remove_unused: Some(false),
230 }
231 }
232}
233#[derive(Debug, Serialize, Deserialize)]
234pub struct Replacement {
235 pub pattern: String,
236 pub replacement: String,
237 #[serde(skip)]
242 regex: OnceLock<Regex>,
243}
244
245impl PartialEq for Replacement {
248 fn eq(&self, other: &Self) -> bool {
249 self.pattern == other.pattern && self.replacement == other.replacement
250 }
251}
252
253impl Replacement {
254 pub fn new(pattern: &str, replacement: &str) -> Self {
255 Self {
256 pattern: pattern.to_string(),
257 replacement: replacement.to_string(),
258 regex: OnceLock::new(),
259 }
260 }
261
262 pub fn regex(&self) -> Result<&Regex, regex::Error> {
267 if let Some(regex) = self.regex.get() {
268 return Ok(regex);
269 }
270 let regex = Regex::new(&self.pattern)?;
271 Ok(self.regex.get_or_init(|| regex))
272 }
273}
274
275#[derive(Debug, Serialize, Deserialize, PartialEq)]
276#[serde(rename_all = "camelCase")]
277pub struct Replacements {
278 pub object_variable: Vec<Replacement>,
279}
280
281impl Replacements {
282 pub fn apply_object_variable(&self, name: &str) -> String {
292 let mut name = name.to_string();
293 for replacement in self.object_variable.iter() {
294 match replacement.regex() {
295 Ok(regex) => {
296 name = regex
297 .replace_all(&name, &replacement.replacement)
298 .to_string();
299 tracing::debug!("new name: {name}");
300 }
301 Err(error) => tracing::warn!(
302 "Skipping object variable replacement with invalid pattern \"{}\": {}",
303 replacement.pattern,
304 error
305 ),
306 }
307 }
308 name
309 }
310
311 pub fn validate(&self) -> Result<(), String> {
316 for replacement in self.object_variable.iter() {
317 replacement.regex().map_err(|error| {
318 format!(
319 "invalid objectVariable pattern \"{}\": {}",
320 replacement.pattern, error
321 )
322 })?;
323 }
324 Ok(())
325 }
326}
327
328impl Default for Replacements {
329 fn default() -> Self {
330 Self {
331 object_variable: vec![
332 Replacement::new(r"^has (\w+)", "$1"),
336 Replacement::new(r"^has([A-Z]\w*)", "$1"),
337 Replacement::new(r"^(\w+)edBy", "$1"),
338 Replacement::new(r"([a-z0-9])([A-Z])", "${1}_${2}"),
343 Replacement::new(r"[^a-zA-Z0-9_]+", "_"),
347 Replacement::new(r"^_+|_+$", ""),
349 ],
350 }
351 }
352}
353
354#[derive(Debug, Deserialize, Serialize, PartialEq)]
355#[serde(rename_all = "camelCase")]
356pub struct Settings {
357 #[serde(default)]
359 pub format: FormatSettings,
360 #[serde(default)]
362 pub completion: CompletionSettings,
363 pub backends: Option<BackendsSettings>,
365 pub prefixes: Option<PrefixesSettings>,
367 pub replacements: Option<Replacements>,
369 #[serde(default)]
371 pub auto_line_break: bool,
372}
373
374impl Default for Settings {
375 fn default() -> Self {
376 Self {
377 format: FormatSettings::default(),
378 completion: CompletionSettings::default(),
379 backends: None,
380 prefixes: Some(PrefixesSettings::default()),
381 replacements: Some(Replacements::default()),
382 auto_line_break: false,
383 }
384 }
385}
386
387#[cfg(not(target_arch = "wasm32"))]
388fn load_user_configuration() -> Result<Settings, ConfigError> {
389 Config::builder()
390 .add_source(config::File::with_name("qlue-ls"))
391 .build()?
392 .try_deserialize::<Settings>()
393}
394
395impl Settings {
396 pub fn new() -> Self {
397 #[cfg(not(target_arch = "wasm32"))]
398 match load_user_configuration() {
399 Ok(settings) => {
400 tracing::info!("Loaded user configuration!!");
401 if let Some(Err(error)) = settings.replacements.as_ref().map(Replacements::validate)
406 {
407 tracing::warn!("Ignoring a replacement from the user configuration: {error}");
408 }
409 settings
410 }
411 Err(error) => {
412 tracing::info!(
413 "Did not load user-configuration:\n{}\n falling back to default values",
414 error
415 );
416 Settings::default()
417 }
418 }
419 #[cfg(target_arch = "wasm32")]
420 Settings::default()
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427 use config::{Config, FileFormat};
428
429 fn parse_yaml<T: serde::de::DeserializeOwned>(yaml: &str) -> T {
430 Config::builder()
431 .add_source(config::File::from_str(yaml, FileFormat::Yaml))
432 .build()
433 .unwrap()
434 .try_deserialize()
435 .unwrap()
436 }
437
438 #[test]
439 fn test_backend_configuration_valid_queries_all_variants() {
440 let yaml = r#"
441 name: TestBackend
442 url: https://example.com/sparql
443 healthCheckUrl: https://example.com/health
444 requestMethod: GET
445 prefixMap:
446 rdf: http://www.w3.org/1999/02/22-rdf-syntax-ns#
447 rdfs: http://www.w3.org/2000/01/rdf-schema#
448 default: false
449 queries:
450 subjectCompletion: SELECT ?qls_entity ?qls_label ?qls_detail WHERE { ?qls_entity a ?type }
451 predicateCompletionContextSensitive: SELECT ?qls_entity WHERE { ?s ?qls_entity ?o }
452 predicateCompletionContextInsensitive: SELECT ?qls_entity WHERE { [] ?qls_entity [] }
453 objectCompletionContextSensitive: SELECT ?qls_entity WHERE { ?s ?p ?qls_entity }
454 objectCompletionContextInsensitive: SELECT ?qls_entity WHERE { [] [] ?qls_entity }
455 valuesCompletionContextSensitive: SELECT ?qls_entity WHERE { ?qls_entity ?p ?o }
456 valuesCompletionContextInsensitive: SELECT ?qls_entity WHERE { ?qls_entity ?p ?o }
457 "#;
458
459 let config: BackendConfiguration = parse_yaml(yaml);
460
461 assert_eq!(config.name, "TestBackend");
462 assert_eq!(config.url, "https://example.com/sparql");
463 assert!(!config.default);
464 assert_eq!(config.queries.len(), 7);
465 assert!(
466 config
467 .queries
468 .contains_key(&CompletionTemplate::SubjectCompletion)
469 );
470 assert!(
471 config
472 .queries
473 .contains_key(&CompletionTemplate::PredicateCompletionContextSensitive)
474 );
475 assert!(
476 config
477 .queries
478 .contains_key(&CompletionTemplate::PredicateCompletionContextInsensitive)
479 );
480 assert!(
481 config
482 .queries
483 .contains_key(&CompletionTemplate::ObjectCompletionContextSensitive)
484 );
485 assert!(
486 config
487 .queries
488 .contains_key(&CompletionTemplate::ObjectCompletionContextInsensitive)
489 );
490 assert!(
491 config
492 .queries
493 .contains_key(&CompletionTemplate::ValuesCompletionContextSensitive)
494 );
495 assert!(
496 config
497 .queries
498 .contains_key(&CompletionTemplate::ValuesCompletionContextInsensitive)
499 );
500 }
501
502 #[test]
503 fn test_backend_configuration_queries_subset() {
504 let yaml = r#"
505 name: MinimalBackend
506 url: https://example.com/sparql
507 prefixMap: {}
508 queries:
509 subjectCompletion: SELECT ?qls_entity WHERE { ?qls_entity ?p ?o }
510 objectCompletionContextInsensitive: SELECT ?qls_entity WHERE { ?s ?p ?qls_entity }
511 "#;
512
513 let config: BackendConfiguration = parse_yaml(yaml);
514
515 assert_eq!(config.queries.len(), 2);
516 assert!(
517 config
518 .queries
519 .contains_key(&CompletionTemplate::SubjectCompletion)
520 );
521 assert!(
522 config
523 .queries
524 .contains_key(&CompletionTemplate::ObjectCompletionContextInsensitive)
525 );
526 assert!(
527 !config
528 .queries
529 .contains_key(&CompletionTemplate::PredicateCompletionContextSensitive)
530 );
531 }
532
533 #[test]
534 fn test_backend_configuration_rejects_invalid_query_key() {
535 let yaml = r#"
537 name: TestBackend
538 url: https://example.com/sparql
539 prefixMap: {}
540 queries:
541 invalidQueryType: SELECT ?qls_entity WHERE { ?s ?p ?o }
542 subjectCompletion: SELECT ?qls_entity WHERE { ?qls_entity ?p ?o }
543 "#;
544
545 let result = Config::builder()
546 .add_source(config::File::from_str(yaml, FileFormat::Yaml))
547 .build()
548 .unwrap()
549 .try_deserialize::<BackendConfiguration>();
550 assert!(result.is_err());
551 }
552
553 #[test]
554 fn test_backend_configuration_with_multiline_queries() {
555 let yaml = r#"
556 name: WikidataBackend
557 url: https://query.wikidata.org/sparql
558 healthCheckUrl: https://query.wikidata.org/
559 prefixMap:
560 wd: http://www.wikidata.org/entity/
561 wdt: http://www.wikidata.org/prop/direct/
562 rdfs: http://www.w3.org/2000/01/rdf-schema#
563 default: false
564 queries:
565 subjectCompletion: |
566 SELECT ?qls_entity ?qls_label ?qls_detail
567 WHERE {
568 ?qls_entity rdfs:label ?qls_label .
569 OPTIONAL { ?qls_entity schema:description ?qls_detail }
570 FILTER(LANG(?qls_label) = "en")
571 }
572 LIMIT 100
573 predicateCompletionContextSensitive: |
574 SELECT ?qls_entity WHERE {
575 ?s ?qls_entity ?o
576 }
577 objectCompletionContextInsensitive: SELECT ?qls_entity WHERE { [] [] ?qls_entity }
578 "#;
579
580 let config: BackendConfiguration = parse_yaml(yaml);
581
582 assert_eq!(config.name, "WikidataBackend");
583 assert_eq!(config.url, "https://query.wikidata.org/sparql");
584 assert!(!config.default);
585 assert_eq!(config.prefix_map.len(), 3);
586 assert_eq!(config.queries.len(), 3);
587
588 let subject_query = config
590 .queries
591 .get(&CompletionTemplate::SubjectCompletion)
592 .unwrap();
593 assert!(subject_query.contains("SELECT ?qls_entity ?qls_label ?qls_detail"));
594 assert!(subject_query.contains("FILTER(LANG(?qls_label) = \"en\")"));
595 }
596
597 #[test]
598 fn test_backends_settings_multiple_backends() {
599 let yaml = r#"
600 backends:
601 wikidata:
602 name: Wikidata
603 url: https://query.wikidata.org/sparql
604 prefixMap:
605 wd: http://www.wikidata.org/entity/
606 queries:
607 subjectCompletion: SELECT ?qls_entity WHERE { ?qls_entity ?p ?o }
608 dbpedia:
609 name: DBpedia
610 url: https://dbpedia.org/sparql
611 prefixMap:
612 dbo: http://dbpedia.org/ontology/
613 default: true
614 queries:
615 objectCompletionContextSensitive: SELECT ?qls_entity WHERE { ?s ?p ?qls_entity }
616 "#;
617
618 let settings: BackendsSettings = parse_yaml(yaml);
619
620 assert_eq!(settings.backends.len(), 2);
621 assert!(settings.backends.contains_key("wikidata"));
622 assert!(settings.backends.contains_key("dbpedia"));
623
624 let wikidata = settings.backends.get("wikidata").unwrap();
625 assert_eq!(wikidata.name, "Wikidata");
626 assert_eq!(wikidata.queries.len(), 1);
627
628 let dbpedia = settings.backends.get("dbpedia").unwrap();
629 assert_eq!(dbpedia.name, "DBpedia");
630 assert!(dbpedia.default);
631 }
632
633 #[test]
634 fn test_full_settings_deserialization() {
635 let yaml = r#"
636 format:
637 alignPredicates: true
638 alignPrefixes: false
639 separatePrologue: false
640 capitalizeKeywords: true
641 insertSpaces: true
642 tabSize: 2
643 whereNewLine: false
644 filterSameLine: true
645 completion:
646 timeoutMs: 5000
647 resultSizeLimit: 100
648 backends:
649 backends:
650 wikidata:
651 name: Wikidata
652 url: https://query.wikidata.org/sparql
653 healthCheckUrl: https://query.wikidata.org/
654 prefixMap:
655 wd: http://www.wikidata.org/entity/
656 wdt: http://www.wikidata.org/prop/direct/
657 default: true
658 queries:
659 subjectCompletion: SELECT ?qls_entity WHERE { ?qls_entity ?p ?o }
660 predicateCompletionContextSensitive: SELECT ?qls_entity WHERE { ?s ?qls_entity ?o }
661 prefixes:
662 addMissing: true
663 removeUnused: false
664 "#;
665
666 let settings: Settings = parse_yaml(yaml);
667
668 assert!(settings.format.align_predicates);
669 assert_eq!(settings.completion.timeout_ms, 5000);
670 assert!(settings.backends.is_some());
671
672 let backends = settings.backends.unwrap();
673 assert_eq!(backends.backends.len(), 1);
674
675 let wikidata = backends.backends.get("wikidata").unwrap();
676 assert_eq!(wikidata.name, "Wikidata");
677 assert!(wikidata.default);
678 assert_eq!(wikidata.queries.len(), 2);
679 }
680
681 #[test]
684 fn test_default_replacements_are_valid_regexes() {
685 assert_eq!(Replacements::default().validate(), Ok(()));
686 }
687
688 #[test]
689 fn test_validate_reports_an_invalid_pattern() {
690 let replacements = Replacements {
691 object_variable: vec![
692 Replacement::new(r"^has(\w+)", "$1"),
693 Replacement::new(r"([unclosed", ""),
694 ],
695 };
696
697 let error = replacements
698 .validate()
699 .expect_err("an unparsable pattern should be reported");
700
701 assert!(
702 error.contains("([unclosed"),
703 "the error should name the offending pattern, got: {}",
704 error
705 );
706 }
707
708 #[test]
709 fn test_invalid_pattern_is_skipped_instead_of_panicking() {
710 let replacements = Replacements {
711 object_variable: vec![
712 Replacement::new(r"([unclosed", ""),
713 Replacement::new(r"^has(\w+)", "$1"),
714 ],
715 };
716
717 assert_eq!(replacements.apply_object_variable("hasAuthor"), "Author");
719 }
720
721 #[test]
722 fn test_only_the_invalid_pattern_is_skipped() {
723 let replacements = Replacements {
724 object_variable: vec![
725 Replacement::new(r"^has(\w+)", "$1"),
726 Replacement::new(r"*nope", ""),
727 Replacement::new(r"Author", "Creator"),
728 ],
729 };
730
731 assert_eq!(replacements.apply_object_variable("hasAuthor"), "Creator");
732 }
733
734 #[test]
735 fn test_regex_is_compiled_once_and_cached() {
736 let replacement = Replacement::new(r"^has(\w+)", "$1");
737
738 let first = replacement.regex().expect("should compile");
739 let second = replacement.regex().expect("should compile");
740
741 assert!(
742 std::ptr::eq(first, second),
743 "the compiled pattern should be cached, not recompiled"
744 );
745 }
746
747 #[test]
748 fn test_invalid_regex_reports_an_error_every_time() {
749 let replacement = Replacement::new(r"([unclosed", "");
750
751 assert!(replacement.regex().is_err());
754 assert!(replacement.regex().is_err());
755 }
756
757 #[test]
758 fn test_replacement_equality_ignores_the_compiled_cache() {
759 let uncompiled = Replacement::new(r"^has(\w+)", "$1");
760 let compiled = Replacement::new(r"^has(\w+)", "$1");
761 compiled.regex().expect("should compile");
762
763 assert_eq!(uncompiled, compiled);
764 }
765
766 #[test]
767 fn test_default_replacements_strip_has_prefix_camel_case() {
768 let replacements = Replacements::default();
769
770 assert_eq!(replacements.apply_object_variable("hasAuthor"), "Author");
771 assert_eq!(
772 replacements.apply_object_variable("hasBirthDate"),
773 "Birth_Date"
774 );
775 assert_eq!(replacements.apply_object_variable("hasX"), "X");
778 }
779
780 #[test]
781 fn test_default_replacements_split_camel_case() {
782 let replacements = Replacements::default();
783
784 assert_eq!(
786 replacements.apply_object_variable("birthDate"),
787 "birth_Date"
788 );
789 assert_eq!(
790 replacements.apply_object_variable("placeOfBirth"),
791 "place_Of_Birth"
792 );
793 assert_eq!(replacements.apply_object_variable("ISBN"), "ISBN");
795 assert_eq!(replacements.apply_object_variable("hasISBN"), "ISBN");
796 }
797
798 #[test]
799 fn test_default_replacements_strip_has_prefix_space_separated() {
800 let replacements = Replacements::default();
801
802 assert_eq!(replacements.apply_object_variable("has author"), "author");
803 assert_eq!(
806 replacements.apply_object_variable("has birth date"),
807 "birth_date"
808 );
809 }
810
811 #[test]
812 fn test_default_replacements_strip_ed_by_suffix() {
813 let replacements = Replacements::default();
814
815 assert_eq!(replacements.apply_object_variable("authoredBy"), "author");
816 assert_eq!(replacements.apply_object_variable("directedBy"), "direct");
817 }
818
819 #[test]
820 fn test_default_replacements_collapse_non_word_characters() {
821 let replacements = Replacements::default();
822
823 assert_eq!(
824 replacements.apply_object_variable("place of birth"),
825 "place_of_birth"
826 );
827 assert_eq!(replacements.apply_object_variable("part-of"), "part_of");
828 assert_eq!(
829 replacements.apply_object_variable("under_score"),
830 "under_score"
831 );
832 assert_eq!(
834 replacements.apply_object_variable("date of - birth"),
835 "date_of_birth"
836 );
837 }
838
839 #[test]
840 fn test_default_replacements_trim_leading_and_trailing_separators() {
841 let replacements = Replacements::default();
842
843 assert_eq!(replacements.apply_object_variable("P31/P279*"), "P31_P279");
846 assert_eq!(replacements.apply_object_variable(" author "), "author");
847 assert_eq!(replacements.apply_object_variable("(author)"), "author");
848 assert_eq!(replacements.apply_object_variable("///"), "");
851 }
852
853 #[test]
854 fn test_default_replacements_leave_unmatched_names_untouched() {
855 let replacements = Replacements::default();
856
857 assert_eq!(replacements.apply_object_variable("author"), "author");
858 assert_eq!(replacements.apply_object_variable("hasty"), "hasty");
861 assert_eq!(replacements.apply_object_variable("has"), "has");
862 assert_eq!(
864 replacements.apply_object_variable("overhasAuthor"),
865 "overhas_Author"
866 );
867 }
868
869 #[test]
870 fn test_replacements_are_applied_in_order() {
871 let replacements = Replacements {
873 object_variable: vec![
874 Replacement::new(r"^has(\w+)", "$1"),
875 Replacement::new(r"^Author$", "creator"),
876 ],
877 };
878
879 assert_eq!(replacements.apply_object_variable("hasAuthor"), "creator");
880 }
881
882 #[test]
883 fn test_empty_replacements_are_a_no_op() {
884 let replacements = Replacements {
885 object_variable: vec![],
886 };
887
888 assert_eq!(replacements.apply_object_variable("hasAuthor"), "hasAuthor");
889 }
890
891 #[test]
892 fn test_replacements_deserialize_from_yaml() {
893 let yaml = r#"
894 objectVariable:
895 - pattern: "^has (\\w+)"
896 replacement: "$1"
897 - pattern: "Suffix$"
898 replacement: ""
899 "#;
900
901 let replacements: Replacements = parse_yaml(yaml);
902
903 assert_eq!(
904 replacements.object_variable,
905 vec![
906 Replacement::new(r"^has (\w+)", "$1"),
907 Replacement::new(r"Suffix$", ""),
908 ]
909 );
910 assert_eq!(
911 replacements.apply_object_variable("has authorSuffix"),
912 "author"
913 );
914 }
915
916 #[test]
917 fn test_settings_replacements_deserialize_from_yaml() {
918 let yaml = r#"
919 format: {}
920 completion: {}
921 replacements:
922 objectVariable:
923 - pattern: "^is(\\w+)"
924 replacement: "$1"
925 "#;
926
927 let settings: Settings = parse_yaml(yaml);
928 let replacements = settings
929 .replacements
930 .expect("replacements should be deserialized");
931
932 assert_eq!(replacements.object_variable.len(), 1);
933 assert_eq!(replacements.apply_object_variable("isPartOf"), "PartOf");
934 }
935
936 #[test]
937 fn test_settings_default_includes_default_replacements() {
938 let settings = Settings::default();
939
940 assert_eq!(
941 settings.replacements,
942 Some(Replacements::default()),
943 "default settings should carry the default replacements"
944 );
945 }
946
947 #[test]
948 fn test_settings_without_replacements_key_is_none() {
949 let yaml = r#"
950 format: {}
951 completion: {}
952 "#;
953
954 let settings: Settings = parse_yaml(yaml);
955
956 assert_eq!(settings.replacements, None);
959 }
960}