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};
33
34#[cfg(not(target_arch = "wasm32"))]
35use config::{Config, ConfigError};
36use serde::{Deserialize, Serialize};
37
38use crate::server::lsp::{SparqlEngine, base_types::LSPAny};
39
40#[derive(Debug, Serialize, Deserialize, Default, PartialEq)]
41#[serde(default)]
42pub struct BackendsSettings {
43    pub backends: HashMap<String, BackendConfiguration>,
44}
45
46#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
47#[serde(rename_all = "camelCase")]
48pub struct BackendConfiguration {
49    pub name: String,
50    pub url: String,
51    pub health_check_url: Option<String>,
52    pub engine: Option<SparqlEngine>,
53    pub request_method: Option<RequestMethod>,
54    #[serde(default)]
55    pub prefix_map: HashMap<String, String>,
56    #[serde(default)]
57    pub default: bool,
58    #[serde(default)]
59    pub queries: HashMap<CompletionTemplate, String>,
60    pub additional_data: Option<LSPAny>,
61}
62
63#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase", try_from = "String")]
65pub(crate) enum CompletionTemplate {
66    Hover,
67    SubjectCompletion,
68    PredicateCompletionContextSensitive,
69    PredicateCompletionContextInsensitive,
70    ObjectCompletionContextSensitive,
71    ObjectCompletionContextInsensitive,
72    ValuesCompletionContextSensitive,
73    ValuesCompletionContextInsensitive,
74}
75
76#[derive(Debug)]
77pub struct UnknownTemplateError(String);
78
79impl fmt::Display for UnknownTemplateError {
80    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
81        write!(f, "unknown completion query template \"{}\"", &self.0)
82    }
83}
84
85impl TryFrom<String> for CompletionTemplate {
86    type Error = UnknownTemplateError;
87
88    fn try_from(s: String) -> Result<Self, Self::Error> {
89        match s.as_str() {
90            "hover" => Ok(CompletionTemplate::Hover),
91            "subjectCompletion" => Ok(CompletionTemplate::SubjectCompletion),
92            "predicateCompletionContextInsensitive" => {
93                Ok(CompletionTemplate::PredicateCompletionContextInsensitive)
94            }
95            "predicateCompletionContextSensitive" => {
96                Ok(CompletionTemplate::PredicateCompletionContextSensitive)
97            }
98            "objectCompletionContextInsensitive" => {
99                Ok(CompletionTemplate::ObjectCompletionContextInsensitive)
100            }
101            "objectCompletionContextSensitive" => {
102                Ok(CompletionTemplate::ObjectCompletionContextSensitive)
103            }
104            "valuesCompletionContextSensitive" => {
105                Ok(CompletionTemplate::ValuesCompletionContextSensitive)
106            }
107            "valuesCompletionContextInsensitive" => {
108                Ok(CompletionTemplate::ValuesCompletionContextInsensitive)
109            }
110            _ => Err(UnknownTemplateError(s.to_string())),
111        }
112    }
113}
114
115impl fmt::Display for CompletionTemplate {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        match self {
118            CompletionTemplate::Hover => write!(f, "hover"),
119            CompletionTemplate::SubjectCompletion => write!(f, "subjectCompletion"),
120            CompletionTemplate::PredicateCompletionContextSensitive => {
121                write!(f, "predicateCompletionContextSensitive")
122            }
123            CompletionTemplate::PredicateCompletionContextInsensitive => {
124                write!(f, "predicateCompletionContextInsensitive")
125            }
126            CompletionTemplate::ObjectCompletionContextSensitive => {
127                write!(f, "objectCompletionContextSensitive")
128            }
129            CompletionTemplate::ObjectCompletionContextInsensitive => {
130                write!(f, "objectCompletionContextInsensitive")
131            }
132            CompletionTemplate::ValuesCompletionContextSensitive => {
133                write!(f, "valuesCompletionContextSensitive")
134            }
135            CompletionTemplate::ValuesCompletionContextInsensitive => {
136                write!(f, "valuesCompletionContextInsensitive")
137            }
138        }
139    }
140}
141
142#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
143#[allow(clippy::upper_case_acronyms)]
144pub enum RequestMethod {
145    GET,
146    POST,
147}
148
149#[derive(Debug, Serialize, Deserialize, PartialEq)]
150#[serde(default)]
151#[serde(rename_all = "camelCase")]
152pub struct CompletionSettings {
153    pub timeout_ms: u32,
154    pub result_size_limit: u32,
155    pub subject_completion_trigger_length: u32,
156    pub object_completion_suffix: bool,
157    /// Maximum number of variable completions to suggest. None means unlimited.
158    pub variable_completion_limit: Option<u32>,
159    /// When completing a subject that matches the previous triple's subject,
160    /// transform the completion to use semicolon notation instead of starting a new triple.
161    pub same_subject_semicolon: bool,
162}
163
164impl Default for CompletionSettings {
165    fn default() -> Self {
166        Self {
167            timeout_ms: 5000,
168            result_size_limit: 100,
169            subject_completion_trigger_length: 3,
170            object_completion_suffix: true,
171            variable_completion_limit: None,
172            same_subject_semicolon: true,
173        }
174    }
175}
176
177#[derive(Debug, Deserialize, Serialize, PartialEq, Clone)]
178#[serde(default)]
179#[serde(rename_all = "camelCase")]
180pub struct FormatSettings {
181    pub align_predicates: bool,
182    pub align_prefixes: bool,
183    pub separate_prologue: bool,
184    pub capitalize_keywords: bool,
185    pub insert_spaces: Option<bool>,
186    pub tab_size: Option<u8>,
187    pub where_new_line: bool,
188    pub filter_same_line: bool,
189    pub compact: Option<u32>,
190    pub line_length: u32,
191    pub contract_triples: bool,
192}
193
194impl Default for FormatSettings {
195    fn default() -> Self {
196        Self {
197            align_predicates: true,
198            align_prefixes: false,
199            separate_prologue: false,
200            capitalize_keywords: true,
201            insert_spaces: Some(true),
202            tab_size: Some(2),
203            where_new_line: false,
204            filter_same_line: true,
205            compact: None,
206            line_length: 120,
207            contract_triples: false,
208        }
209    }
210}
211
212#[derive(Debug, Serialize, Deserialize, PartialEq)]
213#[serde(rename_all = "camelCase")]
214pub struct PrefixesSettings {
215    pub add_missing: Option<bool>,
216    pub remove_unused: Option<bool>,
217}
218
219impl Default for PrefixesSettings {
220    fn default() -> Self {
221        Self {
222            add_missing: Some(true),
223            remove_unused: Some(false),
224        }
225    }
226}
227#[derive(Debug, Serialize, Deserialize, PartialEq)]
228pub struct Replacement {
229    pub pattern: String,
230    pub replacement: String,
231}
232
233impl Replacement {
234    pub fn new(pattern: &str, replacement: &str) -> Self {
235        Self {
236            pattern: pattern.to_string(),
237            replacement: replacement.to_string(),
238        }
239    }
240}
241
242#[derive(Debug, Serialize, Deserialize, PartialEq)]
243#[serde(rename_all = "camelCase")]
244pub struct Replacements {
245    pub object_variable: Vec<Replacement>,
246}
247
248impl Default for Replacements {
249    fn default() -> Self {
250        Self {
251            object_variable: vec![
252                Replacement::new(r"^has (\w+)", "$1"),
253                Replacement::new(r"\s", "_"),
254                Replacement::new(r"^has([A-Z]\w*)", "$1"),
255                Replacement::new(r"^(\w+)edBy", "$1"),
256                Replacement::new(r"([^a-zA-Z0-9_])", ""),
257            ],
258        }
259    }
260}
261
262#[derive(Debug, Deserialize, Serialize, PartialEq)]
263pub struct Settings {
264    /// Format settings
265    pub format: FormatSettings,
266    /// Completion Settings
267    pub completion: CompletionSettings,
268    /// Backend configurations
269    pub backends: Option<BackendsSettings>,
270    /// Automatically add and remove prefix declarations
271    pub prefixes: Option<PrefixesSettings>,
272    /// Automatically add and remove prefix declarations
273    pub replacements: Option<Replacements>,
274}
275
276impl Default for Settings {
277    fn default() -> Self {
278        Self {
279            format: FormatSettings::default(),
280            completion: CompletionSettings::default(),
281            backends: None,
282            prefixes: Some(PrefixesSettings::default()),
283            replacements: Some(Replacements::default()),
284        }
285    }
286}
287
288#[cfg(not(target_arch = "wasm32"))]
289fn load_user_configuration() -> Result<Settings, ConfigError> {
290    Config::builder()
291        .add_source(config::File::with_name("qlue-ls"))
292        .build()?
293        .try_deserialize::<Settings>()
294}
295
296impl Settings {
297    pub fn new() -> Self {
298        #[cfg(not(target_arch = "wasm32"))]
299        match load_user_configuration() {
300            Ok(settings) => {
301                log::info!("Loaded user configuration!!");
302                settings
303            }
304            Err(error) => {
305                log::info!(
306                    "Did not load user-configuration:\n{}\n falling back to default values",
307                    error
308                );
309                Settings::default()
310            }
311        }
312        #[cfg(target_arch = "wasm32")]
313        Settings::default()
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use config::{Config, FileFormat};
321
322    fn parse_yaml<T: serde::de::DeserializeOwned>(yaml: &str) -> T {
323        Config::builder()
324            .add_source(config::File::from_str(yaml, FileFormat::Yaml))
325            .build()
326            .unwrap()
327            .try_deserialize()
328            .unwrap()
329    }
330
331    #[test]
332    fn test_backend_configuration_valid_queries_all_variants() {
333        let yaml = r#"
334            name: TestBackend
335            url: https://example.com/sparql
336            healthCheckUrl: https://example.com/health
337            requestMethod: GET
338            prefixMap:
339              rdf: http://www.w3.org/1999/02/22-rdf-syntax-ns#
340              rdfs: http://www.w3.org/2000/01/rdf-schema#
341            default: false
342            queries:
343              subjectCompletion: SELECT ?qlue_ls_entity ?qlue_ls_label ?qlue_ls_detail WHERE { ?qlue_ls_entity a ?type }
344              predicateCompletionContextSensitive: SELECT ?qlue_ls_entity WHERE { ?s ?qlue_ls_entity ?o }
345              predicateCompletionContextInsensitive: SELECT ?qlue_ls_entity WHERE { [] ?qlue_ls_entity [] }
346              objectCompletionContextSensitive: SELECT ?qlue_ls_entity WHERE { ?s ?p ?qlue_ls_entity }
347              objectCompletionContextInsensitive: SELECT ?qlue_ls_entity WHERE { [] [] ?qlue_ls_entity }
348              valuesCompletionContextSensitive: SELECT ?qlue_ls_entity WHERE { ?qlue_ls_entity ?p ?o }
349              valuesCompletionContextInsensitive: SELECT ?qlue_ls_entity WHERE { ?qlue_ls_entity ?p ?o }
350        "#;
351
352        let config: BackendConfiguration = parse_yaml(yaml);
353
354        assert_eq!(config.name, "TestBackend");
355        assert_eq!(config.url, "https://example.com/sparql");
356        assert!(!config.default);
357        assert_eq!(config.queries.len(), 7);
358        assert!(
359            config
360                .queries
361                .contains_key(&CompletionTemplate::SubjectCompletion)
362        );
363        assert!(
364            config
365                .queries
366                .contains_key(&CompletionTemplate::PredicateCompletionContextSensitive)
367        );
368        assert!(
369            config
370                .queries
371                .contains_key(&CompletionTemplate::PredicateCompletionContextInsensitive)
372        );
373        assert!(
374            config
375                .queries
376                .contains_key(&CompletionTemplate::ObjectCompletionContextSensitive)
377        );
378        assert!(
379            config
380                .queries
381                .contains_key(&CompletionTemplate::ObjectCompletionContextInsensitive)
382        );
383        assert!(
384            config
385                .queries
386                .contains_key(&CompletionTemplate::ValuesCompletionContextSensitive)
387        );
388        assert!(
389            config
390                .queries
391                .contains_key(&CompletionTemplate::ValuesCompletionContextInsensitive)
392        );
393    }
394
395    #[test]
396    fn test_backend_configuration_queries_subset() {
397        let yaml = r#"
398            name: MinimalBackend
399            url: https://example.com/sparql
400            prefixMap: {}
401            queries:
402              subjectCompletion: SELECT ?qlue_ls_entity WHERE { ?qlue_ls_entity ?p ?o }
403              objectCompletionContextInsensitive: SELECT ?qlue_ls_entity WHERE { ?s ?p ?qlue_ls_entity }
404        "#;
405
406        let config: BackendConfiguration = parse_yaml(yaml);
407
408        assert_eq!(config.queries.len(), 2);
409        assert!(
410            config
411                .queries
412                .contains_key(&CompletionTemplate::SubjectCompletion)
413        );
414        assert!(
415            config
416                .queries
417                .contains_key(&CompletionTemplate::ObjectCompletionContextInsensitive)
418        );
419        assert!(
420            !config
421                .queries
422                .contains_key(&CompletionTemplate::PredicateCompletionContextSensitive)
423        );
424    }
425
426    #[test]
427    fn test_backend_configuration_rejects_invalid_query_key() {
428        // This test ensures that invalid query keys are rejected
429        let yaml = r#"
430            name: TestBackend
431            url: https://example.com/sparql
432            prefixMap: {}
433            queries:
434              invalidQueryType: SELECT ?qlue_ls_entity WHERE { ?s ?p ?o }
435              subjectCompletion: SELECT ?qlue_ls_entity WHERE { ?qlue_ls_entity ?p ?o }
436        "#;
437
438        let result = Config::builder()
439            .add_source(config::File::from_str(yaml, FileFormat::Yaml))
440            .build()
441            .unwrap()
442            .try_deserialize::<BackendConfiguration>();
443        assert!(result.is_err());
444    }
445
446    #[test]
447    fn test_backend_configuration_with_multiline_queries() {
448        let yaml = r#"
449            name: WikidataBackend
450            url: https://query.wikidata.org/sparql
451            healthCheckUrl: https://query.wikidata.org/
452            prefixMap:
453              wd: http://www.wikidata.org/entity/
454              wdt: http://www.wikidata.org/prop/direct/
455              rdfs: http://www.w3.org/2000/01/rdf-schema#
456            default: false
457            queries:
458              subjectCompletion: |
459                SELECT ?qlue_ls_entity ?qlue_ls_label ?qlue_ls_detail
460                WHERE {
461                  ?qlue_ls_entity rdfs:label ?qlue_ls_label .
462                  OPTIONAL { ?qlue_ls_entity schema:description ?qlue_ls_detail }
463                  FILTER(LANG(?qlue_ls_label) = "en")
464                }
465                LIMIT 100
466              predicateCompletionContextSensitive: |
467                SELECT ?qlue_ls_entity WHERE {
468                  ?s ?qlue_ls_entity ?o
469                }
470              objectCompletionContextInsensitive: SELECT ?qlue_ls_entity WHERE { [] [] ?qlue_ls_entity }
471        "#;
472
473        let config: BackendConfiguration = parse_yaml(yaml);
474
475        assert_eq!(config.name, "WikidataBackend");
476        assert_eq!(config.url, "https://query.wikidata.org/sparql");
477        assert!(!config.default);
478        assert_eq!(config.prefix_map.len(), 3);
479        assert_eq!(config.queries.len(), 3);
480
481        // Verify multiline query was parsed correctly
482        let subject_query = config
483            .queries
484            .get(&CompletionTemplate::SubjectCompletion)
485            .unwrap();
486        assert!(subject_query.contains("SELECT ?qlue_ls_entity ?qlue_ls_label ?qlue_ls_detail"));
487        assert!(subject_query.contains("FILTER(LANG(?qlue_ls_label) = \"en\")"));
488    }
489
490    #[test]
491    fn test_backends_settings_multiple_backends() {
492        let yaml = r#"
493            backends:
494              wikidata:
495                name: Wikidata
496                url: https://query.wikidata.org/sparql
497                prefixMap:
498                  wd: http://www.wikidata.org/entity/
499                queries:
500                  subjectCompletion: SELECT ?qlue_ls_entity WHERE { ?qlue_ls_entity ?p ?o }
501              dbpedia:
502                name: DBpedia
503                url: https://dbpedia.org/sparql
504                prefixMap:
505                  dbo: http://dbpedia.org/ontology/
506                default: true
507                queries:
508                  objectCompletionContextSensitive: SELECT ?qlue_ls_entity WHERE { ?s ?p ?qlue_ls_entity }
509        "#;
510
511        let settings: BackendsSettings = parse_yaml(yaml);
512
513        assert_eq!(settings.backends.len(), 2);
514        assert!(settings.backends.contains_key("wikidata"));
515        assert!(settings.backends.contains_key("dbpedia"));
516
517        let wikidata = settings.backends.get("wikidata").unwrap();
518        assert_eq!(wikidata.name, "Wikidata");
519        assert_eq!(wikidata.queries.len(), 1);
520
521        let dbpedia = settings.backends.get("dbpedia").unwrap();
522        assert_eq!(dbpedia.name, "DBpedia");
523        assert!(dbpedia.default);
524    }
525
526    #[test]
527    fn test_full_settings_deserialization() {
528        let yaml = r#"
529            format:
530              alignPredicates: true
531              alignPrefixes: false
532              separatePrologue: false
533              capitalizeKeywords: true
534              insertSpaces: true
535              tabSize: 2
536              whereNewLine: false
537              filterSameLine: true
538            completion:
539              timeoutMs: 5000
540              resultSizeLimit: 100
541            backends:
542              backends:
543                wikidata:
544                  name: Wikidata
545                  url: https://query.wikidata.org/sparql
546                  healthCheckUrl: https://query.wikidata.org/
547                  prefixMap:
548                    wd: http://www.wikidata.org/entity/
549                    wdt: http://www.wikidata.org/prop/direct/
550                  default: true
551                  queries:
552                    subjectCompletion: SELECT ?qlue_ls_entity WHERE { ?qlue_ls_entity ?p ?o }
553                    predicateCompletionContextSensitive: SELECT ?qlue_ls_entity WHERE { ?s ?qlue_ls_entity ?o }
554            prefixes:
555              addMissing: true
556              removeUnused: false
557        "#;
558
559        let settings: Settings = parse_yaml(yaml);
560
561        assert!(settings.format.align_predicates);
562        assert_eq!(settings.completion.timeout_ms, 5000);
563        assert!(settings.backends.is_some());
564
565        let backends = settings.backends.unwrap();
566        assert_eq!(backends.backends.len(), 1);
567
568        let wikidata = backends.backends.get("wikidata").unwrap();
569        assert_eq!(wikidata.name, "Wikidata");
570        assert!(wikidata.default);
571        assert_eq!(wikidata.queries.len(), 2);
572    }
573}