Skip to main content

swls_lang_rdf_base/
lib.rs

1pub mod code_actions;
2pub mod parse;
3pub mod rename;
4pub mod traits;
5pub mod triples;
6
7use bevy_ecs::{
8    component::Component, event::EntityEvent, observer::On, prelude::Resource, system::Commands,
9    world::World,
10};
11use swls_core::{
12    feature::{
13        diagnostics::publish_diagnostics,
14        semantic::{basic_semantic_tokens, semantic_tokens_system},
15    },
16    lang::{Lang, LangHelper},
17    prelude::*,
18    CreateEvent,
19};
20
21/// Register a language with the ECS world, handling all `setup_world()` boilerplate:
22/// - Semantic token type registration in `SemanticTokensDict`
23/// - `CreateEvent` observer for file detection by `language_id` or extension
24/// - Diagnostics system scheduling
25/// - Basic semantic highlighting scheduling
26///
27/// Call language-specific setup (parsing, completion, code actions, etc.) after this function.
28///
29/// # Type parameters
30/// - `L`: The language marker component (e.g. `TurtleLang`). Must implement `Default`.
31/// - `H`: The language helper (e.g. `TurtleHelper`). Must implement `Default`.
32pub fn register_rdf_lang<L, H>(
33    world: &mut World,
34    language_id: &'static [&'static str],
35    extensions: &'static [&'static str],
36) where
37    L: Lang + Component + Default + Send + Sync + 'static,
38    L::ElementError: 'static + Clone,
39    H: LangHelper + Default + Send + Sync + 'static,
40{
41    ensure_shared_rdf_systems(world);
42
43    let mut semantic_token_dict = world.resource_mut::<SemanticTokensDict>();
44    L::LEGEND_TYPES.iter().for_each(|lt| {
45        if !semantic_token_dict.contains_key(lt) {
46            let l = semantic_token_dict.0.len();
47            semantic_token_dict.insert(lt.clone(), l);
48        }
49    });
50
51    world.add_observer(move |trigger: On<CreateEvent>, mut commands: Commands| {
52        let e = trigger.event();
53        let matches = trigger
54            .language_id
55            .as_ref()
56            .map(|lang_id| language_id.iter().any(|x| x == lang_id))
57            .unwrap_or_default()
58            || extensions.iter().any(|ext| e.url.as_str().ends_with(ext));
59        if matches {
60            commands
61                .entity(e.event_target())
62                .insert(L::default())
63                .insert(DynLang(Box::new(H::default())));
64        }
65    });
66
67    world.schedule_scope(swls_core::feature::ParseLabel, |_, schedule| {
68        use bevy_ecs::schedule::IntoScheduleConfigs;
69        schedule.add_systems(publish_diagnostics::<L>.after(swls_core::feature::parse::end));
70    });
71
72    world.schedule_scope(swls_core::feature::SemanticLabel, |_, schedule| {
73        use bevy_ecs::schedule::IntoScheduleConfigs;
74        schedule.add_systems((
75            basic_semantic_tokens::<L>.before(semantic_tokens_system),
76            semantic_tokens::<L>
77                .after(basic_semantic_tokens::<L>)
78                .before(semantic_tokens_system),
79        ));
80    });
81}
82
83#[derive(Resource)]
84struct SharedRdfSystemsRegistered;
85
86/// Register the shared, [`DynLang`]-gated systems that apply to *some* RDF
87/// languages but not all — model-based rename and the blank-node refactor code
88/// actions — exactly once, no matter how many languages are installed.
89///
90/// Instead of each language opting in with a `setup_*::<L>` call, every system
91/// asks the document's [`LangHelper`] at runtime whether it applies (via
92/// [`LangHelper::model_based_rename`] / [`LangHelper::blank_node_code_actions`]).
93/// Adding a language therefore means overriding those capability methods in its
94/// helper — there is no scheduling wiring to keep in sync.
95fn ensure_shared_rdf_systems(world: &mut World) {
96    if world.contains_resource::<SharedRdfSystemsRegistered>() {
97        return;
98    }
99    world.insert_resource(SharedRdfSystemsRegistered);
100
101    world.schedule_scope(swls_core::feature::code_action::Label, |_, schedule| {
102        schedule.add_systems((
103            code_actions::extract_blank_node,
104            code_actions::inline_blank_node,
105        ));
106    });
107    world.schedule_scope(swls_core::feature::rename::PrepareRename, |_, schedule| {
108        schedule.add_systems(rename::prepare_rename);
109    });
110    world.schedule_scope(swls_core::feature::rename::Rename, |_, schedule| {
111        schedule.add_systems(rename::rename);
112    });
113}
114
115pub use tokens::semantic_tokens;
116mod tokens {
117    use bevy_ecs::prelude::*;
118    use rdf_parsers::model::{BlankNode, Literal, NamedNode, RDFLiteral, Term};
119    use swls_core::lsp_types::SemanticTokenType;
120    use swls_core::prelude::semantic::*;
121    use swls_core::prelude::*;
122
123    /// True when `span` covers a JSON-LD node object (`{ … }`) rather than a
124    /// plain term.  Objects with an `@id` become a [`NamedNode`] whose span is
125    /// the whole `{ … }`; stamping that would wipe the inner coloring.
126    fn span_is_nested_object(span: &std::ops::Range<usize>, source: &str) -> bool {
127        source
128            .get(span.clone())
129            .map(|s| s.trim_start().starts_with('{'))
130            .unwrap_or(false)
131    }
132
133    fn add_term(
134        term: &Spanned<Term>,
135        ttc: &mut TokenTypesComponent,
136        kind: SemanticTokenType,
137        source: &str,
138    ) {
139        // A JSON-LD keyword surfaces in the model as a term over its own token —
140        // e.g. `@type` becomes the `rdf:type` predicate spanning `"@type"`. Leave
141        // it with the KEYWORD colour the lexer gave it instead of overwriting it.
142        if source
143            .get(term.span().clone())
144            .is_some_and(|s| s.trim_start_matches('"').starts_with('@'))
145        {
146            return;
147        }
148        match term.value() {
149            Term::NamedNode(NamedNode::Prefixed {
150                prefix, value, idx, ..
151            }) => {
152                let start = *idx;
153                if source.as_bytes().get(start) == Some(&b'"') && !value.is_empty() {
154                    // JSON-LD writes a compact IRI as a JSON string: `"ex:obs1"`.
155                    // The lexer coloured the whole token STRING; recolour the whole
156                    // quoted token as the term (quotes included, like the Full-IRI
157                    // arm) and stamp `prefix:` NAMESPACE on top. `idx` is the opening
158                    // quote — even for an object reference nested in a `{ "@id": … }`
159                    // object it points at the inner `"ex:obs1"`, not the `{`.
160                    let sep = start + prefix.len() + 2; // one past the ':'
161                    let close = sep + value.len(); // closing quote
162                    ttc.push(spanned(kind, start..close + 1));
163                    ttc.push(spanned(SemanticTokenType::NAMESPACE, start + 1..sep));
164                } else if source.as_bytes().get(start) == Some(&b'"') {
165                    // Whole-term compact IRI stored as `Prefixed(term, "")`: no
166                    // prefix/local split — colour the whole quoted token as the term.
167                    ttc.push(spanned(kind, term.span().clone()));
168                } else {
169                    // Text syntaxes (Turtle/TriG/SPARQL): the CST pass already
170                    // coloured `prefix:`; only stamp the local part here.
171                    let sep = start + prefix.len() + 1; // one past the ':'
172                    ttc.push(spanned(kind, sep..sep + value.len()));
173                }
174            }
175            Term::Variable(_) | Term::NamedNode(_) => {
176                // A JSON-LD node object with an @id becomes a NamedNode whose
177                // span covers the entire nested { } object.  Stamping it would
178                // wipe the inner coloring (same reasoning as anonymous blank
179                // nodes below); the @id is already colored as the subject of the
180                // inner triples.
181                if span_is_nested_object(term.span(), source) {
182                    return;
183                }
184                ttc.push(spanned(kind, term.span().clone()));
185            }
186            // Named blank nodes (_:label) get their coloring from the CST pass
187            // (NAMESPACE+PROPERTY via BlankNodeLabel token kind in Turtle).  For
188            // JSON-LD, anonymous blank nodes have a Spanned span that covers the
189            // entire nested { } object, so stamping ENUM_MEMBER here would wipe
190            // out all inner coloring — inner triples handle their own content.
191            Term::BlankNode(BlankNode::Named(_, _)) => return,
192            Term::BlankNode(BlankNode::Unnamed(pos, _, _)) => {
193                for po in pos {
194                    for o in &po.object {
195                        add_term(o, ttc, kind.clone(), source);
196                    }
197                }
198            }
199            Term::Collection(spanneds) => {
200                for e in spanneds {
201                    add_term(e, ttc, kind.clone(), source);
202                }
203            }
204            Term::Literal(Literal::RDF(RDFLiteral { ty: Some(la), .. })) => {
205                let dt = la.as_ref().map(|x| Term::NamedNode(x.clone()));
206                add_term(&dt, ttc, kind, source);
207            }
208            _ => return,
209        }
210    }
211
212    pub fn semantic_tokens<L: Component>(
213        query: Query<
214            (&Element, &Source, &mut TokenTypesComponent),
215            (With<L>, With<HighlightRequest>),
216        >,
217    ) {
218        for (turtle, source, mut ttc) in query {
219            let source = source.0.as_str();
220            // `@context` prefix / term definitions: colour each declared name
221            // NAMESPACE from the model. Gated to JSON-LD's quoted keys (`"ex"`);
222            // text syntaxes (Turtle/TriG/SPARQL) already colour their prefix
223            // declarations in the CST pass and must not be re-stamped here.
224            for p in &turtle.prefixes {
225                let span = p.value().prefix.span();
226                if source.as_bytes().get(span.start) == Some(&b'"') {
227                    ttc.push(spanned(SemanticTokenType::NAMESPACE, span.clone()));
228                }
229            }
230            for t in &turtle.triples {
231                add_term(&t.subject, &mut ttc, SemanticTokenType::ENUM_MEMBER, source);
232                for po in &t.po {
233                    add_term(
234                        &po.predicate,
235                        &mut ttc,
236                        SemanticTokenType::ENUM_MEMBER,
237                        source,
238                    );
239                    for o in &po.object {
240                        add_term(o, &mut ttc, SemanticTokenType::ENUM_MEMBER, source);
241                    }
242                }
243            }
244        }
245    }
246
247    #[cfg(test)]
248    mod tests {
249        use super::*;
250
251        fn full(iri: &str, offset: usize, span: std::ops::Range<usize>) -> Spanned<Term> {
252            spanned(Term::NamedNode(NamedNode::Full(iri.into(), offset)), span)
253        }
254
255        // A JSON-LD node object with @id is a NamedNode whose span covers the
256        // whole `{ … }`; it must NOT be stamped (it would wipe inner coloring).
257        // A plain IRI reference of the same node must still be stamped.
258        #[test]
259        fn nested_named_object_is_skipped() {
260            let source = r#"{ "@id": "http://ex/x", "name": "n" }"#;
261            let mut ttc: TokenTypesComponent = Wrapped(Vec::new());
262            // Whole-object span (0..source.len()) — the conformsTo-style object.
263            add_term(
264                &full("http://ex/x", 9, 0..source.len()),
265                &mut ttc,
266                SemanticTokenType::ENUM_MEMBER,
267                source,
268            );
269            assert!(ttc.0.is_empty(), "nested {{ }} object should be skipped");
270
271            // The @id reference itself (just the IRI token) must be stamped.
272            let id_src = r#""http://ex/x""#;
273            add_term(
274                &full("http://ex/x", 0, 0..id_src.len()),
275                &mut ttc,
276                SemanticTokenType::ENUM_MEMBER,
277                id_src,
278            );
279            assert_eq!(ttc.0.len(), 1, "plain IRI reference should be stamped");
280        }
281    }
282}