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