Skip to main content

swls_lang_rdf_base/
code_actions.rs

1//! Shared, language-agnostic code actions for the text RDF syntaxes
2//! (Turtle / TriG / SPARQL / N3 — anything whose parsed [`Element`] is the
3//! [`Turtle`] model).
4//!
5//! These systems are generic over the language marker `L` and are opted into by
6//! each language via [`setup_blank_node_code_action`].  JSON-LD deliberately does
7//! **not** register them, because its concrete syntax is JSON rather than the
8//! `[ … ]` / `_:bN` blank-node forms these actions produce.
9
10use std::collections::HashMap;
11use std::ops::Range as StdRange;
12
13use bevy_ecs::prelude::*;
14use rdf_parsers::model::{BlankNode, Term, Turtle, PO};
15use swls_core::{
16    feature::code_action::CodeActionRequest,
17    lsp_types::{CodeAction, CodeActionKind, Range, TextEdit, WorkspaceEdit},
18    prelude::*,
19    util::{offset_to_position, offsets_to_range, position_to_offset},
20};
21
22/// Recursively search a term for the innermost *unnamed* blank node whose span
23/// contains `offset`.  Records `(span, has_content)` of the best (deepest) match.
24fn find_unnamed_in_term(
25    term: &Spanned<Term>,
26    offset: usize,
27    best: &mut Option<(StdRange<usize>, bool)>,
28) {
29    let span = term.span();
30    if offset < span.start || offset > span.end {
31        return;
32    }
33    match term.value() {
34        Term::BlankNode(BlankNode::Unnamed(pos, _, _)) => {
35            // Deeper (nested) matches overwrite shallower ones → innermost wins.
36            *best = Some((span.clone(), !pos.is_empty()));
37            for po in pos {
38                find_unnamed_in_po(po, offset, best);
39            }
40        }
41        Term::Collection(items) => {
42            for item in items {
43                find_unnamed_in_term(item, offset, best);
44            }
45        }
46        _ => {}
47    }
48}
49
50fn find_unnamed_in_po(po: &Spanned<PO>, offset: usize, best: &mut Option<(StdRange<usize>, bool)>) {
51    let po = po.value();
52    find_unnamed_in_term(&po.predicate, offset, best);
53    for object in &po.object {
54        find_unnamed_in_term(object, offset, best);
55    }
56}
57
58/// Pick a fresh `_:bN` blank-node label that does not already occur in `source`.
59fn fresh_blank_label(source: &str) -> String {
60    let mut n = 0;
61    loop {
62        let candidate = format!("_:b{}", n);
63        if !source.contains(&candidate) {
64            return candidate;
65        }
66        n += 1;
67    }
68}
69
70/// Code action: when the cursor sits inside an anonymous blank node `[ … ]`,
71/// offer to extract it into a labelled blank node (`_:bN`) declared as a separate
72/// statement.  For example:
73///
74/// ```turtle
75/// <#s> foaf:knows [ foaf:name "Alice" ] .
76/// ```
77///
78/// becomes
79///
80/// ```turtle
81/// <#s> foaf:knows _:b0 .
82/// _:b0 foaf:name "Alice" .
83/// ```
84///
85/// This is generic over the language marker `L`; every text RDF syntax whose
86/// parsed model is [`Turtle`] (Turtle, TriG, SPARQL, N3, …) can reuse it.
87pub fn extract_blank_node(
88    mut query: Query<(
89        &Element,
90        &Source,
91        &RopeC,
92        &Label,
93        &PositionComponent,
94        &DynLang,
95        &mut CodeActionRequest,
96    )>,
97    config: Res<ServerConfig>,
98) {
99    if config.config.local.is_disabled(Disabled::CodeActionBlankNodeRefactor) {
100        return;
101    }
102    for (element, source, rope, label, position, lang, mut req) in &mut query {
103        if !lang.0.blank_node_code_actions() {
104            continue;
105        }
106        let Some(offset) = position_to_offset(position.0, &rope.0) else {
107            continue;
108        };
109        let turtle: &Turtle = element.value();
110
111        let mut best: Option<(StdRange<usize>, bool)> = None;
112        for triple in &turtle.triples {
113            let triple = triple.value();
114            find_unnamed_in_term(&triple.subject, offset, &mut best);
115            for po in &triple.po {
116                find_unnamed_in_po(po, offset, &mut best);
117            }
118        }
119
120        let Some((bspan, has_content)) = best else {
121            continue;
122        };
123        // Extracting an empty `[]` would yield an invalid `_:bN .` statement.
124        if !has_content {
125            continue;
126        }
127
128        // Pull the predicate-object content out of the `[ … ]` source slice.
129        let Some(raw) = source.0.get(bspan.start..bspan.end) else {
130            continue;
131        };
132        let inner = raw.trim();
133        let inner = inner.strip_prefix('[').unwrap_or(inner);
134        let inner = inner.strip_suffix(']').unwrap_or(inner);
135        let inner = inner.trim();
136        if inner.is_empty() {
137            continue;
138        }
139
140        let bnode = fresh_blank_label(&source.0);
141
142        // Edit 1: replace the inline blank node with the new label.
143        let Some(replace_range) = offsets_to_range(bspan.start, bspan.end, &rope.0) else {
144            continue;
145        };
146        let replace_edit = TextEdit {
147            range: replace_range,
148            new_text: bnode.clone(),
149        };
150
151        // Edit 2: append the extracted statement at the end of the document.
152        let end_offset = source.0.len();
153        let Some(end_pos) = offset_to_position(end_offset, &rope.0) else {
154            continue;
155        };
156        let needs_leading_newline = !source.0.ends_with('\n');
157        let append_edit = TextEdit {
158            range: Range::new(end_pos, end_pos),
159            new_text: format!(
160                "{}{} {} .\n",
161                if needs_leading_newline { "\n" } else { "" },
162                bnode,
163                inner
164            ),
165        };
166
167        let mut changes = HashMap::new();
168        changes.insert(label.0.clone(), vec![replace_edit, append_edit]);
169
170        req.0.push(CodeAction {
171            title: String::from("Extract blank node into named blank node"),
172            kind: Some(CodeActionKind::REFACTOR_EXTRACT),
173            edit: Some(WorkspaceEdit {
174                changes: Some(changes),
175                ..Default::default()
176            }),
177            ..Default::default()
178        });
179    }
180}
181
182/// Find the innermost `BlankNode::Named` term whose span contains `offset`,
183/// returning its label and span.  Searches subjects, predicates and objects
184/// recursively (into nested `[ … ]` and collections).
185fn find_named_at(turtle: &Turtle, offset: usize) -> Option<(String, StdRange<usize>)> {
186    fn walk(
187        term: &Spanned<Term>,
188        offset: usize,
189        best: &mut Option<(String, StdRange<usize>)>,
190    ) {
191        let span = term.span();
192        if offset < span.start || offset > span.end {
193            return;
194        }
195        match term.value() {
196            Term::BlankNode(BlankNode::Named(name, _)) => {
197                *best = Some((name.clone(), span.clone()));
198            }
199            Term::BlankNode(BlankNode::Unnamed(pos, _, _)) => {
200                for po in pos {
201                    walk(&po.value().predicate, offset, best);
202                    for object in &po.value().object {
203                        walk(object, offset, best);
204                    }
205                }
206            }
207            Term::Collection(items) => {
208                for item in items {
209                    walk(item, offset, best);
210                }
211            }
212            _ => {}
213        }
214    }
215
216    let mut best = None;
217    for triple in &turtle.triples {
218        let triple = triple.value();
219        walk(&triple.subject, offset, &mut best);
220        for po in &triple.po {
221            walk(&po.value().predicate, offset, &mut best);
222            for object in &po.value().object {
223                walk(object, offset, &mut best);
224            }
225        }
226    }
227    best
228}
229
230/// Collect every reference (span) to the named blank node `name` reachable from
231/// `term`, recursing into nested blank nodes and collections.
232fn collect_named_refs(term: &Spanned<Term>, name: &str, out: &mut Vec<StdRange<usize>>) {
233    match term.value() {
234        Term::BlankNode(BlankNode::Named(n, _)) if n == name => out.push(term.span().clone()),
235        Term::BlankNode(BlankNode::Unnamed(pos, _, _)) => {
236            for po in pos {
237                collect_named_refs(&po.value().predicate, name, out);
238                for object in &po.value().object {
239                    collect_named_refs(object, name, out);
240                }
241            }
242        }
243        Term::Collection(items) => {
244            for item in items {
245                collect_named_refs(item, name, out);
246            }
247        }
248        _ => {}
249    }
250}
251
252/// Code action: the inverse of [`extract_blank_node`].  When the cursor sits on a
253/// labelled blank node `_:bN` that is *defined* by exactly one statement and
254/// *referenced* in exactly one other place, inline the definition back into an
255/// anonymous blank node `[ … ]`.  For example:
256///
257/// ```turtle
258/// <#s> foaf:knows _:b0 .
259/// _:b0 foaf:name "Alice" .
260/// ```
261///
262/// becomes
263///
264/// ```turtle
265/// <#s> foaf:knows [ foaf:name "Alice" ] .
266/// ```
267///
268/// Generic over the language marker `L`, mirroring [`extract_blank_node`].
269pub fn inline_blank_node(
270    mut query: Query<(
271        &Element,
272        &Source,
273        &RopeC,
274        &Label,
275        &PositionComponent,
276        &DynLang,
277        &mut CodeActionRequest,
278    )>,
279    config: Res<ServerConfig>,
280) {
281    if config.config.local.is_disabled(Disabled::CodeActionBlankNodeRefactor) {
282        return;
283    }
284    for (element, source, rope, label, position, lang, mut req) in &mut query {
285        if !lang.0.blank_node_code_actions() {
286            continue;
287        }
288        let Some(offset) = position_to_offset(position.0, &rope.0) else {
289            continue;
290        };
291        let turtle: &Turtle = element.value();
292
293        let Some((name, _)) = find_named_at(turtle, offset) else {
294            continue;
295        };
296
297        // Locate the single defining statement (`_:name <po> .`) and gather every
298        // reference to `_:name` everywhere else.
299        let mut def: Option<(usize, StdRange<usize>)> = None;
300        let mut refs: Vec<StdRange<usize>> = Vec::new();
301        for (idx, triple) in turtle.triples.iter().enumerate() {
302            let tvalue = triple.value();
303            let is_def = matches!(
304                tvalue.subject.value(),
305                Term::BlankNode(BlankNode::Named(n, _)) if *n == name
306            );
307            if is_def && def.is_none() {
308                def = Some((idx, tvalue.subject.span().clone()));
309            } else {
310                collect_named_refs(&tvalue.subject, &name, &mut refs);
311            }
312            // Always scan predicate/object positions for references (including the
313            // definition's own objects, so a self-reference disqualifies inlining).
314            for po in &tvalue.po {
315                collect_named_refs(&po.value().predicate, &name, &mut refs);
316                for object in &po.value().object {
317                    collect_named_refs(object, &name, &mut refs);
318                }
319            }
320        }
321
322        // Inlining only makes sense with exactly one definition and one reference.
323        let Some((def_idx, _)) = def else {
324            continue;
325        };
326        if refs.len() != 1 {
327            continue;
328        }
329        let ref_span = refs.into_iter().next().unwrap();
330
331        // Extract the predicate-object source of the definition (everything after
332        // the subject up to, but excluding, the closing `.`).
333        let def_triple = turtle.triples[def_idx].value();
334        let def_span = turtle.triples[def_idx].span();
335        let inner_start = def_triple.subject.span().end;
336        let Some(inner_raw) = source.0.get(inner_start..def_span.end) else {
337            continue;
338        };
339        let inner = inner_raw.trim();
340        let inner = inner.strip_suffix('.').unwrap_or(inner).trim();
341        if inner.is_empty() {
342            continue;
343        }
344
345        // Compute the deletion range for the whole definition statement, consuming
346        // surrounding line whitespace/newline for a clean removal.
347        let mut del_start = def_span.start;
348        let mut del_end = def_span.end;
349        let bytes = source.0.as_bytes();
350        while del_end < bytes.len() && matches!(bytes[del_end], b' ' | b'\t') {
351            del_end += 1;
352        }
353        if del_end < bytes.len() && bytes[del_end] == b'\r' {
354            del_end += 1;
355        }
356        if del_end < bytes.len() && bytes[del_end] == b'\n' {
357            del_end += 1;
358        }
359        while del_start > 0 && matches!(bytes[del_start - 1], b' ' | b'\t') {
360            del_start -= 1;
361        }
362
363        // Guard against overlapping edits (e.g. definition and reference share a
364        // line); inlining would corrupt the document, so skip the action.
365        if del_start < ref_span.end && ref_span.start < del_end {
366            continue;
367        }
368
369        let Some(ref_range) = offsets_to_range(ref_span.start, ref_span.end, &rope.0) else {
370            continue;
371        };
372        let Some(del_range) = offsets_to_range(del_start, del_end, &rope.0) else {
373            continue;
374        };
375
376        let replace_edit = TextEdit {
377            range: ref_range,
378            new_text: format!("[ {} ]", inner),
379        };
380        let delete_edit = TextEdit {
381            range: del_range,
382            new_text: String::new(),
383        };
384
385        let mut changes = HashMap::new();
386        changes.insert(label.0.clone(), vec![replace_edit, delete_edit]);
387
388        req.0.push(CodeAction {
389            title: String::from("Inline named blank node"),
390            kind: Some(CodeActionKind::REFACTOR_INLINE),
391            edit: Some(WorkspaceEdit {
392                changes: Some(changes),
393                ..Default::default()
394            }),
395            ..Default::default()
396        });
397    }
398}
399
400// Registered once (non-generic) by `register_rdf_lang`; each invocation is gated
401// at runtime by `LangHelper::blank_node_code_actions`, so JSON-LD (JSON blank
402// nodes) is skipped while the Turtle-family syntaxes opt in.