Skip to main content

swls_lang_jsonld/ecs/
completion.rs

1use std::collections::{HashMap, HashSet};
2
3use bevy_ecs::prelude::*;
4use completion::{CompletionRequest, SimpleCompletion};
5use swls_core::{
6    lsp_types::{CompletionItemKind, TextEdit},
7    prelude::*,
8    systems::{prefix::prefix_completion_helper, PrefixEntry},
9};
10use swls_lov::LocalPrefix;
11use tracing::instrument;
12
13use crate::{ecs::JsonLdActiveContext, JsonLdLang, Registry};
14
15/// Strip the leading and trailing JSON string quotes from a token's text.
16/// Returns the unquoted content, or the original text if it is not a quoted string.
17fn unquote(text: &str) -> &str {
18    let s = text.strip_prefix('"').unwrap_or(text);
19    s.strip_suffix('"').unwrap_or(s)
20}
21
22// ── context-editing helper ────────────────────────────────────────────────────
23
24/// What to add to a JSON-LD `@context`.
25pub enum ContextEntry<'a> {
26    /// A prefix declaration: `"name": "namespace"` inside the context object.
27    Prefix { name: &'a str, namespace: &'a str },
28    /// A remote context URL added as a plain string.
29    Url(&'a str),
30}
31
32/// Given that `bytes[start]` is the opening character of a JSON value, return
33/// the index one past the closing character of that value.
34fn find_value_end(bytes: &[u8], start: usize) -> Option<usize> {
35    match bytes.get(start)? {
36        b'"' => {
37            let mut i = start + 1;
38            while i < bytes.len() {
39                match bytes[i] {
40                    b'\\' => i += 2,
41                    b'"' => return Some(i + 1),
42                    _ => i += 1,
43                }
44            }
45            None
46        }
47        b'{' | b'[' => {
48            let (open, close) = if bytes[start] == b'{' {
49                (b'{', b'}')
50            } else {
51                (b'[', b']')
52            };
53            let mut depth = 0usize;
54            let mut in_str = false;
55            let mut i = start;
56            while i < bytes.len() {
57                if in_str {
58                    match bytes[i] {
59                        b'\\' => i += 1,
60                        b'"' => in_str = false,
61                        _ => {}
62                    }
63                } else {
64                    match bytes[i] {
65                        b'"' => in_str = true,
66                        c if c == open => depth += 1,
67                        c if c == close => {
68                            depth -= 1;
69                            if depth == 0 {
70                                return Some(i + 1);
71                            }
72                        }
73                        _ => {}
74                    }
75                }
76                i += 1;
77            }
78            None
79        }
80        _ => None,
81    }
82}
83
84/// Locate the byte span of the `@context` value inside `source`.
85fn find_context_value_span(source: &str) -> Option<std::ops::Range<usize>> {
86    let key = "\"@context\"";
87    let key_pos = source.find(key)?;
88    let after_key = key_pos + key.len();
89
90    let colon_offset = source[after_key..].find(':')?;
91    let after_colon = after_key + colon_offset + 1;
92
93    let ws_offset = source[after_colon..].find(|c: char| !c.is_whitespace())?;
94    let value_start = after_colon + ws_offset;
95
96    let value_end = find_value_end(source.as_bytes(), value_start)?;
97    Some(value_start..value_end)
98}
99
100/// Produce the new `@context` value string with `entry` added, or `None` if
101/// the entry is already present or the value shape is not supported.
102fn build_new_context(current: &str, entry: &ContextEntry<'_>) -> Option<String> {
103    match current.trim_start().as_bytes().first()? {
104        b'{' => match entry {
105            ContextEntry::Prefix { name, namespace } => {
106                let close = current.rfind('}')?;
107                let inner = current[1..close].trim();
108                if inner.is_empty() {
109                    Some(format!("{{\"{}\":\"{}\"}}", name, namespace))
110                } else {
111                    Some(format!(
112                        "{}, \"{}\":\"{}\"}}",
113                        &current[..close],
114                        name,
115                        namespace
116                    ))
117                }
118            }
119            ContextEntry::Url(url) => {
120                // A URL cannot go inside an object — convert to array.
121                Some(format!("[{}, \"{}\"]", current, url))
122            }
123        },
124        b'"' => match entry {
125            ContextEntry::Prefix { name, namespace } => {
126                Some(format!("[{}, {{\"{}\":\"{}\"}}]", current, name, namespace))
127            }
128            ContextEntry::Url(url) => Some(format!("[{}, \"{}\"]", current, url)),
129        },
130        b'[' => {
131            let close = current.rfind(']')?;
132            let inner = current[1..close].trim();
133            let new_entry = match entry {
134                ContextEntry::Prefix { name, namespace } => {
135                    format!("{{\"{}\":\"{}\"}}", name, namespace)
136                }
137                ContextEntry::Url(url) => format!("\"{}\"", url),
138            };
139            if inner.is_empty() {
140                Some(format!("[{}]", new_entry))
141            } else {
142                Some(format!("{}, {}]", &current[..close], new_entry))
143            }
144        }
145        _ => None,
146    }
147}
148
149/// Find the byte position just after the first `{` of the top-level JSON object,
150/// skipping leading whitespace. Returns `None` if no `{` is found.
151fn find_toplevel_object_open(source: &str) -> Option<usize> {
152    let offset = source.find('{')?;
153    Some(offset + 1)
154}
155
156/// Build the new-context value string for a fresh `@context` insertion.
157fn new_context_value(entry: &ContextEntry<'_>) -> String {
158    match entry {
159        ContextEntry::Prefix { name, namespace } => {
160            format!("{{\"{}\":\"{}\"}}", name, namespace)
161        }
162        ContextEntry::Url(url) => format!("\"{}\"", url),
163    }
164}
165
166/// Returns a `TextEdit` that inserts `entry` into the `@context` of the JSON-LD
167/// document described by `source` / `rope`. Returns `None` when:
168/// - the entry (prefix key or URL string) is already present, or
169/// - the existing value shape cannot be extended.
170///
171/// When no `@context` key exists yet, a new one is inserted right after the
172/// opening `{` of the top-level JSON object.
173pub fn add_to_context(
174    source: &str,
175    rope: &LineIndex,
176    entry: ContextEntry<'_>,
177) -> Option<Vec<TextEdit>> {
178    match find_context_value_span(source) {
179        Some(span) => {
180            let current = &source[span.clone()];
181
182            // Check whether the entry is already present.
183            match &entry {
184                ContextEntry::Prefix { name, .. } => {
185                    if current.contains(&format!("\"{}\"", name)) {
186                        return None;
187                    }
188                }
189                ContextEntry::Url(url) => {
190                    if current.contains(&format!("\"{}\"", url)) {
191                        return None;
192                    }
193                }
194            }
195
196            let new_text = build_new_context(current, &entry)?;
197            let range = range_to_range(&span, rope)?;
198            Some(vec![TextEdit { range, new_text }])
199        }
200        None => {
201            // No @context present — insert one after the opening `{`.
202            let insert_pos = find_toplevel_object_open(source)?;
203            let context_value = new_context_value(&entry);
204            let new_text = format!("\"@context\": {}, ", context_value);
205            let range = range_to_range(&(insert_pos..insert_pos), rope)?;
206            Some(vec![TextEdit { range, new_text }])
207        }
208    }
209}
210
211// ── prefix completion ─────────────────────────────────────────────────────────
212
213/// JSON-LD prefix completion from LOV / prefix.cc.
214///
215/// Suggests prefixes that are not yet declared in the document's `@context` and
216/// produces an additional [`TextEdit`] via [`add_to_context`] that inserts the
217/// new prefix declaration into the `@context` value.
218///
219/// Uses [`DynLang::quote`] so the inserted predicate text is correctly wrapped
220/// in JSON string quotes (e.g. `"foaf:"`).
221// #[instrument(skip(query, lovs, prefix_cc, config))]
222// pub fn jsonld_lov_undefined_prefix_completion(
223//     mut query: Query<
224//         (
225//             &TokenComponent,
226//             &Prefixes,
227//             &Source,
228//             &RopeC,
229//             &DynLang,
230//             &mut CompletionRequest,
231//         ),
232//         With<JsonLdLang>,
233//     >,
234//     lovs: Query<&LocalPrefix>,
235//     prefix_cc: Query<&PrefixEntry>,
236//     config: Res<ServerConfig>,
237// ) {
238//     for (word, prefixes, source, rope, lang, mut req) in &mut query {
239//         let bare_text = unquote(&word.text);
240//
241//         let defined_namespaces: HashSet<&str> = prefixes.0.iter().map(|p| p.url.as_str()).collect();
242//         let mut suggested: HashSet<String> = HashSet::new();
243//
244//         let candidates = lovs
245//             .iter()
246//             .map(|l| {
247//                 (
248//                     l.name.as_ref(),
249//                     l.namespace.as_ref(),
250//                     Some(l.title.as_ref()),
251//                 )
252//             })
253//             .chain(
254//                 prefix_cc
255//                     .iter()
256//                     .filter(|p| {
257//                         !config
258//                             .config
259//                             .local
260//                             .prefix_disabled
261//                             .iter()
262//                             .any(|x| p.name.starts_with(x.as_str()))
263//                     })
264//                     .map(|p| (p.name.as_ref(), p.namespace.as_ref(), None)),
265//             );
266//
267//         for (name, namespace, title) in candidates {
268//             if !name.starts_with(bare_text) {
269//                 continue;
270//             }
271//             if defined_namespaces.contains(namespace) {
272//                 continue;
273//             }
274//             if suggested.contains(namespace) {
275//                 continue;
276//             }
277//
278//             let Some(extra_edits) =
279//                 add_to_context(&source.0, &rope.0, ContextEntry::Prefix { name, namespace })
280//             else {
281//                 continue;
282//             };
283//
284//             // Quote the new predicate text for JSON-LD (e.g. "foaf:").
285//             let new_text = lang.quote(&format!("{}:$0", name));
286//             let mut completion = SimpleCompletion::new(
287//                 CompletionItemKind::MODULE,
288//                 name.to_string(),
289//                 TextEdit {
290//                     range: word.range.clone(),
291//                     new_text,
292//                 },
293//             )
294//             .documentation(namespace);
295//
296//             if let Some(t) = title {
297//                 completion = completion.label_description(t);
298//             }
299//
300//             let completion = extra_edits
301//                 .into_iter()
302//                 .fold(completion, |c, e| c.text_edit(e));
303//
304//             req.push(completion);
305//             suggested.insert(namespace.to_string());
306//         }
307//     }
308// }
309
310pub fn jsonld_lov_undefined_prefix_completion(
311    mut query: Query<
312        (
313            &Source,
314            &RopeC,
315            &TokenComponent,
316            &Prefixes,
317            &mut CompletionRequest,
318            &DynLang,
319        ),
320        With<JsonLdLang>,
321    >,
322    lovs: Query<&LocalPrefix>,
323    prefix_cc: Query<&PrefixEntry>,
324    config: Res<ServerConfig>,
325) {
326    if config.config.local.is_disabled(Disabled::CompletionPrefix) {
327        return;
328    }
329    let fmt = config.config.local.prefix_format.unwrap_or_default();
330    for (source, rope, word, prefixes, mut req, lang) in &mut query {
331        prefix_completion_helper(
332            word,
333            prefixes,
334            &mut req.0,
335            |name, location| lang.prefix_edits(&source.0, &rope.0, name, location, fmt),
336            lovs.iter(),
337            prefix_cc.iter(),
338            lang,
339        );
340    }
341}
342/// JSON-LD property completion driven by the CJS component registry.
343///
344/// When the cursor is in predicate position this system looks up the CJS
345/// component for the subject's declared `@type` and suggests its parameter
346/// names (including inherited ones).  Parameter IRIs are compacted via the
347/// document's active `@context` terms first, then via declared prefixes.
348#[instrument(skip(query, hierarchy, registry))]
349pub fn jsonld_property_completion(
350    mut query: Query<
351        (
352            &TokenComponent,
353            &TripleComponent,
354            &Types,
355            &JsonLdActiveContext,
356            &Prefixes,
357            &mut CompletionRequest,
358        ),
359        With<JsonLdLang>,
360    >,
361    hierarchy: Res<TypeHierarchy>,
362    registry: Res<Registry>,
363    config: Res<ServerConfig>,
364) {
365    if config.config.local.is_disabled(Disabled::CompletionProperty) {
366        return;
367    }
368    for (token, triple, types, active_ctx, prefixes, mut request) in &mut query {
369        if triple.target != TripleTarget::Predicate {
370            continue;
371        }
372
373        let bare_text = unquote(&token.text);
374
375        let Some(type_ids) = types.get(triple.triple.subject.value.as_ref()) else {
376            continue;
377        };
378
379        // Build reverse lookup: full IRI -> term name (from active context).
380        let iri_to_term: HashMap<&str, &str> = active_ctx
381            .0
382            .terms
383            .iter()
384            .filter_map(|(term, def)| def.iri.as_deref().map(|iri| (iri, term.as_str())))
385            .collect();
386
387        let subclasses: HashSet<_> = type_ids
388            .iter()
389            .flat_map(|t| hierarchy.iter_subclass(*t))
390            .collect();
391
392        let mut seen_params: HashSet<String> = HashSet::new();
393
394        for type_iri in &subclasses {
395            let Some(component) = registry.0.components.get(type_iri.as_ref()) else {
396                continue;
397            };
398
399            for param in &component.parameters {
400                let compact = iri_to_term
401                    .get(param.iri.as_str())
402                    .map(|&t| t.to_string())
403                    .or_else(|| prefixes.shorten(&param.iri));
404
405                let Some(compact) = compact else {
406                    continue;
407                };
408
409                if !compact.starts_with(bare_text) || !seen_params.insert(compact.clone()) {
410                    continue;
411                }
412
413                let mut completion = SimpleCompletion::new(
414                    CompletionItemKind::FIELD,
415                    compact.clone(),
416                    TextEdit {
417                        range: token.range.clone(),
418                        new_text: format!("\"{}\"", compact),
419                    },
420                );
421
422                if let Some(comment) = &param.comment {
423                    completion = completion.documentation(comment.as_str());
424                }
425
426                let sort_prefix = if param.required { "0" } else { "1" };
427                request.push(completion.sort_text(format!("{}{}", sort_prefix, compact)));
428            }
429        }
430    }
431}
432
433pub fn setup_completion(world: &mut World) {
434    use swls_core::feature::completion::*;
435    world.schedule_scope(CompletionLabel, |_, schedule| {
436        schedule.add_systems((
437            jsonld_property_completion.after(generate_completions),
438            jsonld_lov_undefined_prefix_completion.after(generate_completions),
439        ));
440    });
441}
442
443#[cfg(test)]
444mod tests {
445    use completion::CompletionRequest;
446    use swls_core::{components::*, prelude::*};
447    use swls_test_utils::{create_file, setup_world, TestClient};
448    use test_log::test;
449
450    #[test]
451    fn prefix_property_completion_works() {
452        let (mut world, _) = setup_world(TestClient::new(), crate::setup_world::<TestClient>);
453
454        // Valid JSON-LD: a complete triple whose key is a prefixed predicate.
455        // The cursor is positioned inside the key "foaf:name" to trigger predicate completion.
456        let src = "{\n  \"@context\": { \"foaf\": \"http://xmlns.com/foaf/0.1/\" },\n  \"@id\": \"http://example.com/me\",\n  \"foaf:name\": \"John\"\n}";
457        let entity = create_file(&mut world, src, "http://example.com/ns#", "jsonld", Open);
458
459        world.run_schedule(ParseLabel);
460
461        // JSON-LD triple extraction is async (context resolution runs in a
462        // spawned task), so drive those tasks to completion before querying the
463        // cursor's triple.
464        let client = world.resource::<TestClient>().clone();
465        futures::executor::block_on(
466            client.await_futures(|| world.run_schedule(swls_core::Tasks)),
467        );
468
469        // Verify that a TripleComponent is set with Predicate target when the
470        // cursor is inside the "foaf:name" key (line 3, char 3 ≈ inside the key).
471        world.entity_mut(entity).insert((
472            CompletionRequest(vec![]),
473            PositionComponent(swls_core::lsp_types::Position {
474                line: 3,
475                character: 3,
476            }),
477        ));
478        world.run_schedule(CompletionLabel);
479
480        let triple_comp = world.entity(entity).get::<TripleComponent>();
481        assert!(
482            triple_comp.is_some(),
483            "Expected TripleComponent to be set for cursor inside a JSON-LD predicate key"
484        );
485        assert_eq!(
486            triple_comp.unwrap().target,
487            TripleTarget::Predicate,
488            "Expected Predicate target for cursor inside a JSON-LD key"
489        );
490    }
491
492    #[test]
493    fn add_to_context_inserts_when_absent() {
494        use swls_core::text::LineIndex;
495
496        use crate::ecs::completion::{add_to_context, ContextEntry};
497
498        // Document without any @context.
499        let src = "{\n  \"@id\": \"http://example.com/me\"\n}";
500        let rope = LineIndex::new(src);
501
502        let edits = add_to_context(
503            src,
504            &rope,
505            ContextEntry::Prefix {
506                name: "foaf",
507                namespace: "http://xmlns.com/foaf/0.1/",
508            },
509        );
510        assert!(edits.is_some(), "Expected edits when @context is absent");
511        let edits = edits.unwrap();
512        assert_eq!(edits.len(), 1);
513        assert!(
514            edits[0].new_text.contains("@context"),
515            "Edit should insert @context, got: {:?}",
516            edits[0].new_text
517        );
518        assert!(
519            edits[0].new_text.contains("foaf"),
520            "Edit should contain the prefix name"
521        );
522    }
523
524    #[test]
525    fn add_to_context_no_duplicate() {
526        use swls_core::text::LineIndex;
527
528        use crate::ecs::completion::{add_to_context, ContextEntry};
529
530        // Document where "foaf" is already declared.
531        let src = "{\n  \"@context\": {\"foaf\": \"http://xmlns.com/foaf/0.1/\"},\n  \"@id\": \"http://example.com/me\"\n}";
532        let rope = LineIndex::new(src);
533
534        let edits = add_to_context(
535            src,
536            &rope,
537            ContextEntry::Prefix {
538                name: "foaf",
539                namespace: "http://xmlns.com/foaf/0.1/",
540            },
541        );
542        assert!(
543            edits.is_none(),
544            "Should return None when prefix already present"
545        );
546    }
547}