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