Skip to main content

swls_lang_jsonld/
lib.rs

1#![doc(
2    html_logo_url = "https://ajuvercr.github.io/semantic-web-lsp/assets/icons/favicon.png",
3    html_favicon_url = "https://ajuvercr.github.io/semantic-web-lsp/assets/icons/favicon.ico"
4)]
5pub mod cjs;
6
7use std::{borrow::Cow, ops::Range};
8
9use bevy_ecs::{
10    component::Component,
11    observer::On,
12    query::With,
13    resource::Resource,
14    schedule::IntoScheduleConfigs,
15    system::{Commands, Query, Res, RunSystemOnce},
16    world::{CommandQueue, World},
17};
18use components_rs::{
19    components::registry::{resolve_iri_to_url, ComponentRegistry},
20    module_state::ModuleState,
21};
22use oxigraph::model::{GraphName, Literal, NamedNode, Quad};
23use swls_core::{
24    lang::{Lang, LangHelper},
25    lsp_types::{SemanticTokenType, Url},
26    prelude::{goto_definition::GotoDefinitionRequest, *},
27    util::resolve_iri,
28    Started,
29};
30use swls_lang_rdf_base::register_rdf_lang;
31use swls_lang_turtle::lang::parser::TurtleParseError;
32
33pub mod ecs;
34use crate::{
35    ecs::{
36        derive_jsonld_triples, format_jsonld_system, setup_completion, setup_parsing, ContextCache,
37        JsonLdActiveContext,
38    },
39    fs::build_registry,
40};
41
42#[derive(Component, Default)]
43pub struct JsonLdLang;
44
45#[derive(Debug, Default)]
46pub struct JsonLdHelper;
47
48impl LangHelper for JsonLdHelper {
49    fn keyword(&self) -> &[&'static str] {
50        &[
51            "@context",
52            "@id",
53            "@type",
54            "@graph",
55            "@base",
56            "@vocab",
57            "@language",
58            "@value",
59            "@list",
60            "@set",
61            "@reverse",
62            "@index",
63            "@container",
64        ]
65    }
66
67    fn default_position(&self) -> TripleTarget {
68        TripleTarget::Predicate
69    }
70
71    fn unquote<'a>(&self, text: &'a str) -> &'a str {
72        let s = text.strip_prefix('"').unwrap_or(text);
73        s.strip_suffix('"').unwrap_or(s)
74    }
75    fn quote(&self, inp: &str) -> String {
76        format!("\"{}\"", inp)
77    }
78    fn rename_placeholder<'a>(&self, raw: &'a str) -> &'a str {
79        self.unquote(raw)
80    }
81    fn rename_wrap(&self, new_text: &str) -> String {
82        self.quote(new_text)
83    }
84    fn handles_prefix_completion(&self) -> bool {
85        true
86    }
87
88    /// Opt JSON-LD out of the generic prefix-diagnostics system.
89    ///
90    /// `@context` has no syntactic distinction between a namespace prefix and a
91    /// term alias, and terms can be declared via shared/remote contexts or used
92    /// purely to define other terms — none of which the span-based, body-walking
93    /// detector models correctly, so it produces false positives (see the module
94    /// docs on [`swls_core::systems::prefix_diagnostic_helper`]). Undefined-prefix
95    /// detection is moot too: JSON-LD silently drops terms with unknown prefixes,
96    /// so they never reach a triple to flag.
97    fn supports_prefix_diagnostics(&self) -> bool {
98        false
99    }
100
101    /// Extract the prefix name whose `:` was just typed *inside a JSON string*
102    /// (e.g. typing `:` in `"foaf:knows"`), so on-type formatting can splice the
103    /// prefix into `@context`.  Unlike the text-RDF default, the term boundary is
104    /// the opening double-quote of the string.
105    fn prefix_name_at<'a>(&self, source: &'a str, offset: usize) -> Option<&'a str> {
106        let bytes = source.as_bytes();
107        if offset == 0 || offset > source.len() || bytes[offset - 1] != b':' {
108            return None;
109        }
110        let colon = offset - 1;
111
112        let mut start = colon;
113        while start > 0 {
114            let c = bytes[start - 1];
115            if c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-' | b'.') {
116                start -= 1;
117            } else {
118                break;
119            }
120        }
121        if start == colon {
122            return None;
123        }
124        // Must sit inside a JSON string: the char before the name is the opening
125        // quote.  A context *key* like `"foaf":` has the `:` outside the quotes,
126        // so it has no name char before the `:` and is correctly ignored.
127        if start == 0 || bytes[start - 1] != b'"' {
128            return None;
129        }
130        Some(&source[start..colon])
131    }
132
133    fn prefix_edits(
134        &self,
135        source: &str,
136        rope: &LineIndex,
137        name: &str,
138        namespace: &str,
139        _format: swls_core::components::PrefixFormat,
140    ) -> Option<Vec<swls_core::lsp_types::TextEdit>> {
141        crate::ecs::completion::add_to_context(
142            source,
143            rope,
144            crate::ecs::completion::ContextEntry::Prefix { name, namespace },
145        )
146    }
147
148    fn inlay_types_hint(
149        &self,
150        subject: &Range<usize>,
151        rope: &LineIndex,
152        last_type: Option<&Range<usize>>,
153        types: Vec<Cow<'_, str>>,
154    ) -> Option<swls_core::lsp_types::InlayHint> {
155        let (label, position) = if let Some(lt) = last_type {
156            if let Some(pos) = offset_to_position(lt.end, &rope) {
157                let label = format!(", {}", types.join(", "));
158                (label, pos)
159            } else {
160                return None;
161            }
162        } else {
163            let offset = if rope.char_at_byte(subject.start) == Some('[') {
164                subject.start + 1
165            } else {
166                subject.end
167            };
168
169            if let Some(pos) = offset_to_position(offset + 1, &rope) {
170                let label = if types.len() == 1 {
171                    format!(r#" "@type": "{}";"#, types[0])
172                } else {
173                    format!(
174                        r#" "@type": [ {} ],"#,
175                        types
176                            .into_iter()
177                            .map(|x| format!("\"{}\"", x))
178                            .collect::<Vec<_>>()
179                            .join(", ")
180                    )
181                };
182                (label, pos)
183            } else {
184                return None;
185            }
186        };
187
188        return Some(swls_core::lsp_types::InlayHint {
189            position,
190            label: swls_core::lsp_types::InlayHintLabel::String(label),
191            kind: None,
192            text_edits: None,
193            tooltip: None,
194            padding_left: None,
195            padding_right: None,
196            data: None,
197        });
198    }
199}
200
201pub fn setup_world<C: Client + ClientSync + Resource + Clone>(world: &mut World) {
202    register_rdf_lang::<JsonLdLang, JsonLdHelper>(world, &["jsonld"], &[".jsonld"]);
203
204    // For .json files and the "json" language ID, only activate JSON-LD when the
205    // source actually contains "@context" — plain JSON files should be left alone.
206    world.add_observer(
207        |trigger: On<CreateEvent>, mut commands: Commands, query: Query<&Source>| {
208            let e = trigger.event();
209            let is_json = trigger
210                .language_id
211                .as_ref()
212                .map(|l| l == "json")
213                .unwrap_or_default()
214                || e.url.as_str().ends_with(".json");
215            if !is_json {
216                return;
217            }
218            let entity = e.entity;
219            if let Ok(source) = query.get(entity) {
220                if source.0.contains("\"@context\"") {
221                    commands
222                        .entity(entity)
223                        .insert(JsonLdLang::default())
224                        .insert(DynLang(Box::new(JsonLdHelper::default())));
225                }
226            }
227        },
228    );
229    world.insert_resource(ContextCache::default());
230    world.insert_resource(Registry::empty());
231    setup_parsing::<C>(world);
232    setup_completion(world);
233
234    world.schedule_scope(FormatLabel, |_, schedule| {
235        schedule.add_systems(format_jsonld_system);
236    });
237
238    world.schedule_scope(Started, |_, schedule| {
239        schedule.add_systems((start_jsonld::<C>,));
240    });
241
242    world.schedule_scope(GotoDefinitionLabel, |_, schedule| {
243        schedule.add_systems(goto_cjs.after(swls_core::systems::get_current_prefix));
244    });
245}
246
247/// Convert a byte offset to an LSP `Position` (line + character).
248fn byte_offset_to_position(source: &str, offset: usize) -> swls_core::lsp_types::Position {
249    let offset = offset.min(source.len());
250    let before = &source[..offset];
251    let line = before.matches('\n').count() as u32;
252    let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) as u32;
253    swls_core::lsp_types::Position::new(line, col)
254}
255
256/// Convert a byte-range span to an LSP `Range` using the file's source text.
257fn span_to_lsp_range(source: &str, span: &std::ops::Range<usize>) -> swls_core::lsp_types::Range {
258    swls_core::lsp_types::Range::new(
259        byte_offset_to_position(source, span.start),
260        byte_offset_to_position(source, span.end),
261    )
262}
263
264/// Expand a JSON-LD compact IRI or bare term name using the document's active context.
265///
266/// Handles chains like `css:dist/...` → `npmd:@solid/...dist/...` → `https://...dist/...`
267/// and bare term names like `BearerWebIdExtractor` → `css:dist/...BearerWebIdExtractor`.
268fn expand_iri_with_context(
269    active: &rdf_parsers::jsonld::convert::ActiveContext,
270    value: &str,
271) -> String {
272    expand_iri_inner(active, value, 0)
273}
274
275fn expand_iri_inner(
276    active: &rdf_parsers::jsonld::convert::ActiveContext,
277    value: &str,
278    depth: usize,
279) -> String {
280    if depth > 10 || value.is_empty() || value.starts_with('@') {
281        return value.to_string();
282    }
283    // Already absolute — well-known schemes only, so we don't mistake `css:` for absolute.
284    if value.starts_with("https://")
285        || value.starts_with("http://")
286        || value.starts_with("file://")
287        || value.starts_with("urn:")
288    {
289        return value.to_string();
290    }
291    // Bare term lookup (e.g. "BearerWebIdExtractor")
292    if let Some(def) = active.terms.get(value) {
293        if let Some(iri) = &def.iri {
294            if iri != value {
295                return expand_iri_inner(active, iri, depth + 1);
296            }
297        }
298    }
299    // Compact IRI like "prefix:suffix"
300    if let Some(colon_pos) = value.find(':') {
301        if colon_pos > 0 {
302            let prefix = &value[..colon_pos];
303            let suffix = &value[colon_pos + 1..];
304            if let Some(def) = active.terms.get(prefix) {
305                if let Some(iri) = &def.iri {
306                    let expanded_prefix = expand_iri_inner(active, iri, depth + 1);
307                    return format!("{}{}", expanded_prefix, suffix);
308                }
309            }
310        }
311    }
312    value.to_string()
313}
314
315#[tracing::instrument(skip(query, res))]
316fn goto_cjs(
317    mut query: Query<
318        (
319            &TokenComponent,
320            Option<&TripleComponent>,
321            &Label,
322            &mut GotoDefinitionRequest,
323            Option<&JsonLdActiveContext>,
324            Option<&PrefixComponent>,
325        ),
326        With<JsonLdLang>,
327    >,
328    res: Res<Registry>,
329    config: Res<ServerConfig>,
330) {
331    use swls_core::lsp_types::{Location, Range};
332
333    if config.config.local.is_disabled(Disabled::GotoDefinitionComponentsJs) {
334        return;
335    }
336
337    for (token, triple, label, mut req, active_ctx, prefix) in &mut query {
338        // A real namespace prefix (`"foaf": "…/"`) is handled by the shared
339        // `goto_prefix`, which jumps to the ontology file. Term aliases
340        // (`"Component": "oo:Component"`) are not namespaces and still resolve to
341        // their Components.js definition here.
342        if prefix.map(|p| p.is_namespace()).unwrap_or(false) {
343            continue;
344        }
345        // Only use the expanded IRI from the TripleComponent if the cursor token
346        // actually overlaps the matched term's span.  get_current_triple is lenient
347        // and may fall back to a nearby triple (e.g. the first triple in the
348        // document) when the cursor is on @context or other non-triple content.
349        let triple_term_str = triple.and_then(|tc| {
350            let term_span = match tc.target {
351                TripleTarget::Subject => &tc.triple.subject.span,
352                TripleTarget::Predicate => &tc.triple.predicate.span,
353                TripleTarget::Object => &tc.triple.object.span,
354                TripleTarget::Graph => return None,
355            };
356            let cursor = token.source_span.start;
357            if term_span.start <= cursor && cursor <= term_span.end {
358                tc.term().map(|t| t.as_str())
359            } else {
360                None
361            }
362        });
363        let raw_token = token.text.as_str().trim_matches('"');
364        // When no triple term is available (e.g. cursor in @context), expand compact
365        // IRIs like `css:dist/...` using the document's active context so that
366        // resolve_iri_to_url can match them against import_paths.
367        let context_expanded = if triple_term_str.is_none() {
368            active_ctx.map(|ctx| expand_iri_with_context(&ctx.0, raw_token))
369        } else {
370            None
371        };
372        let st: &str = triple_term_str
373            .as_deref()
374            .or(context_expanded.as_deref())
375            .unwrap_or(raw_token);
376
377        tracing::debug!("Goto definition {:?} {}", triple_term_str, st,);
378
379        // Components: navigate to the component's own source file at the exact @id span.
380        let found_target = if let Some(component) = res.0.components.get(st) {
381            Some((component.source_file.as_str(), component.iri_span.clone()))
382        } else if let Some(module) = res.0.modules.get(st) {
383            Some((module.source_file.as_str(), module.iri_span.clone()))
384        } else if let Some((file, span)) = res.0.parameters.get(st) {
385            Some((file.as_str(), span.clone()))
386        } else {
387            None
388        };
389
390        tracing::debug!(
391            "CJS from {:?} {:?}",
392            found_target,
393            resolve_iri_to_url(st, &res.1.import_paths),
394        );
395        if let Some((file, span)) = found_target {
396            if let Ok(uri) = swls_core::lsp_types::Url::parse(file) {
397                let range = res
398                    .0
399                    .file_sources
400                    .get(file)
401                    .map(|src| span_to_lsp_range(src, &span))
402                    .unwrap_or_default();
403                req.0.push(Location { uri, range });
404                continue;
405            }
406        }
407
408        let iri_no_fragment = st.split('#').next().unwrap_or(st);
409        let resolved = resolve_iri_to_url(iri_no_fragment, &res.1.import_paths)
410            .or_else(|| res.1.context_urls.get(iri_no_fragment).cloned());
411        if let Some(t) = resolved {
412            tracing::debug!("target {}", t.as_str());
413            req.0.push(Location {
414                uri: t,
415                range: Range::default(),
416            });
417            continue;
418        }
419
420        if triple_term_str.is_none() {
421            // Import IRIs: strip any fragment, then resolve to a local file path.
422            let target = resolve_iri(&label.as_str(), st);
423            if let Ok(uri) = swls_core::lsp_types::Url::parse(&target) {
424                req.0.push(Location {
425                    uri,
426                    range: Range::default(),
427                });
428                continue;
429            }
430        }
431
432        tracing::debug!("goto_cjs: no definition found for '{}'", st);
433    }
434}
435
436mod fs {
437    use components_rs::{
438        error::{ComponentsJsError, Result},
439        fs::FsDirEntry,
440    };
441    use swls_core::{lsp_types::Url, prelude::Fs};
442
443    use crate::Registry;
444
445    pub struct LocalFs(Fs);
446
447    #[async_trait::async_trait]
448    impl components_rs::fs::Fs for LocalFs {
449        /// Read the entire contents of a file as a UTF-8 string.
450        async fn read_to_string(&self, url: &Url) -> Result<String> {
451            self.0
452                 .0
453                .read_file(&url)
454                .await
455                .ok_or(ComponentsJsError::General(format!(
456                    "Failed to read file {}",
457                    url.as_str()
458                )))
459        }
460
461        /// List the immediate children of a directory.
462        async fn read_dir(&self, path: &Url) -> Result<Vec<components_rs::fs::FsDirEntry>> {
463            let entries = self
464                .0
465                 .0
466                .read_dir(path)
467                .await
468                .ok_or(ComponentsJsError::General(format!(
469                    "Failed to read dir {:?}",
470                    path.as_str()
471                )))?;
472            Ok(entries
473                .into_iter()
474                .map(|entry| components_rs::fs::FsDirEntry {
475                    name: entry.name,
476                    path: entry.path,
477                    is_dir: entry.is_dir,
478                })
479                .collect())
480        }
481
482        /// Check whether `path` is a file.
483        async fn is_file(&self, path: &Url) -> bool {
484            self.0 .0.is_file(path).await
485        }
486
487        /// Check whether `path` is a directory.
488        async fn is_dir(&self, path: &Url) -> bool {
489            self.0 .0.is_dir(path).await
490        }
491
492        async fn glob(&self, base: &Url, pattern: &str) -> Result<Vec<FsDirEntry>> {
493            let entries = self
494                .0
495                 .0
496                .glob(base, pattern)
497                .await
498                .ok_or(ComponentsJsError::General(format!(
499                    "Failed to read dir {:?} {}",
500                    base.as_str(),
501                    pattern
502                )))?;
503            Ok(entries
504                .into_iter()
505                .map(|entry| components_rs::fs::FsDirEntry {
506                    name: entry.name,
507                    path: entry.path,
508                    is_dir: entry.is_dir,
509                })
510                .collect())
511        }
512    }
513
514    pub async fn build_registry(fs: &Fs, path: &Url) -> Result<Registry> {
515        use components_rs::components::registry::ComponentRegistry;
516        use components_rs::module_state::ModuleState;
517
518        let fs = LocalFs(fs.clone());
519        let state = ModuleState::build(&fs, path).await?;
520
521        let mut registry = ComponentRegistry::new();
522        registry.register_available_modules(&fs, &state).await?;
523        registry.finalize();
524
525        Ok(Registry(registry, state))
526    }
527}
528
529fn build_cjs_quads(registry: &ComponentRegistry) -> Vec<Quad> {
530    let rdf_type = NamedNode::new_unchecked("http://www.w3.org/1999/02/22-rdf-syntax-ns#type");
531    let rdfs_class = NamedNode::new_unchecked("http://www.w3.org/2000/01/rdf-schema#Class");
532    let rdfs_subclass_of =
533        NamedNode::new_unchecked("http://www.w3.org/2000/01/rdf-schema#subClassOf");
534    let rdfs_comment = NamedNode::new_unchecked("http://www.w3.org/2000/01/rdf-schema#comment");
535    let rdf_property =
536        NamedNode::new_unchecked("http://www.w3.org/1999/02/22-rdf-syntax-ns#Property");
537    let rdfs_domain = NamedNode::new_unchecked("http://www.w3.org/2000/01/rdf-schema#domain");
538    let rdfs_range = NamedNode::new_unchecked("http://www.w3.org/2000/01/rdf-schema#range");
539
540    let graph = GraphName::DefaultGraph;
541    let mut quads = Vec::new();
542
543    for comp in registry.components.values() {
544        let iri = NamedNode::new_unchecked(&comp.iri);
545
546        quads.push(Quad::new(
547            iri.clone(),
548            rdf_type.clone(),
549            rdfs_class.clone(),
550            graph.clone(),
551        ));
552
553        if let Some(comment) = &comp.comment {
554            quads.push(Quad::new(
555                iri.clone(),
556                rdfs_comment.clone(),
557                Literal::new_simple_literal(comment.as_str()),
558                graph.clone(),
559            ));
560        }
561
562        for parent in &comp.extends {
563            let parent_node = NamedNode::new_unchecked(parent);
564            quads.push(Quad::new(
565                iri.clone(),
566                rdfs_subclass_of.clone(),
567                parent_node,
568                graph.clone(),
569            ));
570        }
571
572        for param in &comp.parameters {
573            let param_iri = NamedNode::new_unchecked(&param.iri);
574
575            quads.push(Quad::new(
576                param_iri.clone(),
577                rdf_type.clone(),
578                rdf_property.clone(),
579                graph.clone(),
580            ));
581            quads.push(Quad::new(
582                param_iri.clone(),
583                rdfs_domain.clone(),
584                iri.clone(),
585                graph.clone(),
586            ));
587
588            if let Some(range) = &param.range {
589                let range_node = NamedNode::new_unchecked(range);
590                quads.push(Quad::new(
591                    param_iri.clone(),
592                    rdfs_range.clone(),
593                    range_node,
594                    graph.clone(),
595                ));
596            }
597
598            if let Some(comment) = &param.comment {
599                quads.push(Quad::new(
600                    param_iri.clone(),
601                    rdfs_comment.clone(),
602                    Literal::new_simple_literal(comment.as_str()),
603                    graph.clone(),
604                ));
605            }
606        }
607    }
608
609    quads
610}
611
612#[derive(Resource)]
613pub struct Registry(pub ComponentRegistry, pub ModuleState);
614impl Registry {
615    pub fn empty() -> Self {
616        Self(ComponentRegistry::new(), ModuleState::empty())
617    }
618}
619
620#[tracing::instrument(skip(fs, client, config, commands))]
621fn start_jsonld<C: Client + Resource + Clone>(
622    fs: Res<Fs>,
623    client: Res<C>,
624    config: Res<ServerConfig>,
625    commands: Res<CommandSender>,
626) {
627    if !config.config.jsonld.unwrap_or(true) {
628        return;
629    }
630    let fs = fs.clone();
631    tracing::debug!("loading CJS registry, config: {:?}", config);
632    if let Some(ws) = config.workspaces.first().and_then(|x| {
633        if x.uri.as_str().ends_with('/') {
634            Some(x.uri.clone())
635        } else {
636            Url::parse(&format!("{}/", x.uri.as_str())).ok()
637        }
638    }) {
639        tracing::debug!("CJS workspace root: {:?}", ws.as_str());
640        let commands = commands.clone();
641        let thing = async move {
642            tracing::debug!("Starting CJS registry build for {:?}", ws.as_str());
643            if let Ok(reg) = build_registry(&fs, &ws).await {
644                let mut command_queue = CommandQueue::default();
645                command_queue.push(move |world: &mut World| {
646                    let quads = build_cjs_quads(&reg.0);
647                    world.insert_resource(reg);
648
649                    let store_clone = world.get_resource::<swls_core::store::Store>();
650                    //
651                    if let Some(store) = store_clone {
652                        tracing::debug!("Derive store found adding {} triples", quads.len());
653                        let mut loader = store.0.bulk_loader();
654                        let _ = loader.load_quads(quads.into_iter());
655                        let _ = loader.commit();
656                    }
657
658                    let _ = world.run_system_once(derive_jsonld_triples::<C>);
659                });
660                let _ = commands.unbounded_send(command_queue);
661            }
662            ()
663        };
664        client.spawn(thing);
665    } else {
666        tracing::warn!("No workspace root found, skipping CJS registry build");
667    }
668}
669
670impl Lang for JsonLdLang {
671    type ElementError = TurtleParseError;
672
673    const LANG: &'static str = "jsonld";
674    const TRIGGERS: &'static [&'static str] = &["\"@", "\""];
675    const CODE_ACTION: bool = false;
676    const HOVER: bool = true;
677    const PATTERN: Option<&'static str> = None;
678
679    const LEGEND_TYPES: &'static [SemanticTokenType] = &[
680        semantic_token::BOOLEAN,
681        SemanticTokenType::COMMENT,
682        SemanticTokenType::ENUM_MEMBER,
683        SemanticTokenType::KEYWORD,
684        SemanticTokenType::NAMESPACE,
685        SemanticTokenType::NUMBER,
686        SemanticTokenType::PROPERTY,
687        SemanticTokenType::STRING,
688    ];
689
690    fn semantic_token_type(kind: rowan::SyntaxKind) -> Option<SemanticTokenType> {
691        use rdf_parsers::jsonld::parser::SyntaxKind as SK;
692        let k = kind.0;
693        if k == SK::Comment as u16 {
694            Some(SemanticTokenType::COMMENT)
695        } else if k == SK::StringToken as u16 {
696            Some(SemanticTokenType::STRING)
697        } else if k == SK::JsonNumber as u16 {
698            Some(SemanticTokenType::NUMBER)
699        } else if k == SK::TrueLit as u16 || k == SK::FalseLit as u16 || k == SK::NullLit as u16 {
700            Some(semantic_token::BOOLEAN)
701        } else {
702            None
703        }
704    }
705
706    fn semantic_token_spans(
707        kind: rowan::SyntaxKind,
708        span: std::ops::Range<usize>,
709        text: &str,
710    ) -> Vec<(SemanticTokenType, std::ops::Range<usize>)> {
711        // Keywords (`"@id"`, `"@type"`, …) are the one thing only the lexer knows.
712        if text.get(span.start + 1..span.start + 2) == Some("@") {
713            return vec![(SemanticTokenType::KEYWORD, span)];
714        }
715        // Everything term-shaped — compact/full IRIs and `@context` prefix keys —
716        // is coloured from the parsed model (`semantic_tokens` / `add_term`), which
717        // knows the prefix/local split and the resolved IRI. The lexer only sees an
718        // opaque JSON string, so it must not guess; it just supplies the STRING /
719        // NUMBER / BOOLEAN base for real literals, which the model overrides for the
720        // strings that are actually IRIs.
721        Self::semantic_token_type(kind)
722            .map(|t| vec![(t, span)])
723            .unwrap_or_default()
724    }
725}