Skip to main content

swls_core/systems/
prefix_diagnostics.rs

1use std::collections::{HashMap, HashSet};
2
3use bevy_ecs::prelude::*;
4use rdf_parsers::{
5    model::{BlankNode, Literal, NamedNode, Term, Turtle},
6    Spanned,
7};
8use swls_lov::LocalPrefix;
9use tracing::instrument;
10
11use crate::{
12    feature::{code_action::CodeActionRequest, diagnostics::DiagnosticPublisher},
13    lsp_types::{
14        CodeAction, CodeActionKind, Diagnostic, DiagnosticSeverity, Position, Range, TextEdit,
15        WorkspaceEdit,
16    },
17    prelude::*,
18    systems::PrefixEntry,
19    util::offset_to_position,
20};
21
22// ─── Core helper (mirrors prefix_completion_helper) ───────────────────────────
23
24/// Analyse a document's parsed [`Turtle`] model against its declared prefixes and
25/// return (diagnostics, code_actions).
26///
27/// Walking the model (rather than the derived `Triples`) is what makes prefix
28/// usage detection correct for prefixes that appear only in a literal's datatype
29/// (`"5"^^xsd:integer`) — the triple form stores datatypes pre-expanded, dropping
30/// the prefix entirely.
31///
32/// The caller supplies `make_fix_edit(prefix_name, suggested_url) -> Vec<TextEdit>` —
33/// identical in spirit to the `extra_edits` callback in `prefix_completion_helper`.
34/// This callback is responsible for generating the language-specific text edit that
35/// adds a prefix declaration (typically via [`LangHelper::prefix_edits`]).  It may
36/// return an empty Vec if the language does not support auto-insertion.
37///
38/// LOV / prefix.cc iterators are used to suggest a URL for undefined prefixes.
39/// Because there are usually only a handful of undefined prefixes, we collect the
40/// undefined set first and then make a single pass over the LOV / prefix.cc data
41/// to resolve just those — rather than materialising the entire prefix universe
42/// into a lookup map.
43pub fn prefix_diagnostic_helper<'a>(
44    turtle: &Turtle,
45    prefixes: &Prefixes,
46    rope: &RopeC,
47    label: &Label,
48    lovs: impl Iterator<Item = &'a LocalPrefix>,
49    prefix_cc: impl Iterator<Item = &'a PrefixEntry>,
50    mut make_fix_edit: impl FnMut(&str, &str) -> Vec<TextEdit>,
51    report_undefined: bool,
52    report_unused: bool,
53) -> (Vec<Diagnostic>, Vec<CodeAction>) {
54    let declared: HashSet<&str> = prefixes.iter().map(|p| p.prefix.as_str()).collect();
55
56    // Collect every prefixed-name usage straight from the parsed model, as
57    // (prefix name, byte span of the occurrence).  This includes datatypes, which
58    // the derived triples cannot express.
59    let mut uses: Vec<(String, std::ops::Range<usize>)> = Vec::new();
60    // Resolved absolute IRIs of every named node.  Used to detect JSON-LD term
61    // aliases, which are used as bare terms (not `prefix:local`) and so resolve
62    // directly to their target IRI rather than showing up as a prefix usage.
63    let mut resolved: HashSet<String> = HashSet::new();
64    for triple in &turtle.triples {
65        let triple = triple.value();
66        collect_prefix_uses(&triple.subject, &mut uses, &mut resolved);
67        for po in &triple.po {
68            let po = po.value();
69            collect_prefix_uses(&po.predicate, &mut uses, &mut resolved);
70            for object in &po.object {
71                collect_prefix_uses(object, &mut uses, &mut resolved);
72            }
73        }
74    }
75
76    let mut used: HashSet<String> = HashSet::new();
77    // One entry per undefined-prefix *occurrence* (→ one diagnostic each), in
78    // document order. Deduped by (name, byte span): a subject that heads a
79    // predicate-object list (`:a :b :c; :d :e .`) yields one usage per object in
80    // the model and must only be flagged once.
81    let mut undefined_occurrences: Vec<(String, Range)> = Vec::new();
82    // Distinct undefined prefix names (→ one quick-fix each, + URL lookup).
83    let mut undefined_names: HashSet<String> = HashSet::new();
84    let mut seen_spans: HashSet<(String, usize, usize)> = HashSet::new();
85
86    for (prefix_name, span) in &uses {
87        used.insert(prefix_name.clone());
88
89        if declared.contains(prefix_name.as_str()) {
90            continue;
91        }
92        // Record each distinct (prefix, span) occurrence once.
93        if seen_spans.insert((prefix_name.clone(), span.start, span.end)) {
94            if let (Some(start), Some(end)) = (
95                offset_to_position(span.start, &rope.0),
96                offset_to_position(span.end, &rope.0),
97            ) {
98                undefined_occurrences.push((prefix_name.clone(), Range::new(start, end)));
99                undefined_names.insert(prefix_name.clone());
100            }
101        }
102    }
103
104    // Resolve suggested URLs for only the undefined prefixes in a single pass.
105    let mut url_lookup: HashMap<&str, String> = HashMap::new();
106    if !undefined_names.is_empty() {
107        for lp in lovs {
108            if undefined_names.contains(lp.name.as_ref()) {
109                url_lookup
110                    .entry(lp.name.as_ref())
111                    .or_insert_with(|| lp.namespace.to_string());
112            }
113        }
114        for pe in prefix_cc {
115            if undefined_names.contains(pe.name.as_ref()) {
116                url_lookup
117                    .entry(pe.name.as_ref())
118                    .or_insert_with(|| pe.namespace.to_string());
119            }
120        }
121    }
122
123    let mut diagnostics: Vec<Diagnostic> = Vec::new();
124    let mut code_actions: Vec<CodeAction> = Vec::new();
125
126    // ── ERROR: used but not declared ─────────────────────────────────────────
127    if report_undefined {
128        // One diagnostic per occurrence, so every offending token is underlined.
129        // Keep them grouped by prefix so the per-prefix quick-fix can reference
130        // every occurrence it resolves.
131        let mut diags_by_prefix: HashMap<&str, Vec<Diagnostic>> = HashMap::new();
132        for (prefix_name, range) in &undefined_occurrences {
133            let diag = Diagnostic {
134                range: range.clone(),
135                severity: Some(DiagnosticSeverity::ERROR),
136                message: format!("Undefined prefix \"{}\"", prefix_name),
137                ..Default::default()
138            };
139            diagnostics.push(diag.clone());
140            diags_by_prefix
141                .entry(prefix_name.as_str())
142                .or_default()
143                .push(diag);
144        }
145
146        // One quick-fix per distinct prefix (in document order): the fix inserts
147        // a single declaration at the top of the file, resolving *every*
148        // occurrence at once — so per-occurrence quick-fixes would be duplicates.
149        // The action links back to all of the prefix's diagnostics (via their
150        // ranges) so editors associate the fix with each underlined occurrence.
151        let mut emitted: HashSet<&str> = HashSet::new();
152        for (prefix_name, _) in &undefined_occurrences {
153            if !emitted.insert(prefix_name.as_str()) {
154                continue;
155            }
156            let suggested_url = url_lookup
157                .get(prefix_name.as_str())
158                .map(|s| s.as_str())
159                .unwrap_or("");
160
161            let fix_edits = make_fix_edit(prefix_name, suggested_url);
162            if fix_edits.is_empty() {
163                continue;
164            }
165
166            let mut changes = std::collections::HashMap::new();
167            changes.insert(label.0.clone(), fix_edits);
168            code_actions.push(CodeAction {
169                title: format!("Add prefix declaration for \"{}\"", prefix_name),
170                kind: Some(CodeActionKind::QUICKFIX),
171                diagnostics: diags_by_prefix.remove(prefix_name.as_str()),
172                edit: Some(WorkspaceEdit {
173                    changes: Some(changes),
174                    ..Default::default()
175                }),
176                ..Default::default()
177            });
178        }
179    }
180
181    // ── WARNING: declared but not used ────────────────────────────────────────
182    for prefix in report_unused.then(|| prefixes.iter()).into_iter().flatten() {
183        if used.contains(prefix.prefix.as_str()) {
184            continue;
185        }
186
187        // JSON-LD term aliases (`"name": "foaf:name"`) are used as bare terms, not
188        // as `prefix:local`; their target is a specific IRI rather than a namespace
189        // (it does not end in `/` or `#`).  Treat the alias as used when that target
190        // resolves somewhere in the document.
191        let target = prefix.url.as_str();
192        let is_alias = !target.ends_with('/') && !target.ends_with('#');
193        if is_alias && resolved.contains(target) {
194            continue;
195        }
196
197        let (start, end) = find_prefix_declaration_range(&rope.0, &prefix.prefix);
198        diagnostics.push(Diagnostic {
199            range: Range::new(start, end),
200            severity: Some(DiagnosticSeverity::WARNING),
201            message: format!("Prefix \"{}\" is declared but never used", prefix.prefix),
202            ..Default::default()
203        });
204    }
205
206    (diagnostics, code_actions)
207}
208
209/// Walk `term`, recursing into RDF collections and anonymous blank-node property
210/// lists (and a literal's datatype `"x"^^prefix:local`), collecting:
211/// - `uses`: every prefixed-name usage as `(prefix name, byte span)`, and
212/// - `resolved`: the resolved absolute IRI of every named node.
213fn collect_prefix_uses(
214    term: &Spanned<Term>,
215    uses: &mut Vec<(String, std::ops::Range<usize>)>,
216    resolved: &mut HashSet<String>,
217) {
218    match term.value() {
219        Term::NamedNode(nn) => record_named_node(nn, term.span(), uses, resolved),
220        Term::Literal(Literal::RDF(lit)) => {
221            // The datatype carries its own span (over the `^^<iri>` term), which is
222            // what makes a prefix that only appears in a datatype visible here.
223            if let Some(datatype) = &lit.ty {
224                record_named_node(datatype.value(), datatype.span(), uses, resolved);
225            }
226        }
227        Term::Collection(items) => {
228            for item in items {
229                collect_prefix_uses(item, uses, resolved);
230            }
231        }
232        Term::BlankNode(BlankNode::Unnamed(pos, _, _)) => {
233            for po in pos {
234                let po = po.value();
235                collect_prefix_uses(&po.predicate, uses, resolved);
236                for object in &po.object {
237                    collect_prefix_uses(object, uses, resolved);
238                }
239            }
240        }
241        _ => {}
242    }
243}
244
245/// Record a named node's prefix usage (at `span`) and its resolved absolute IRI.
246fn record_named_node(
247    nn: &NamedNode,
248    span: &std::ops::Range<usize>,
249    uses: &mut Vec<(String, std::ops::Range<usize>)>,
250    resolved: &mut HashSet<String>,
251) {
252    match nn {
253        NamedNode::Prefixed {
254            prefix, computed, ..
255        } => {
256            // JSON-LD stores a registered whole-term compact IRI as
257            // `Prefixed(term, "")` where `prefix` is the whole term including the
258            // ':'.  A real prefix name never contains ':', so that identifies the
259            // whole-term case — it is not a `prefix:local` split and so not a
260            // prefix usage.
261            if !prefix.contains(':') {
262                uses.push((prefix.clone(), span.clone()));
263            }
264            if let Some(computed) = computed {
265                resolved.insert(computed.clone());
266            }
267        }
268        NamedNode::Full(iri, _) => {
269            resolved.insert(iri.clone());
270        }
271        _ => {}
272    }
273}
274
275/// Scan the rope for the declaration line of `prefix_name`.
276///
277/// Handles three syntaxes:
278/// - Turtle/TriG: `@prefix foaf: <…>.`
279/// - SPARQL:       `PREFIX foaf: <…>`
280/// - JSON-LD:      `"foaf": "http://…"` (inside `@context`)
281fn find_prefix_declaration_range(rope: &LineIndex, prefix_name: &str) -> (Position, Position) {
282    // Patterns that identify a declaration for this prefix, paired with the byte
283    // offset (within the match) at which the highlighted key/name begins.
284    let turtle_needle = format!("@prefix {}:", prefix_name);
285    let sparql_needle = format!("PREFIX {}:", prefix_name);
286    // JSON-LD: `"foaf":` — a context key. May appear mid-line (even on a
287    // single-line document), so we search anywhere on the line, not just at the
288    // start.
289    let jsonld_needle = format!("\"{}\":", prefix_name);
290
291    // (needle, offset of key start within needle, key length)
292    let patterns = [
293        (&turtle_needle, "@prefix ".len(), prefix_name.len()),
294        (&sparql_needle, "PREFIX ".len(), prefix_name.len()),
295        // Highlight the quoted key, including the surrounding quotes.
296        (&jsonld_needle, 0, prefix_name.len() + 2),
297    ];
298
299    let candidate = (0..rope.len_lines()).find_map(|line_idx| {
300        let line = rope.line_str(line_idx)?;
301        let line_start = rope.line_start(line_idx)?;
302
303        for (needle, key_off, key_len) in patterns.iter() {
304            if let Some(idx) = line.find(needle.as_str()) {
305                // Byte offsets within the whole document; `offset_to_position`
306                // converts them (and the encoding) for us.
307                let key_byte = line_start + idx + key_off;
308                let start = offset_to_position(key_byte, rope)?;
309                let end = offset_to_position(key_byte + key_len, rope)?;
310                return Some((start, end));
311            }
312        }
313        None
314    });
315
316    candidate.unwrap_or((Position::new(0, 0), Position::new(0, 0)))
317}
318
319// ─── ECS systems ─────────────────────────────────────────────────────────────
320
321/// ECS system: runs `prefix_diagnostic_helper` for every open document whose
322/// triples or declared prefixes changed.
323///
324/// Skips languages that opt out via [`LangHelper::supports_prefix_diagnostics`]
325/// (e.g. JSON-LD, which pre-expands all terms before storing them as triples).
326#[instrument(skip(query, client, lovs, prefix_cc, config))]
327pub fn prefix_diagnostics(
328    query: Query<
329        (
330            &Element,
331            &Prefixes,
332            &Source,
333            &RopeC,
334            &Label,
335            &Wrapped<crate::lsp_types::TextDocumentItem>,
336            &DynLang,
337        ),
338        (Or<(Changed<Element>, Changed<Prefixes>)>, With<Open>),
339    >,
340    mut client: ResMut<DiagnosticPublisher>,
341    lovs: Query<&LocalPrefix>,
342    prefix_cc: Query<&PrefixEntry>,
343    config: Res<ServerConfig>,
344) {
345    let fmt = config.config.local.prefix_format.unwrap_or_default();
346    let report_undefined = !config.config.local.is_disabled(Disabled::UndefinedPrefix);
347    let report_unused = !config.config.local.is_disabled(Disabled::UnusedPrefix);
348    for (element, prefixes, source, rope, label, params, lang) in &query {
349        if !lang.0.supports_prefix_diagnostics() {
350            // Clear any stale prefix diagnostics for this language and skip.
351            let _ = client.publish(&params.0, vec![], "prefix");
352            continue;
353        }
354
355        let (diagnostics, _) = prefix_diagnostic_helper(
356            element.value(),
357            prefixes,
358            rope,
359            label,
360            lovs.iter(),
361            prefix_cc.iter(),
362            |name, url| {
363                lang.0
364                    .prefix_edits(&source.0, &rope.0, name, url, fmt)
365                    .unwrap_or_default()
366            },
367            report_undefined,
368            report_unused,
369        );
370
371        let _ = client.publish(&params.0, diagnostics, "prefix");
372    }
373}
374
375/// ECS system: runs `prefix_diagnostic_helper` for every open document to populate
376/// `CodeActionRequest` with "Add prefix declaration" quickfixes.
377///
378/// Skips languages that opt out via [`LangHelper::supports_prefix_diagnostics`].
379#[instrument(skip(query, lovs, prefix_cc, config))]
380pub fn add_missing_prefix_code_action(
381    mut query: Query<
382        (
383            &Element,
384            &Prefixes,
385            &Source,
386            &RopeC,
387            &Label,
388            &DynLang,
389            &mut CodeActionRequest,
390        ),
391        With<Open>,
392    >,
393    lovs: Query<&LocalPrefix>,
394    prefix_cc: Query<&PrefixEntry>,
395    config: Res<ServerConfig>,
396) {
397    let fmt = config.config.local.prefix_format.unwrap_or_default();
398    if config.config.local.is_disabled(Disabled::UndefinedPrefix) {
399        return;
400    }
401    for (element, prefixes, source, rope, label, lang, mut req) in &mut query {
402        if !lang.0.supports_prefix_diagnostics() {
403            continue;
404        }
405
406        let (_, actions) = prefix_diagnostic_helper(
407            element.value(),
408            prefixes,
409            rope,
410            label,
411            lovs.iter(),
412            prefix_cc.iter(),
413            |name, url| {
414                lang.0
415                    .prefix_edits(&source.0, &rope.0, name, url, fmt)
416                    .unwrap_or_default()
417            },
418            true,
419            false,
420        );
421
422        req.0.extend(actions);
423    }
424}