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    /// When enabled, preserves intentional blank lines from the original source.
193    /// Consecutive blank lines are collapsed into a single empty line.
194    /// Disabled by default to preserve current behavior.
195    pub keep_empty_lines: bool,
196}
197
198impl Default for FormatSettings {
199    fn default() -> Self {
200        Self {
201            align_predicates: true,
202            align_prefixes: false,
203            separate_prologue: false,
204            capitalize_keywords: true,
205            insert_spaces: Some(true),
206            tab_size: Some(2),
207            where_new_line: false,
208            filter_same_line: true,
209            compact: None,
210            line_length: 120,
211            contract_triples: false,
212            keep_empty_lines: false,
213        }
214    }
215}
216
217#[derive(Debug, Serialize, Deserialize, PartialEq)]
218#[serde(rename_all = "camelCase")]
219pub struct PrefixesSettings {
220    pub add_missing: Option<bool>,
221    pub remove_unused: Option<bool>,
222}
223
224impl Default for PrefixesSettings {
225    fn default() -> Self {
226        Self {
227            add_missing: Some(true),
228            remove_unused: Some(false),
229        }
230    }
231}
232#[derive(Debug, Serialize, Deserialize, PartialEq)]
233pub struct Replacement {
234    pub pattern: String,
235    pub replacement: String,
236}
237
238impl Replacement {
239    pub fn new(pattern: &str, replacement: &str) -> Self {
240        Self {
241            pattern: pattern.to_string(),
242            replacement: replacement.to_string(),
243        }
244    }
245}
246
247#[derive(Debug, Serialize, Deserialize, PartialEq)]
248#[serde(rename_all = "camelCase")]
249pub struct Replacements {
250    pub object_variable: Vec<Replacement>,
251}
252
253impl Default for Replacements {
254    fn default() -> Self {
255        Self {
256            object_variable: vec![
257                Replacement::new(r"^has (\w+)", "$1"),
258                Replacement::new(r"^has([A-Z]\w*)", "$1"),
259                Replacement::new(r"^(\w+)edBy", "$1"),
260                Replacement::new(r"([^a-zA-Z0-9_])", ""),
261            ],
262        }
263    }
264}
265
266#[derive(Debug, Deserialize, Serialize, PartialEq)]
267#[serde(rename_all = "camelCase")]
268pub struct Settings {
269    /// Format settings
270    #[serde(default)]
271    pub format: FormatSettings,
272    /// Completion Settings
273    #[serde(default)]
274    pub completion: CompletionSettings,
275    /// Backend configurations
276    pub backends: Option<BackendsSettings>,
277    /// Automatically add and remove prefix declarations
278    pub prefixes: Option<PrefixesSettings>,
279    /// Automatically add and remove prefix declarations
280    pub replacements: Option<Replacements>,
281    /// Automatically insert a line break after typing `;` or `.` following a valid triple.
282    #[serde(default)]
283    pub auto_line_break: bool,
284}
285
286impl Default for Settings {
287    fn default() -> Self {
288        Self {
289            format: FormatSettings::default(),
290            completion: CompletionSettings::default(),
291            backends: None,
292            prefixes: Some(PrefixesSettings::default()),
293            replacements: Some(Replacements::default()),
294            auto_line_break: false,
295        }
296    }
297}
298
299#[cfg(not(target_arch = "wasm32"))]
300fn load_user_configuration() -> Result<Settings, ConfigError> {
301    Config::builder()
302        .add_source(config::File::with_name("qlue-ls"))
303        .build()?
304        .try_deserialize::<Settings>()
305}
306
307impl Settings {
308    pub fn new() -> Self {
309        #[cfg(not(target_arch = "wasm32"))]
310        match load_user_configuration() {
311            Ok(settings) => {
312                log::info!("Loaded user configuration!!");
313                settings
314            }
315            Err(error) => {
316                log::info!(
317                    "Did not load user-configuration:\n{}\n falling back to default values",
318                    error
319                );
320                Settings::default()
321            }
322        }
323        #[cfg(target_arch = "wasm32")]
324        Settings::default()
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use config::{Config, FileFormat};
332
333    fn parse_yaml<T: serde::de::DeserializeOwned>(yaml: &str) -> T {
334        Config::builder()
335            .add_source(config::File::from_str(yaml, FileFormat::Yaml))
336            .build()
337            .unwrap()
338            .try_deserialize()
339            .unwrap()
340    }
341
342    #[test]
343    fn test_backend_configuration_valid_queries_all_variants() {
344        let yaml = r#"
345            name: TestBackend
346            url: https://example.com/sparql
347            healthCheckUrl: https://example.com/health
348            requestMethod: GET
349            prefixMap:
350              rdf: http://www.w3.org/1999/02/22-rdf-syntax-ns#
351              rdfs: http://www.w3.org/2000/01/rdf-schema#
352            default: false
353            queries:
354              subjectCompletion: SELECT ?qlue_ls_entity ?qlue_ls_label ?qlue_ls_detail WHERE { ?qlue_ls_entity a ?type }
355              predicateCompletionContextSensitive: SELECT ?qlue_ls_entity WHERE { ?s ?qlue_ls_entity ?o }
356              predicateCompletionContextInsensitive: SELECT ?qlue_ls_entity WHERE { [] ?qlue_ls_entity [] }
357              objectCompletionContextSensitive: SELECT ?qlue_ls_entity WHERE { ?s ?p ?qlue_ls_entity }
358              objectCompletionContextInsensitive: SELECT ?qlue_ls_entity WHERE { [] [] ?qlue_ls_entity }
359              valuesCompletionContextSensitive: SELECT ?qlue_ls_entity WHERE { ?qlue_ls_entity ?p ?o }
360              valuesCompletionContextInsensitive: SELECT ?qlue_ls_entity WHERE { ?qlue_ls_entity ?p ?o }
361        "#;
362
363        let config: BackendConfiguration = parse_yaml(yaml);
364
365        assert_eq!(config.name, "TestBackend");
366        assert_eq!(config.url, "https://example.com/sparql");
367        assert!(!config.default);
368        assert_eq!(config.queries.len(), 7);
369        assert!(
370            config
371                .queries
372                .contains_key(&CompletionTemplate::SubjectCompletion)
373        );
374        assert!(
375            config
376                .queries
377                .contains_key(&CompletionTemplate::PredicateCompletionContextSensitive)
378        );
379        assert!(
380            config
381                .queries
382                .contains_key(&CompletionTemplate::PredicateCompletionContextInsensitive)
383        );
384        assert!(
385            config
386                .queries
387                .contains_key(&CompletionTemplate::ObjectCompletionContextSensitive)
388        );
389        assert!(
390            config
391                .queries
392                .contains_key(&CompletionTemplate::ObjectCompletionContextInsensitive)
393        );
394        assert!(
395            config
396                .queries
397                .contains_key(&CompletionTemplate::ValuesCompletionContextSensitive)
398        );
399        assert!(
400            config
401                .queries
402                .contains_key(&CompletionTemplate::ValuesCompletionContextInsensitive)
403        );
404    }
405
406    #[test]
407    fn test_backend_configuration_queries_subset() {
408        let yaml = r#"
409            name: MinimalBackend
410            url: https://example.com/sparql
411            prefixMap: {}
412            queries:
413              subjectCompletion: SELECT ?qlue_ls_entity WHERE { ?qlue_ls_entity ?p ?o }
414              objectCompletionContextInsensitive: SELECT ?qlue_ls_entity WHERE { ?s ?p ?qlue_ls_entity }
415        "#;
416
417        let config: BackendConfiguration = parse_yaml(yaml);
418
419        assert_eq!(config.queries.len(), 2);
420        assert!(
421            config
422                .queries
423                .contains_key(&CompletionTemplate::SubjectCompletion)
424        );
425        assert!(
426            config
427                .queries
428                .contains_key(&CompletionTemplate::ObjectCompletionContextInsensitive)
429        );
430        assert!(
431            !config
432                .queries
433                .contains_key(&CompletionTemplate::PredicateCompletionContextSensitive)
434        );
435    }
436
437    #[test]
438    fn test_backend_configuration_rejects_invalid_query_key() {
439        // This test ensures that invalid query keys are rejected
440        let yaml = r#"
441            name: TestBackend
442            url: https://example.com/sparql
443            prefixMap: {}
444            queries:
445              invalidQueryType: SELECT ?qlue_ls_entity WHERE { ?s ?p ?o }
446              subjectCompletion: SELECT ?qlue_ls_entity WHERE { ?qlue_ls_entity ?p ?o }
447        "#;
448
449        let result = Config::builder()
450            .add_source(config::File::from_str(yaml, FileFormat::Yaml))
451            .build()
452            .unwrap()
453            .try_deserialize::<BackendConfiguration>();
454        assert!(result.is_err());
455    }
456
457    #[test]
458    fn test_backend_configuration_with_multiline_queries() {
459        let yaml = r#"
460            name: WikidataBackend
461            url: https://query.wikidata.org/sparql
462            healthCheckUrl: https://query.wikidata.org/
463            prefixMap:
464              wd: http://www.wikidata.org/entity/
465              wdt: http://www.wikidata.org/prop/direct/
466              rdfs: http://www.w3.org/2000/01/rdf-schema#
467            default: false
468            queries:
469              subjectCompletion: |
470                SELECT ?qlue_ls_entity ?qlue_ls_label ?qlue_ls_detail
471                WHERE {
472                  ?qlue_ls_entity rdfs:label ?qlue_ls_label .
473                  OPTIONAL { ?qlue_ls_entity schema:description ?qlue_ls_detail }
474                  FILTER(LANG(?qlue_ls_label) = "en")
475                }
476                LIMIT 100
477              predicateCompletionContextSensitive: |
478                SELECT ?qlue_ls_entity WHERE {
479                  ?s ?qlue_ls_entity ?o
480                }
481              objectCompletionContextInsensitive: SELECT ?qlue_ls_entity WHERE { [] [] ?qlue_ls_entity }
482        "#;
483
484        let config: BackendConfiguration = parse_yaml(yaml);
485
486        assert_eq!(config.name, "WikidataBackend");
487        assert_eq!(config.url, "https://query.wikidata.org/sparql");
488        assert!(!config.default);
489        assert_eq!(config.prefix_map.len(), 3);
490        assert_eq!(config.queries.len(), 3);
491
492        // Verify multiline query was parsed correctly
493        let subject_query = config
494            .queries
495            .get(&CompletionTemplate::SubjectCompletion)
496            .unwrap();
497        assert!(subject_query.contains("SELECT ?qlue_ls_entity ?qlue_ls_label ?qlue_ls_detail"));
498        assert!(subject_query.contains("FILTER(LANG(?qlue_ls_label) = \"en\")"));
499    }
500
501    #[test]
502    fn test_backends_settings_multiple_backends() {
503        let yaml = r#"
504            backends:
505              wikidata:
506                name: Wikidata
507                url: https://query.wikidata.org/sparql
508                prefixMap:
509                  wd: http://www.wikidata.org/entity/
510                queries:
511                  subjectCompletion: SELECT ?qlue_ls_entity WHERE { ?qlue_ls_entity ?p ?o }
512              dbpedia:
513                name: DBpedia
514                url: https://dbpedia.org/sparql
515                prefixMap:
516                  dbo: http://dbpedia.org/ontology/
517                default: true
518                queries:
519                  objectCompletionContextSensitive: SELECT ?qlue_ls_entity WHERE { ?s ?p ?qlue_ls_entity }
520        "#;
521
522        let settings: BackendsSettings = parse_yaml(yaml);
523
524        assert_eq!(settings.backends.len(), 2);
525        assert!(settings.backends.contains_key("wikidata"));
526        assert!(settings.backends.contains_key("dbpedia"));
527
528        let wikidata = settings.backends.get("wikidata").unwrap();
529        assert_eq!(wikidata.name, "Wikidata");
530        assert_eq!(wikidata.queries.len(), 1);
531
532        let dbpedia = settings.backends.get("dbpedia").unwrap();
533        assert_eq!(dbpedia.name, "DBpedia");
534        assert!(dbpedia.default);
535    }
536
537    #[test]
538    fn test_full_settings_deserialization() {
539        let yaml = r#"
540            format:
541              alignPredicates: true
542              alignPrefixes: false
543              separatePrologue: false
544              capitalizeKeywords: true
545              insertSpaces: true
546              tabSize: 2
547              whereNewLine: false
548              filterSameLine: true
549            completion:
550              timeoutMs: 5000
551              resultSizeLimit: 100
552            backends:
553              backends:
554                wikidata:
555                  name: Wikidata
556                  url: https://query.wikidata.org/sparql
557                  healthCheckUrl: https://query.wikidata.org/
558                  prefixMap:
559                    wd: http://www.wikidata.org/entity/
560                    wdt: http://www.wikidata.org/prop/direct/
561                  default: true
562                  queries:
563                    subjectCompletion: SELECT ?qlue_ls_entity WHERE { ?qlue_ls_entity ?p ?o }
564                    predicateCompletionContextSensitive: SELECT ?qlue_ls_entity WHERE { ?s ?qlue_ls_entity ?o }
565            prefixes:
566              addMissing: true
567              removeUnused: false
568        "#;
569
570        let settings: Settings = parse_yaml(yaml);
571
572        assert!(settings.format.align_predicates);
573        assert_eq!(settings.completion.timeout_ms, 5000);
574        assert!(settings.backends.is_some());
575
576        let backends = settings.backends.unwrap();
577        assert_eq!(backends.backends.len(), 1);
578
579        let wikidata = backends.backends.get("wikidata").unwrap();
580        assert_eq!(wikidata.name, "Wikidata");
581        assert!(wikidata.default);
582        assert_eq!(wikidata.queries.len(), 2);
583    }
584}