Skip to main content

swls_lang_jsonld/ecs/
mod.rs

1use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc};
2
3use bevy_ecs::{
4    prelude::*, schedule::ScheduleLabel, system::RunSystemOnce as _, world::CommandQueue,
5};
6use futures::{channel, SinkExt};
7use rdf_parsers::{
8    jsonld::{
9        convert::{
10            convert_with_loader, parse_jsonld_for_context, ActiveContext, ContextLoader, JsonLdVal,
11        },
12        parser::{Lang, Rule, SyntaxKind},
13    },
14    IncrementalBias, PrevParseInfo,
15};
16use rowan::{GreenNode, NodeOrToken};
17use swls_core::{
18    lsp_types::{request::InlayHintRefreshRequest, TextDocumentItem, Url},
19    prelude::*,
20};
21use swls_lang_turtle::{ecs::parse::derive_triples_system, lang::parser::TurtleParseError};
22use tracing::instrument;
23
24use crate::{cjs, JsonLdLang, Registry};
25
26pub mod completion;
27pub use completion::setup_completion;
28
29/// Caches remotely-fetched `@context` documents so they survive across parse ticks.
30#[derive(Resource, Default)]
31pub struct ContextCache(pub HashMap<String, String>);
32
33/// ECS component that carries the accumulated JSON-LD active context for a document.
34///
35/// Populated by [`parse_jsonld_system`] and consumed by JSON-LD-specific
36/// completion systems to offer term-alias and prefix-based completions.
37#[derive(Component, Debug, Default, Clone)]
38pub struct JsonLdActiveContext(pub ActiveContext);
39
40/// Resolves `@context` URLs for a single document parse.
41///
42/// Resolution order:
43/// 1. Per-invocation [`local_cache`](Self::local_cache) — avoids re-fetching
44///    the same URL twice within one document's context chain.
45/// 2. [`cjs_contexts`](Self::cjs_contexts) — the pre-loaded CJS context map from
46///    [`ModuleState`].  Returning the value here avoids the full ECS round-trip
47///    (spawn entity → run `FetchLabel` → wait for oneshot channel) for the
48///    common case where every `.jsonld` file references the same CJS context URL.
49/// 3. ECS world / network fallback — for contexts not known at startup.
50struct WorldContextLoader {
51    sender: CommandSender,
52    /// Pre-loaded CJS contexts shared from [`ModuleState::contexts`].
53    cjs_contexts: Arc<HashMap<String, JsonLdVal>>,
54    /// Cache of already-resolved contexts within this loader's lifetime.
55    local_cache: HashMap<String, JsonLdVal>,
56}
57
58impl ContextLoader for WorldContextLoader {
59    fn load_val<'a>(
60        &'a mut self,
61        url: &'a str,
62    ) -> Pin<Box<dyn Future<Output = Option<rdf_parsers::jsonld::convert::JsonLdVal>> + 'a>> {
63        // 1. Per-invocation cache.
64        if let Some(val) = self.local_cache.get(url) {
65            let val = val.clone();
66            return Box::pin(std::future::ready(Some(val)));
67        }
68
69        // 2. CJS contexts — direct map lookup, no ECS round-trip needed.
70        if let Some(result) = self.cjs_contexts.get(url).map(|ctx_doc| {
71            if let Some(ctx) = ctx_doc.get("@context") {
72                ctx.clone()
73            } else {
74                ctx_doc.clone()
75            }
76        }) {
77            self.local_cache.insert(url.to_string(), result.clone());
78            return Box::pin(std::future::ready(Some(result)));
79        }
80
81        // 3. ECS world / network fallback.
82        tracing::debug!("context loader load val {}", url);
83        let cs = self.sender.clone();
84        let url = url.to_string();
85        Box::pin(async move {
86            let (sender, receiver) = futures::channel::oneshot::channel::<Result<String, _>>();
87            let mut command_queue = CommandQueue::default();
88            let url2 = url.clone();
89            tracing::debug!("Trying to find context {}", url.as_str());
90            command_queue.push(move |world: &mut World| {
91                world.spawn(ContextRequest {
92                    url: url2,
93                    on_ready: Some(sender),
94                });
95                world.run_schedule(FetchLabel);
96            });
97
98            let _ = cs.unbounded_send(command_queue);
99
100            let v = receiver.await.ok()?;
101            tracing::debug!("context loader got response for {}", url);
102            match v {
103                Ok(s) => parse_jsonld_for_context(&s),
104                Err(x) => {
105                    if let JsonLdVal::Object(members, _) = &x {
106                        if let Some((_, _, _, ctx)) =
107                            members.iter().find(|(k, _, _, _)| k == "@context")
108                        {
109                            return Some(ctx.clone());
110                        }
111                    }
112
113                    Some(x)
114                }
115            }
116        })
117    }
118    fn load<'a>(&'a mut self, url: &'a str) -> Pin<Box<dyn Future<Output = Option<String>> + 'a>> {
119        tracing::debug!("context loader load string {}", url);
120        Box::pin(std::future::ready(None))
121    }
122}
123
124pub fn setup_parsing<C: Client + Resource + Clone>(world: &mut World) {
125    use swls_core::feature::parse::*;
126    world.schedule_scope(ParseLabel, |_, schedule| {
127        schedule.add_systems((
128            parse_jsonld_system::<C>,
129            derive_jsonld_prefixes
130                .after(parse_jsonld_system::<C>)
131                .before(prefixes),
132            derive_triples_system::<JsonLdLang>
133                .after(parse_jsonld_system::<C>)
134                .before(triples),
135        ));
136    });
137
138    let mut schedule = bevy_ecs::schedule::Schedule::new(FetchLabel);
139    schedule.add_systems((
140        cjs::cjs_loader.before(fetch_from_world),
141        fetch_from_world,
142        fetch_from_web::<C>.after(fetch_from_world),
143        return_empty.after(fetch_from_web::<C>),
144    ));
145    world.add_schedule(schedule);
146}
147
148/// Indicates that something should be fetched
149#[derive(ScheduleLabel, Clone, Eq, PartialEq, Debug, Hash)]
150pub struct FetchLabel;
151
152fn return_empty(requests: Query<(Entity, &mut ContextRequest)>, mut commander: Commands) {
153    for (e, mut r) in requests {
154        if let Some(sender) = r.on_ready.take() {
155            tracing::debug!("loader sending empty {}", r.url);
156            let _ = sender.send(Result::Ok(String::new()));
157        } else {
158            tracing::debug!("loader cannot send empty {}", r.url);
159        }
160        commander.entity(e).despawn();
161    }
162}
163
164fn fetch_from_world(
165    mut requests: Query<(Entity, &mut ContextRequest)>,
166    documents: Query<(&Label, Option<&Wrapped<JsonLdVal>>, Option<&Source>)>,
167    mut commander: Commands,
168) {
169    // NOTE: do NOT call run_schedule(ParseLabel) here even when a
170    // Wrapped<JsonLdVal> context entity is found.  The value has already been
171    // delivered to the waiting async task via the oneshot channel; that task
172    // will call run_schedule(ParseLabel) itself when it finishes inserting the
173    // Element.  Calling ParseLabel here is premature and was the source of a
174    // cascading loop: the persistent context entity (Wrapped<JsonLdVal>,
175    // created by cjs_loader and never despawned) would be found again by every
176    // subsequent load_val call, each triggering another ParseLabel which could
177    // re-fire parse_jsonld_system, spawning more tasks that each called
178    // load_val again.  With the WorldContextLoader now returning CJS contexts
179    // directly from cjs_contexts (no ContextRequest spawned), this code path
180    // is unreachable for CJS contexts.  Removing the ParseLabel trigger here
181    // closes the loop for any future code that might recreate the pattern.
182    for (entity, mut request) in requests.iter_mut() {
183        for (labels, jsonld_val, s) in documents {
184            if labels.as_str() == request.url.as_str() {
185                if let Some(sender) = request.on_ready.take() {
186                    let o = match (jsonld_val, s) {
187                        (Some(val), _) => {
188                            let char_count = format!("{:?}", val).len();
189                            tracing::debug!(
190                                "loader fetch_from_world sent {} (found val {} {})",
191                                request.url,
192                                jsonld_val.is_some(),
193                                char_count
194                            );
195                            sender.send(Result::Err(val.as_ref().clone()))
196                        }
197                        (_, Some(source)) => {
198                            tracing::debug!(
199                                "loader fetch_from_world sent {} (found val false {})",
200                                request.url,
201                                source.len()
202                            );
203                            sender.send(Result::Ok(source.to_string()))
204                        }
205                        _ => {
206                            tracing::debug!(
207                                "loader fetch_from_world sent {} (empty string)",
208                                request.url,
209                            );
210                            sender.send(Result::Ok("".to_string()))
211                        }
212                    };
213                    tracing::debug!("Sent successful {:?}", o);
214                }
215                if let Ok(mut e) = commander.get_entity(entity) {
216                    e.despawn();
217                }
218            }
219        }
220    }
221}
222
223fn fetch_from_web<C: Client + Resource + Clone>(
224    mut requests: Query<(&mut ContextRequest,), Added<ContextRequest>>,
225    sender: Res<CommandSender>,
226    client: Res<C>,
227    fs: Res<Fs>,
228) {
229    for (r,) in requests.iter_mut() {
230        let client_clone = client.as_ref().clone();
231        let url = r.url.to_string();
232
233        let Ok(label) = Url::parse(&url) else {
234            continue;
235        };
236        let sender = sender.clone();
237        if label.scheme().starts_with("http") {
238            client.spawn(async move {
239                if let Ok(resp) = client_clone.fetch(&url, &HashMap::new()).await {
240                    let mut cq = CommandQueue::default();
241                    cq.push(move |world: &mut World| {
242                        if world
243                            .query::<(Entity, &Label)>()
244                            .iter(&world)
245                            .find(|x| x.1.as_str() == &url)
246                            .is_none()
247                        {
248                            world.spawn((Source(resp.body), Label(label)));
249                            let _ = world.run_system_once(derive_jsonld_triples::<C>);
250                        }
251                    });
252                    let _ = sender.unbounded_send(cq);
253                }
254            });
255        } else {
256            let fs = fs.clone();
257            client.spawn(async move {
258                if let Some(resp) = fs.0.read_file(&label).await {
259                    let mut cq = CommandQueue::default();
260                    cq.push(move |world: &mut World| {
261                        if world
262                            .query::<(Entity, &Label)>()
263                            .iter(&world)
264                            .find(|x| x.1.as_str() == &url)
265                            .is_none()
266                        {
267                            let item = TextDocumentItem {
268                                version: 1,
269                                uri: label.clone(),
270                                language_id: String::from("turtle"),
271                                text: String::new(),
272                            };
273                            let e = world
274                                .spawn((
275                                    RopeC(LineIndex::new(&resp)),
276                                    Source(resp),
277                                    Label(label.clone()),
278                                    Wrapped(item),
279                                    Types(HashMap::new()),
280                                ))
281                                .id();
282
283                            world.trigger(CreateEvent {
284                                url: label,
285                                language_id: Some("jsonld".to_string()),
286                                entity: e,
287                            });
288                            tracing::debug!("Found local file, rerunning the deriving {}", url);
289                            let _ = world.run_system_once(derive_jsonld_triples::<C>);
290                        }
291                    });
292                    let _ = sender.unbounded_send(cq);
293                }
294            });
295        }
296    }
297}
298
299#[derive(bevy_ecs::prelude::Component)]
300pub struct ContextRequest {
301    pub url: String,
302    pub on_ready: Option<channel::oneshot::Sender<Result<String, JsonLdVal>>>,
303}
304
305fn extract_jsonld_cst_tokens(node: &rowan::SyntaxNode<Lang>) -> Vec<Spanned<rowan::SyntaxKind>> {
306    let mut tokens = Vec::new();
307    for not in node.descendants_with_tokens() {
308        if let NodeOrToken::Token(t) = not {
309            if t.kind() == SyntaxKind::WhiteSpace {
310                continue;
311            }
312            let range = t.text_range();
313            let span = usize::from(range.start())..usize::from(range.end());
314            tokens.push(spanned(rowan::SyntaxKind(t.kind() as u16), span));
315        }
316    }
317    tokens
318}
319
320fn collect_errors(node: &rowan::SyntaxNode<Lang>) -> Vec<TurtleParseError> {
321    let mut errors = Vec::new();
322    let mut stack = vec![node.clone()];
323    while let Some(current) = stack.pop() {
324        for child in current.children_with_tokens() {
325            match child {
326                NodeOrToken::Node(n) => {
327                    if n.kind() == SyntaxKind::Error {
328                        let range = rdf_parsers::effective_error_span::<Lang>(&n);
329                        let msg = n
330                            .parent()
331                            .map(|p| format!("Expected: {:?}", p.kind()))
332                            .unwrap_or_else(|| format!("Unexpected: {}", n.text()));
333                        errors.push(TurtleParseError { range, msg });
334                    } else {
335                        stack.push(n);
336                    }
337                }
338                NodeOrToken::Token(t) => {
339                    if t.kind() == SyntaxKind::Error {
340                        let r = t.text_range();
341                        errors.push(TurtleParseError {
342                            range: r.start().into()..r.end().into(),
343                            msg: format!("Unexpected: {}", t.text()),
344                        });
345                    }
346                }
347            }
348        }
349    }
350    errors
351}
352
353/// Re-derive JSON-LD triples for every open JSON-LD document.
354///
355/// All documents are processed sequentially inside **one** spawned task.  A
356/// single [`CommandQueue`] is sent at the end that inserts all derived elements
357/// at once and runs [`ParseLabel`] exactly once.  This means
358/// `derive_triples_system`, `load_store`, and `derive_ontologies` each execute
359/// once for the whole batch instead of once per document — the dominant cost
360/// during initial CJS workspace loading.
361pub fn derive_jsonld_triples<C: Client + Resource + Clone>(
362    query: Query<(Entity, &Wrapped<GreenNode>, &Label, &Source), With<JsonLdLang>>,
363    sender: Res<CommandSender>,
364    client: Res<C>,
365    registry: Res<Registry>,
366) {
367    // Collect owned data up front so we can move it into the async block.
368    let items: Vec<(Entity, rowan::GreenNode, String, usize)> = query
369        .iter()
370        .map(|(e, gn, label, source)| (e, gn.0.clone(), label.0.to_string(), source.0.len()))
371        .collect();
372
373    if items.is_empty() {
374        return;
375    }
376
377    let cjs_contexts = registry.1.contexts.clone();
378    let mut sender_clone = sender.clone();
379    let c2 = client.clone();
380
381    client.spawn_local(move || async move {
382        let mut all_results: Vec<(Entity, Element, JsonLdActiveContext)> =
383            Vec::with_capacity(items.len());
384
385        for (e, gn, base, span_len) in items {
386            let mut loader = WorldContextLoader {
387                sender: sender_clone.clone(),
388                cjs_contexts: cjs_contexts.clone(),
389                local_cache: HashMap::new(),
390            };
391            let syntax = rowan::SyntaxNode::new_root(gn);
392            let (jsonld_model, ctx) = convert_with_loader(&syntax, &mut loader, Some(base)).await;
393
394            tracing::debug!("{} triples", jsonld_model.triples.len());
395            let element = Element(spanned(jsonld_model, 0..span_len));
396            all_results.push((e, element, JsonLdActiveContext(ctx)));
397        }
398
399        // Insert all elements and run ParseLabel once so derive_ontologies
400        // only executes once regardless of how many documents were processed.
401        let mut command_queue = CommandQueue::default();
402        command_queue.push(move |world: &mut World| {
403            for (e, element, ctx) in all_results {
404                world.entity_mut(e).insert((element, ctx));
405            }
406            world.run_schedule(ParseLabel);
407        });
408        let _ = sender_clone.0.send(command_queue).await;
409        c2.send_request::<InlayHintRefreshRequest>(()).await;
410    });
411}
412
413#[instrument(skip(query, sender, commands, client, registry))]
414fn parse_jsonld_system<C: Client + Resource + Clone>(
415    query: Query<
416        (Entity, &Source, &Label, Option<&Wrapped<PrevParseInfo>>),
417        (Changed<Source>, With<JsonLdLang>, With<Open>),
418    >,
419    mut commands: Commands,
420    sender: Res<CommandSender>,
421    client: Res<C>,
422    registry: Res<Registry>,
423    config: Res<ServerConfig>,
424) {
425    if !config.config.jsonld.unwrap_or(true) {
426        return;
427    }
428    let cjs_contexts = registry.1.contexts.clone();
429
430    for (entity, source, label, prev) in &query {
431        let (parse, new_prev) = rdf_parsers::parse_incremental(
432            Rule::new(SyntaxKind::JsonldDoc),
433            source.0.as_str(),
434            prev.map(|x| &x.0),
435            IncrementalBias::default(),
436        );
437        commands.entity(entity).insert(Wrapped(new_prev));
438
439        let syntax = parse.syntax::<Lang>();
440        let errors = collect_errors(&syntax);
441        let cst_tokens = extract_jsonld_cst_tokens(&syntax);
442
443        let gn = parse.green_node.clone();
444        let base = label.0.to_string();
445        let span = 0..source.0.len();
446        let e = entity.clone();
447        let mut sender = sender.clone();
448        let cjs_contexts = cjs_contexts.clone();
449        client.spawn_local(move || async move {
450            let mut loader = WorldContextLoader {
451                sender: sender.clone(),
452                cjs_contexts,
453                local_cache: HashMap::new(),
454            };
455            let syntax = rowan::SyntaxNode::new_root(gn.clone());
456            let (jsonld_model, ctx) = convert_with_loader(&syntax, &mut loader, Some(base)).await;
457
458            tracing::debug!("{} triples", jsonld_model.triples.len());
459
460            let element = Element(spanned(jsonld_model, span));
461
462            let mut command_queue = CommandQueue::default();
463            command_queue.push(move |world: &mut World| {
464                world
465                    .entity_mut(e)
466                    .insert((element, JsonLdActiveContext(ctx)));
467                // Re-run ParseLabel so that derive_triples_system and
468                // derive_jsonld_prefixes see the newly-inserted element
469                // (they react to Changed<Element> filtered by With<JsonLdLang>).
470                // Without
471                // this, those systems only fire on the *next* user edit.
472                world.run_schedule(ParseLabel);
473            });
474            let _ = sender.0.send(command_queue).await;
475        });
476
477        if errors.is_empty() {
478            commands
479                .entity(entity)
480                .insert((
481                    Errors(errors),
482                    CstTokens(cst_tokens),
483                    Wrapped(parse.green_node),
484                ))
485                .remove::<Dirty>();
486        } else {
487            commands.entity(entity).insert((
488                Errors(errors),
489                CstTokens(cst_tokens),
490                Wrapped(parse.green_node),
491                Dirty,
492            ));
493        }
494    }
495}
496
497/// Derives a [`Prefixes`] component for a JSON-LD document from the prefixes
498/// declared in its `@context` (e.g. `"foaf": "http://xmlns.com/foaf/0.1/"`).
499///
500/// This mirrors the equivalent system in `lang-turtle` and is required so that
501/// generic completion and hover systems that rely on [`Prefixes`] work for
502/// JSON-LD documents.
503fn derive_jsonld_prefixes(
504    query: Query<(Entity, &Label, &Element), (Changed<Element>, With<JsonLdLang>)>,
505    mut commands: Commands,
506) {
507    use swls_lang_rdf_base::traits::NamedNodeExt;
508
509    for (entity, url, turtle) in &query {
510        let prefixes: Vec<_> = turtle
511            .prefixes
512            .iter()
513            .flat_map(|prefix| {
514                let expanded = prefix.value.value().expand(turtle.value())?;
515                let parsed_url = swls_core::lsp_types::Url::parse(&expanded).ok()?;
516                Some(Prefix {
517                    url: parsed_url,
518                    prefix: prefix.prefix.value().clone(),
519                })
520            })
521            .collect();
522
523        let base = turtle
524            .base
525            .as_ref()
526            .and_then(|b| {
527                b.0 .1
528                    .value()
529                    .expand(turtle.value())
530                    .and_then(|x| swls_core::lsp_types::Url::parse(&x).ok())
531            })
532            .unwrap_or_else(|| url.0.clone());
533
534        commands.entity(entity).insert(Prefixes(prefixes, base));
535    }
536}
537
538pub(crate) fn format_jsonld_system(
539    mut query: Query<(&RopeC, &Wrapped<GreenNode>, &mut FormatRequest), With<JsonLdLang>>,
540    config: Res<ServerConfig>,
541) {
542    use swls_core::lsp_types::{Position, Range};
543    if !config.config.format.jsonld() {
544        tracing::debug!("JSON-LD formatting disabled by config");
545        return;
546    }
547    for (source, node, mut request) in &mut query {
548        if request.0.is_some() {
549            tracing::debug!("Didn't format with the jsonld format system, already formatted");
550            continue;
551        }
552        tracing::debug!("Formatting with turtle format system");
553
554        let root = rowan::SyntaxNode::new_root(node.0.clone());
555
556        let formatted = rdf_parsers::jsonld::format::format(&root, 80);
557
558        request.0 = Some(vec![swls_core::lsp_types::TextEdit::new(
559            Range::new(
560                Position::new(0, 0),
561                Position::new(source.0.len_lines() as u32 + 1, 0),
562            ),
563            formatted,
564        )]);
565    }
566}
567
568#[cfg(test)]
569mod id_highlight {
570    use swls_core::feature::semantic::{HighlightRequest, TokenTypesComponent};
571    use swls_core::feature::{ParseLabel, SemanticLabel};
572    use swls_core::lsp_types::SemanticTokenType;
573    use swls_core::prelude::*;
574    use swls_test_utils::{create_file, setup_world, TestClient};
575
576    /// A prefixed IRI must be highlighted the same way whether it appears as a
577    /// subject `"@id": "ex:obs1"` or as an object reference `{ "@id": "ex:x" }`:
578    /// `prefix:` as NAMESPACE and the local part as an IRI colour — never STRING.
579    #[test]
580    fn id_iris_consistently_highlighted_not_string() {
581        let (mut world, _) = setup_world(TestClient::new(), crate::setup_world::<TestClient>);
582        let src = "{\n  \"@context\": { \"ex\": \"https://example.org/\", \"sosa\": \"http://www.w3.org/ns/sosa/\" },\n  \"@graph\": [\n    { \"@type\": \"sosa:Message\", \"sosa:primaryTopic\": { \"@id\": \"ex:obs1\" } },\n    { \"@id\": \"ex:obs1\", \"sosa:isObservedBy\": { \"@id\": \"ex:sensor-1\" } }\n  ]\n}";
583        let entity = create_file(&mut world, src, "http://example.com/ns#", "jsonld", Open);
584        world.run_schedule(ParseLabel);
585        let client = world.resource::<TestClient>().clone();
586        futures::executor::block_on(client.await_futures(|| world.run_schedule(swls_core::Tasks)));
587        world.entity_mut(entity).insert(HighlightRequest(vec![]));
588        world.run_schedule(SemanticLabel);
589
590        let ttc = world.entity(entity).get::<TokenTypesComponent>().expect("ttc");
591        let mut ts: Vec<Option<SemanticTokenType>> = vec![None; src.len()];
592        for s in &ttc.0 {
593            for j in s.span().clone() {
594                if j < ts.len() {
595                    ts[j] = Some(s.value().clone());
596                }
597            }
598        }
599        let ty_at = |b: usize| ts[b].as_ref().map(|t| t.as_str().to_string());
600
601        // Check the full `"prefix:local"` token, quotes included, for every
602        // occurrence — subject and object alike.  This pins down the byte
603        // boundaries so a one-off (the parser's `idx` points at the opening quote)
604        // is caught.
605        let check = |iri: &str| {
606            let quoted = format!("\"{iri}\"");
607            let colon = iri.find(':').unwrap();
608            for (q, _) in src.match_indices(&quoted) {
609                let open = q; // opening quote
610                let content = q + 1; // first char of the IRI
611                let close = q + 1 + iri.len(); // closing quote
612                assert_ne!(
613                    ty_at(open).as_deref(),
614                    Some("namespace"),
615                    "opening quote of {quoted} at {open} must not be coloured as the IRI"
616                );
617                // `prefix:` (through the ':') is NAMESPACE.
618                for b in content..content + colon + 1 {
619                    assert_eq!(
620                        ty_at(b).as_deref(),
621                        Some("namespace"),
622                        "{quoted}: byte {b} ({:?}) should be NAMESPACE",
623                        &src[b..b + 1]
624                    );
625                }
626                // The whole local part — including the last char — is enumMember.
627                for b in content + colon + 1..close {
628                    assert_eq!(
629                        ty_at(b).as_deref(),
630                        Some("enumMember"),
631                        "{quoted}: byte {b} ({:?}) should be enumMember (not string)",
632                        &src[b..b + 1]
633                    );
634                }
635            }
636        };
637        check("ex:obs1");
638        check("ex:sensor-1");
639    }
640
641    /// The model is authoritative for term-shaped content while the lexer keeps
642    /// keywords: `@context` prefix keys are NAMESPACE, keyword tokens (`@id`,
643    /// `@type`) stay KEYWORD (even though `@type` is the `rdf:type` predicate in
644    /// the model), a compact-IRI value splits NAMESPACE/enumMember, and a plain
645    /// string literal stays STRING (the lexer's job, not overwritten).
646    #[test]
647    fn context_keywords_and_literals_coloured_correctly() {
648        let (mut world, _) = setup_world(TestClient::new(), crate::setup_world::<TestClient>);
649        let src = "{\n  \"@context\": { \"ex\": \"https://example.org/\" },\n  \"@id\": \"ex:alice\",\n  \"@type\": \"ex:Person\",\n  \"ex:label\": \"Alice\"\n}";
650        let entity = create_file(&mut world, src, "http://example.com/ns#", "jsonld", Open);
651        world.run_schedule(ParseLabel);
652        let client = world.resource::<TestClient>().clone();
653        futures::executor::block_on(client.await_futures(|| world.run_schedule(swls_core::Tasks)));
654        world.entity_mut(entity).insert(HighlightRequest(vec![]));
655        world.run_schedule(SemanticLabel);
656
657        let ttc = world.entity(entity).get::<TokenTypesComponent>().expect("ttc");
658        let mut ts: Vec<Option<SemanticTokenType>> = vec![None; src.len()];
659        for s in &ttc.0 {
660            for j in s.span().clone() {
661                if j < ts.len() {
662                    ts[j] = Some(s.value().clone());
663                }
664            }
665        }
666        // Assert every byte of `needle`'s single occurrence has type `expected`.
667        let all = |needle: &str, expected: &str| {
668            let q = src.find(needle).unwrap_or_else(|| panic!("missing {needle:?}"));
669            for b in q..q + needle.len() {
670                assert_eq!(
671                    ts[b].as_ref().map(|t| t.as_str()),
672                    Some(expected),
673                    "{needle:?} byte {b} ({:?})",
674                    &src[b..b + 1]
675                );
676            }
677        };
678
679        all("\"ex\"", "namespace"); // @context prefix key (from the model)
680        all("\"@id\"", "keyword"); // keyword kept by the lexer
681        all("\"@type\"", "keyword"); // keyword, not the rdf:type predicate colour
682        all("\"Alice\"", "string"); // plain literal, lexer's job, not overwritten
683        all("\"https://example.org/\"", "string"); // @context IRI value stays a literal
684    }
685}