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
15fn unquote(text: &str) -> &str {
18 let s = text.strip_prefix('"').unwrap_or(text);
19 s.strip_suffix('"').unwrap_or(s)
20}
21
22pub enum ContextEntry<'a> {
26 Prefix { name: &'a str, namespace: &'a str },
28 Url(&'a str),
30}
31
32fn 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
84fn 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
100fn 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 ¤t[..close],
114 name,
115 namespace
116 ))
117 }
118 }
119 ContextEntry::Url(url) => {
120 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!("{}, {}]", ¤t[..close], new_entry))
143 }
144 }
145 _ => None,
146 }
147}
148
149fn find_toplevel_object_open(source: &str) -> Option<usize> {
152 let offset = source.find('{')?;
153 Some(offset + 1)
154}
155
156fn 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
166pub 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 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 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
211pub 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#[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 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(¶m.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) = ¶m.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 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 let client = world.resource::<TestClient>().clone();
465 futures::executor::block_on(
466 client.await_futures(|| world.run_schedule(swls_core::Tasks)),
467 );
468
469 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 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 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}