Skip to main content

qlue_ls/server/
configuration.rs

1//! Server configuration and settings structures.
2//!
3//! This module defines the configuration schema for qlue-ls, loadable from
4//! `qlue-ls.toml` or `qlue-ls.yml` files in the working directory.
5//!
6//! # Key Types
7//!
8//! - [`Settings`]: Top-level configuration container
9//! - [`FormatSettings`]: Formatter options (alignment, capitalization, spacing)
10//! - [`CompletionSettings`]: Timeout and result limits for completions
11//! - [`BackendConfiguration`]: SPARQL endpoint with prefix map and custom queries
12//!
13//! # Configuration Loading
14//!
15//! [`Settings::new`] attempts to load from a config file. If not found or invalid,
16//! it falls back to [`Settings::default`]. Settings can also be updated at runtime
17//! via the `qlueLs/changeSettings` notification.
18//!
19//! # Backend Configuration
20//!
21//! Backends define SPARQL endpoints used for completions and query execution.
22//! Each backend can have:
23//! - Custom prefix maps for URI compression
24//! - Request method (GET/POST)
25//! - Custom SPARQL templates for completion queries
26//!
27//! # Related Modules
28//!
29//! - [`super::Server`]: Stores settings in `Server.settings`
30//! - [`super::message_handler::settings`]: Handles runtime settings changes
31
32use 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    /// Maximum number of variable completions to suggest. None means unlimited.
159    pub variable_completion_limit: Option<u32>,
160    /// When completing a subject that matches the previous triple's subject,
161    /// transform the completion to use semicolon notation instead of starting a new triple.
162    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    /// When enabled, preserves intentional blank lines from the original source.
194    /// Consecutive blank lines are collapsed into a single empty line.
195    /// Disabled by default to preserve current behavior.
196    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    /// Cache for the compiled [`Self::pattern`].
238    ///
239    /// INFO: not part of the configuration format, it is skipped in both
240    /// directions and starts out empty.
241    #[serde(skip)]
242    regex: OnceLock<Regex>,
243}
244
245// NOTE: `Regex` has no `PartialEq`, and the cache is derived from `pattern`
246// anyway, so only the configured values take part in the comparison.
247impl 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    /// The compiled [`Self::pattern`], compiled once and cached afterwards.
263    ///
264    /// Returns an error for an invalid pattern. Patterns come from user
265    /// configuration, so this is a normal outcome and not a bug.
266    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    /// Applies the configured `object_variable` replacements to `name`, in order.
283    ///
284    /// Each replacement is a regex `pattern` and a `replacement` string that may
285    /// reference capture groups (`$1`, ...). Replacements are applied
286    /// sequentially, so a later pattern sees the output of the earlier ones.
287    ///
288    /// WARNING: an invalid pattern is skipped with a warning, the remaining
289    /// replacements are still applied. Use [`Self::validate`] to reject invalid
290    /// patterns where they enter the server instead.
291    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    /// Checks that every configured pattern compiles.
312    ///
313    /// Compiled patterns are cached, so a successful validation also warms the
314    /// cache for the following [`Self::apply_object_variable`] calls.
315    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                // NOTE: strip the "has" prefix and the "edBy" suffix.
333                // These run before the camelCase split below, because they
334                // match on the camelCase boundary themselves.
335                Replacement::new(r"^has (\w+)", "$1"),
336                Replacement::new(r"^has([A-Z]\w*)", "$1"),
337                Replacement::new(r"^(\w+)edBy", "$1"),
338                // NOTE: turn camelCase boundaries into snake_case separators,
339                // so "birthDate" becomes "birth_date" and not "birthdate".
340                // INFO: `${1}` instead of `$1`, otherwise "$1_" would be read
341                // as a reference to a capture group named "1_".
342                Replacement::new(r"([a-z0-9])([A-Z])", "${1}_${2}"),
343                // NOTE: collapse runs of invalid characters into a single
344                // separator instead of dropping them, so multi word names keep
345                // their word boundaries.
346                Replacement::new(r"[^a-zA-Z0-9_]+", "_"),
347                // NOTE: a leading or trailing separator is never meaningful.
348                Replacement::new(r"^_+|_+$", ""),
349            ],
350        }
351    }
352}
353
354#[derive(Debug, Deserialize, Serialize, PartialEq)]
355#[serde(rename_all = "camelCase")]
356pub struct Settings {
357    /// Format settings
358    #[serde(default)]
359    pub format: FormatSettings,
360    /// Completion Settings
361    #[serde(default)]
362    pub completion: CompletionSettings,
363    /// Backend configurations
364    pub backends: Option<BackendsSettings>,
365    /// Automatically add and remove prefix declarations
366    pub prefixes: Option<PrefixesSettings>,
367    /// Automatically add and remove prefix declarations
368    pub replacements: Option<Replacements>,
369    /// Automatically insert a line break after typing `;` or `.` following a valid triple.
370    #[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                // NOTE: an invalid pattern does not invalidate the whole
402                // configuration file, it is skipped when the replacements are
403                // applied. Warn about it once here instead of on every
404                // completion request.
405                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        // This test ensures that invalid query keys are rejected
536        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        // Verify multiline query was parsed correctly
589        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    // NOTE: object variable replacements
682
683    #[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        // WARNING: this used to panic.
718        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        // INFO: nothing is cached for an invalid pattern, but it keeps failing
752        // in the same way instead of panicking.
753        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        // INFO: a single trailing character is not an `\w*` match for `[A-Z]\w*`,
776        // but `[A-Z]` alone still matches because `\w*` may be empty.
777        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        // INFO: lowercasing is not done here, `to_sparql_variable` does it.
785        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        // INFO: no lowercase-to-uppercase boundary, so acronyms stay intact.
794        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        // NOTE: `^has (\w+)` only captures the first word, the remaining words
804        // are joined by the separator collapsing of the later patterns.
805        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        // NOTE: a run of invalid characters collapses into a single separator.
833        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        // WARNING: without the trim, the collapsing above would leave a
844        // trailing "_" here.
845        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        // INFO: a name made up entirely of invalid characters collapses to
849        // nothing, `to_sparql_variable` turns that into "var".
850        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        // INFO: "has" is only stripped as a prefix followed by a space or an
859        // uppercase letter, not as a bare word or a lowercase continuation.
860        assert_eq!(replacements.apply_object_variable("hasty"), "hasty");
861        assert_eq!(replacements.apply_object_variable("has"), "has");
862        // INFO: the "has" here is not a prefix, so only the camelCase split applies.
863        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        // NOTE: the second replacement must see the output of the first.
872        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        // WARNING: an omitted `replacements` key disables replacements entirely,
957        // it does NOT fall back to `Replacements::default()`.
958        assert_eq!(settings.replacements, None);
959    }
960}