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