Skip to main content

swls_core/systems/
namespace_properties.rs

1//! Validation: warn about IRIs used as properties (predicates) that live in a
2//! user-configured "closed" namespace but are not defined in any known ontology.
3//!
4//! This lets a user assert "every predicate under `http://example.org/ns#` must be
5//! a real, declared property" and get a warning when they typo or use an undefined
6//! term.  Individual IRIs can be allow-listed via [`ServerConfig`]'s
7//! `allowed_properties`, which is what the accompanying quick-fix / `executeCommand`
8//! handler mutates.
9
10use bevy_ecs::prelude::*;
11use sophia_api::{quad::Quad, term::TermKind};
12
13use crate::{
14    feature::{code_action::CodeActionRequest, diagnostics::DiagnosticPublisher},
15    lsp_types::{
16        CodeAction, CodeActionKind, Command, Diagnostic, DiagnosticSeverity, Range,
17        TextDocumentItem,
18    },
19    prelude::*,
20    util::offset_to_position,
21};
22
23/// `executeCommand` identifier that adds an IRI to the user's `allowed_properties`.
24pub const ALLOW_PROPERTY_COMMAND: &str = "swls.allowProperty";
25
26/// Walk the document's triples and return `(iri, range)` for every predicate IRI
27/// that starts with a configured closed namespace, is not a known ontology
28/// property, and is not already allow-listed by the user.
29fn unknown_namespace_properties(
30    triples: &Triples,
31    rope: &RopeC,
32    ontologies: &Ontologies,
33    closed_namespaces: &std::collections::HashSet<String>,
34    allowed_properties: &std::collections::HashSet<String>,
35    disabled: bool,
36) -> Vec<(String, Range)> {
37    if closed_namespaces.is_empty() || disabled {
38        return Vec::new();
39    }
40
41    let mut out = Vec::new();
42    for quad in triples.0.iter() {
43        for term in &[quad.s(), quad.p(), quad.o()] {
44            if term.ty != Some(TermKind::Iri) {
45                continue;
46            }
47            let iri = term.as_str();
48            if !closed_namespaces.iter().any(|ns| iri.starts_with(ns)) {
49                continue;
50            }
51            if allowed_properties.contains(iri)
52                || ontologies.properties.contains_key(iri)
53                || ontologies.classes.values().any(|c| c.term.value == iri)
54            {
55                continue;
56            }
57
58            let span = &term.span;
59            if span.is_empty() {
60                continue;
61            }
62            if let (Some(start), Some(end)) = (
63                offset_to_position(span.start, &rope.0),
64                offset_to_position(span.end, &rope.0),
65            ) {
66                out.push((iri.to_string(), Range::new(start, end)));
67            }
68        }
69    }
70    out
71}
72
73/// ECS system (ParseLabel): publish warnings for predicates in closed namespaces
74/// that are not defined in any known ontology.
75pub fn validate_namespace_properties(
76    query: Query<(&Triples, &RopeC, &Wrapped<TextDocumentItem>), With<Open>>,
77    ontologies: Res<Ontologies>,
78    config: Res<ServerConfig>,
79    mut client: ResMut<DiagnosticPublisher>,
80) {
81    let closed = &config.config.local.closed_namespaces;
82    let allowed = &config.config.local.allowed_properties;
83    let disabled = config.config.local.is_disabled(Disabled::NamespaceProperties);
84
85    for (triples, rope, item) in &query {
86        let violations =
87            unknown_namespace_properties(triples, rope, &ontologies, closed, allowed, disabled);
88
89        let diagnostics: Vec<Diagnostic> = violations
90            .into_iter()
91            .map(|(iri, range)| Diagnostic {
92                range,
93                severity: Some(DiagnosticSeverity::WARNING),
94                message: format!(
95                    "Property \"{}\" is not defined in the ontology for its namespace",
96                    iri
97                ),
98                ..Default::default()
99            })
100            .collect();
101
102        let _ = client.publish(&item.0, diagnostics, "namespace_properties");
103    }
104}
105
106/// ECS system (CodeActionLabel): offer a quick-fix that allow-lists an unknown
107/// property IRI.  The action carries a [`Command`] so the editor round-trips back
108/// through `workspace/executeCommand`, letting the server persist the choice to
109/// the global config and clear the warning at runtime.
110///
111/// Only the unknown property *under the cursor* is offered, so the quick-fix does
112/// not show up when the cursor is elsewhere in the document.
113pub fn unknown_property_code_action(
114    mut query: Query<(&Triples, &RopeC, &PositionComponent, &mut CodeActionRequest), With<Open>>,
115    ontologies: Res<Ontologies>,
116    config: Res<ServerConfig>,
117) {
118    let closed = &config.config.local.closed_namespaces;
119    let allowed = &config.config.local.allowed_properties;
120    let disabled = config.config.local.is_disabled(Disabled::NamespaceProperties);
121
122    for (triples, rope, position, mut req) in &mut query {
123        let cursor = position.0;
124        let mut seen = std::collections::HashSet::new();
125        for (iri, range) in
126            unknown_namespace_properties(triples, rope, &ontologies, closed, allowed, disabled)
127        {
128            if !position_in_range(cursor, range) {
129                continue;
130            }
131            if !seen.insert(iri.clone()) {
132                continue;
133            }
134            req.0.push(CodeAction {
135                title: format!("Mark \"{}\" as a known property", iri),
136                kind: Some(CodeActionKind::QUICKFIX),
137                command: Some(Command {
138                    title: "Allow property".to_string(),
139                    command: ALLOW_PROPERTY_COMMAND.to_string(),
140                    arguments: Some(vec![serde_json::Value::String(iri)]),
141                }),
142                ..Default::default()
143            });
144        }
145    }
146}
147
148/// Whether `pos` falls within `range` (inclusive of both ends).
149fn position_in_range(pos: crate::lsp_types::Position, range: Range) -> bool {
150    pos >= range.start && pos <= range.end
151}