Skip to main content

strixonomy_owl/
span.rs

1use crate::turtle_lex::{advance_turtle_scan, is_in_comment_or_string_at, TurtleScanState};
2use std::collections::BTreeMap;
3use strixonomy_core::{Annotation, Axiom, Entity, SourceLocation};
4
5pub fn short_name_from_iri(iri: &str) -> String {
6    if let Some((_, name)) = iri.rsplit_once('#') {
7        return name.to_string();
8    }
9    if let Some((_, name)) = iri.rsplit_once('/') {
10        return name.to_string();
11    }
12    iri.to_string()
13}
14
15/// Parse Turtle/SPARQL prefix declarations (`@prefix`, `@PREFIX`, `PREFIX`; case-insensitive).
16pub fn prefixes_from_turtle(source_text: &str) -> BTreeMap<String, String> {
17    let mut map = BTreeMap::new();
18    for line in source_text.lines() {
19        let trimmed = line.trim();
20        if trimmed.is_empty() || trimmed.starts_with('#') {
21            continue;
22        }
23        let mut parts = trimmed.split_whitespace();
24        let Some(keyword) = parts.next() else {
25            continue;
26        };
27        if !(keyword.eq_ignore_ascii_case("@prefix") || keyword.eq_ignore_ascii_case("PREFIX")) {
28            continue;
29        }
30        let Some(prefix_raw) = parts.next() else { continue };
31        let Some(uri_raw) = parts.next() else { continue };
32        let prefix = prefix_raw.trim_end_matches(':').to_string();
33        if let Some(uri) = parse_turtle_prefix_iri(uri_raw) {
34            map.insert(prefix, uri);
35        }
36    }
37    map
38}
39
40fn parse_turtle_prefix_iri(token: &str) -> Option<String> {
41    let token = token.trim_end_matches('.');
42    if !token.starts_with('<') || !token.ends_with('>') {
43        return None;
44    }
45    let inner = &token[1..token.len() - 1];
46    if inner.is_empty() || inner.contains('<') || inner.contains('>') {
47        return None;
48    }
49    Some(inner.to_string())
50}
51
52pub fn namespaces_for_text(
53    source_text: &str,
54    declared: &BTreeMap<String, String>,
55) -> BTreeMap<String, String> {
56    let mut merged = declared.clone();
57    merged.extend(prefixes_from_turtle(source_text));
58    merged
59}
60
61fn subject_needles(
62    iri: &str,
63    _short_name: &str,
64    namespaces: &BTreeMap<String, String>,
65) -> Vec<String> {
66    let mut needles = vec![format!("<{iri}>")];
67    // Default prefix (`:Local`) — only when the empty prefix is bound and the suffix is PN_LOCAL.
68    if let Some(default_ns) = namespaces.get("") {
69        if iri.starts_with(default_ns.as_str()) {
70            let local = &iri[default_ns.len()..];
71            if crate::patch::is_valid_pn_local(local) {
72                needles.push(format!(":{local}"));
73            }
74        }
75    }
76    // Prefixed CURIE — longest namespace match + valid PN_LOCAL (same rules as write-back).
77    if let Some((prefix, ns)) = crate::patch::best_namespace_match(iri, namespaces) {
78        let local = &iri[ns.len()..];
79        if crate::patch::is_valid_pn_local(local) {
80            needles.push(format!("{prefix}:{local}"));
81        }
82    }
83    needles
84}
85
86/// Byte offset immediately after a line's content (handles `\n` and `\r\n`).
87fn line_byte_offset_after(text: &str, line_start: usize, line: &str) -> usize {
88    let after_content = line_start + line.len();
89    let bytes = text.as_bytes();
90    if bytes.get(after_content) == Some(&b'\r') && bytes.get(after_content + 1) == Some(&b'\n') {
91        after_content + 2
92    } else if bytes.get(after_content) == Some(&b'\n') {
93        after_content + 1
94    } else {
95        after_content
96    }
97}
98
99/// Locate the byte range of an entity's subject token (first Turtle statement for that subject).
100#[allow(dead_code)]
101pub fn find_subject_statement(
102    source_text: &str,
103    iri: &str,
104    short_name: &str,
105    namespaces: &BTreeMap<String, String>,
106) -> Option<ByteRange> {
107    all_subject_statements(source_text, iri, short_name, namespaces).into_iter().next()
108}
109
110/// All statement start positions for a subject IRI in document order.
111pub fn all_subject_statements(
112    source_text: &str,
113    iri: &str,
114    short_name: &str,
115    namespaces: &BTreeMap<String, String>,
116) -> Vec<ByteRange> {
117    let needles = subject_needles(iri, short_name, namespaces);
118    let mut byte_offset = 0usize;
119    let mut out = Vec::new();
120    for line in source_text.lines() {
121        let trimmed = line.trim_start();
122        if trimmed.starts_with('@') {
123            byte_offset = line_byte_offset_after(source_text, byte_offset, line);
124            continue;
125        }
126        if let Some(col_in_trimmed) = subject_column_at_line_start(trimmed, &needles) {
127            let ws = line.len() - trimmed.len();
128            let start = byte_offset + ws + col_in_trimmed;
129            out.push(ByteRange { start: start as u64, end: start as u64 });
130        }
131        byte_offset = line_byte_offset_after(source_text, byte_offset, line);
132    }
133    out
134}
135
136/// Full byte ranges for every Turtle statement whose subject is `iri`.
137pub fn all_entity_statement_ranges(
138    source_text: &str,
139    iri: &str,
140    short_name: &str,
141    namespaces: &BTreeMap<String, String>,
142) -> Vec<ByteRange> {
143    all_subject_statements(source_text, iri, short_name, namespaces)
144        .into_iter()
145        .filter_map(|start_range| {
146            let end = statement_end_byte(source_text, start_range.start as usize)?;
147            if end > start_range.start as usize {
148                Some(ByteRange { start: start_range.start, end: end as u64 })
149            } else {
150                None
151            }
152        })
153        .collect()
154}
155
156/// Prefer the subject statement that declares a type (`a` / `rdf:type`), e.g. `ex:Foo a owl:Class`.
157fn primary_entity_statement_start(
158    source_text: &str,
159    iri: &str,
160    short_name: &str,
161    namespaces: &BTreeMap<String, String>,
162) -> Option<ByteRange> {
163    let starts = all_subject_statements(source_text, iri, short_name, namespaces);
164    for start in &starts {
165        if let Some(end) = statement_end_byte(source_text, start.start as usize) {
166            let stmt = &source_text[start.start as usize..end];
167            if crate::turtle_lex::statement_has_type_predicate(stmt) {
168                return Some(*start);
169            }
170        }
171    }
172    starts.first().copied()
173}
174
175/// True when `needle` is the subject at the start of a trimmed Turtle line.
176fn subject_column_at_line_start(trimmed: &str, needles: &[String]) -> Option<usize> {
177    for needle in needles {
178        if !trimmed.starts_with(needle) {
179            continue;
180        }
181        let rest = &trimmed[needle.len()..];
182        if rest.is_empty() || rest.starts_with(|c: char| c.is_whitespace() || c == ';' || c == '.')
183        {
184            return Some(0);
185        }
186    }
187    None
188}
189
190/// Locate the primary entity block (type declaration) in Turtle source.
191pub fn find_entity_block(
192    source_text: &str,
193    iri: &str,
194    short_name: &str,
195    namespaces: &BTreeMap<String, String>,
196) -> SourceLocation {
197    if let Some(range) = primary_entity_statement_start(source_text, iri, short_name, namespaces) {
198        let start = range.start as usize;
199        let line_idx = source_text[..start].matches('\n').count();
200        let line_start = source_text[..start].rfind('\n').map(|p| p + 1).unwrap_or(0);
201        let col = start - line_start;
202        return SourceLocation {
203            line: Some((line_idx + 1) as u64),
204            column: Some(col as u64),
205            start_byte: Some(range.start),
206            end_byte: None,
207        };
208    }
209    SourceLocation::default()
210}
211
212/// Fill byte spans for labels, comments, and subclass triples.
213pub fn annotate_spans(
214    source_text: &str,
215    entities: &mut [Entity],
216    annotations: &mut [Annotation],
217    axioms: &mut [Axiom],
218) {
219    for entity in entities.iter_mut() {
220        if let Some(block) = entity_block_range(source_text, entity, &BTreeMap::new()) {
221            if entity.source_location.end_byte.is_none() {
222                entity.source_location.end_byte = Some(block.end);
223            }
224        }
225    }
226
227    for ann in annotations.iter_mut() {
228        if let Some(span) = find_predicate_literal_span(
229            source_text,
230            &ann.subject,
231            predicate_local_name(&ann.predicate),
232            &ann.object,
233        ) {
234            ann.source_location = span;
235        }
236    }
237
238    for axiom in axioms.iter_mut() {
239        if axiom.axiom_kind == strixonomy_core::AXIOM_KIND_SUB_CLASS_OF {
240            if let Some(span) = find_subclass_span(source_text, &axiom.subject, &axiom.object) {
241                axiom.source_location = span;
242            }
243        }
244    }
245}
246
247pub fn entity_block_range(
248    source_text: &str,
249    entity: &Entity,
250    declared_namespaces: &BTreeMap<String, String>,
251) -> Option<ByteRange> {
252    let namespaces = namespaces_for_text(source_text, declared_namespaces);
253    let start = if let Some(s) = entity.source_location.start_byte {
254        s as usize
255    } else {
256        primary_entity_statement_start(source_text, &entity.iri, &entity.short_name, &namespaces)
257            .map(|r| r.start as usize)?
258    };
259    let end = statement_end_byte(source_text, start)?;
260    if end > start {
261        Some(ByteRange { start: start as u64, end: end as u64 })
262    } else {
263        None
264    }
265}
266
267/// Primary declaration block for patch insertions (type statement, not trailing triples).
268pub fn entity_primary_block_range(
269    source_text: &str,
270    entity_iri: &str,
271    namespaces: &BTreeMap<String, String>,
272) -> Option<ByteRange> {
273    let short_name = short_name_from_iri(entity_iri);
274    let start = primary_entity_statement_start(source_text, entity_iri, &short_name, namespaces)?;
275    let end = statement_end_byte(source_text, start.start as usize)?;
276    if end > start.start as usize {
277        Some(ByteRange { start: start.start, end: end as u64 })
278    } else {
279        None
280    }
281}
282
283/// True when `b` can appear inside a Turtle PN_LOCAL / prefixed-name token.
284pub(crate) fn is_turtle_name_char(b: u8) -> bool {
285    b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b':' | b'~' | b'%' | b'\\' | b'.')
286}
287
288/// True when `.` at `i` is a statement/object terminator, not part of PN_LOCAL or an IRI.
289///
290/// Turtle allows `.` inside local names (`ex:foo.bar`) and absolute IRIs (`<http://a.b/c>`).
291/// A terminating `.` is never both preceded and followed by name characters.
292pub(crate) fn is_turtle_terminating_dot(bytes: &[u8], i: usize) -> bool {
293    if bytes.get(i) != Some(&b'.') {
294        return false;
295    }
296    let prev_name = i > 0 && is_turtle_name_char(bytes[i - 1]) && bytes[i - 1] != b'.';
297    let next_name = bytes.get(i + 1).is_some_and(|b| is_turtle_name_char(*b) && *b != b'.');
298    !(prev_name && next_name)
299}
300
301/// True when `byte_offset` lies inside a `#` line comment or any Turtle string literal.
302///
303/// Handles `"…"`, `'…'`, `"""…"""`, and `'''…'''` forms (same lexer rules as
304/// [`statement_end_byte`]).
305pub fn is_in_comment_or_string(text: &str, byte_offset: usize) -> bool {
306    is_in_comment_or_string_at(text, byte_offset)
307}
308
309/// Walk from subject start to the terminating `.` of this statement.
310///
311/// Tracks `"…"`, `'…'`, `"""…"""`, `'''…'''` strings, IRIs (`<...>`), `#` line comments,
312/// brackets, and parens. Does not treat `.` inside IRIs, comments, strings, or PN_LOCAL
313/// names (`ex:foo.bar`) as terminators.
314pub(crate) fn statement_end_byte(source_text: &str, start: usize) -> Option<usize> {
315    let bytes = source_text.as_bytes();
316    if start >= bytes.len() {
317        return None;
318    }
319    let mut i = start;
320    let mut bracket_depth = 0i32;
321    let mut paren_depth = 0i32;
322    let mut state = TurtleScanState::default();
323
324    while i < bytes.len() {
325        if state.in_comment {
326            if bytes[i] == b'\n' {
327                state.in_comment = false;
328            }
329            i += 1;
330            continue;
331        }
332        if state.in_string() || state.in_iri {
333            i = advance_turtle_scan(bytes, i, &mut state);
334            continue;
335        }
336
337        if bytes.get(i..i + 3) == Some(br#"""""#) {
338            state.string_kind = Some(crate::turtle_lex::TurtleStringKind::LongDouble);
339            i += 3;
340            continue;
341        }
342        if bytes.get(i..i + 3) == Some(br"'''") {
343            state.string_kind = Some(crate::turtle_lex::TurtleStringKind::LongSingle);
344            i += 3;
345            continue;
346        }
347
348        let b = bytes[i];
349        match b {
350            b'#' => {
351                state.in_comment = true;
352                i += 1;
353            }
354            b'"' => {
355                state.string_kind = Some(crate::turtle_lex::TurtleStringKind::ShortDouble);
356                i += 1;
357            }
358            b'\'' => {
359                state.string_kind = Some(crate::turtle_lex::TurtleStringKind::ShortSingle);
360                i += 1;
361            }
362            b'<' => {
363                state.in_iri = true;
364                i += 1;
365            }
366            b'[' => {
367                bracket_depth += 1;
368                i += 1;
369            }
370            b']' => {
371                bracket_depth = bracket_depth.saturating_sub(1);
372                i += 1;
373            }
374            b'(' => {
375                paren_depth += 1;
376                i += 1;
377            }
378            b')' => {
379                paren_depth = paren_depth.saturating_sub(1);
380                i += 1;
381            }
382            b'.' if bracket_depth == 0
383                && paren_depth == 0
384                && is_turtle_terminating_dot(bytes, i) =>
385            {
386                return Some(i + 1);
387            }
388            _ => i += 1,
389        }
390    }
391    None
392}
393
394#[derive(Debug, Clone, Copy)]
395pub struct ByteRange {
396    pub start: u64,
397    pub end: u64,
398}
399
400fn line_start_byte(source_text: &str, line_idx: usize) -> u64 {
401    let mut offset = 0usize;
402    for (i, line) in source_text.lines().enumerate() {
403        if i == line_idx {
404            return offset as u64;
405        }
406        offset = line_byte_offset_after(source_text, offset, line);
407    }
408    0
409}
410
411fn predicate_local_name(predicate: &str) -> &str {
412    if predicate.contains("label") {
413        "label"
414    } else if predicate.contains("comment") {
415        "comment"
416    } else if predicate.contains("subClassOf") {
417        "subClassOf"
418    } else {
419        predicate
420    }
421}
422
423fn find_predicate_literal_span(
424    source_text: &str,
425    subject: &str,
426    predicate: &str,
427    object: &str,
428) -> Option<SourceLocation> {
429    let object_needle = object.trim_matches('"');
430    for (line_idx, line) in source_text.lines().enumerate() {
431        if !line.contains(subject) && !line.contains(&short_name_from_iri(subject)) {
432            continue;
433        }
434        if line.contains(predicate) && line.contains(object_needle) {
435            let col = line.find(predicate).unwrap_or(0);
436            let start_byte = line_start_byte(source_text, line_idx) + col as u64;
437            return Some(SourceLocation {
438                line: Some((line_idx + 1) as u64),
439                column: Some(col as u64),
440                start_byte: Some(start_byte),
441                end_byte: Some(start_byte + line.len() as u64),
442            });
443        }
444    }
445    None
446}
447
448fn find_subclass_span(source_text: &str, subject: &str, parent: &str) -> Option<SourceLocation> {
449    let parent_short = short_name_from_iri(parent);
450    for (line_idx, line) in source_text.lines().enumerate() {
451        if !line.contains("subClassOf") {
452            continue;
453        }
454        if (line.contains(subject) || line.contains(&short_name_from_iri(subject)))
455            && (line.contains(parent) || line.contains(&parent_short))
456        {
457            let start_byte = line_start_byte(source_text, line_idx);
458            return Some(SourceLocation {
459                line: Some((line_idx + 1) as u64),
460                column: Some(0),
461                start_byte: Some(start_byte),
462                end_byte: Some(start_byte + line.len() as u64),
463            });
464        }
465    }
466    None
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use strixonomy_core::{Entity, EntityKind};
473
474    fn ex_ns() -> BTreeMap<String, String> {
475        BTreeMap::from([
476            ("ex".into(), "http://example.org/people#".into()),
477            ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
478        ])
479    }
480
481    fn clinic_ns() -> BTreeMap<String, String> {
482        BTreeMap::from([("ex".into(), "http://example.org/clinic#".into())])
483    }
484
485    #[test]
486    fn finds_entity_line() {
487        let ttl = include_str!("../../../fixtures/example.ttl");
488        let loc = find_entity_block(ttl, "http://example.org/people#Person", "Person", &ex_ns());
489        assert!(loc.line.is_some());
490        let line = ttl.lines().nth(loc.line.unwrap() as usize - 1).unwrap();
491        assert!(line.contains("ex:Person a owl:Class"));
492    }
493
494    #[test]
495    fn subject_not_domain_mention() {
496        let ttl = include_str!("../../../fixtures/example.ttl");
497        let range =
498            find_subject_statement(ttl, "http://example.org/people#Person", "Person", &ex_ns())
499                .expect("subject");
500        let line = ttl[..range.start as usize].rfind('\n').map(|p| p + 1).unwrap_or(0);
501        let subject_line = &ttl[line..];
502        assert!(subject_line.starts_with("ex:Person a"));
503    }
504
505    #[test]
506    fn patient_block_includes_multiline_restriction() {
507        let ttl = include_str!("../../../fixtures/complex-classes.ttl");
508        let entity = Entity {
509            iri: "http://example.org/clinic#Patient".into(),
510            short_name: "Patient".into(),
511            kind: EntityKind::Class,
512            ontology_id: String::new(),
513            source_location: find_entity_block(
514                ttl,
515                "http://example.org/clinic#Patient",
516                "Patient",
517                &clinic_ns(),
518            ),
519            labels: vec![],
520            comments: vec![],
521            deprecated: false,
522            obo_id: None,
523            ..Default::default()
524        };
525        let range = entity_block_range(ttl, &entity, &clinic_ns()).expect("block");
526        let block = &ttl[range.start as usize..range.end as usize];
527        assert!(block.contains("owl:Restriction"));
528        assert!(block.contains("owl:someValuesFrom"));
529        assert!(block.trim_end().ends_with('.'));
530    }
531
532    #[test]
533    fn add_label_targets_class_block_not_trailing_triple() {
534        use crate::patch::{apply_patches_to_text, PatchOp};
535        let ttl = include_str!("../../../fixtures/example.ttl");
536        let patches = vec![PatchOp::AddLabel {
537            entity_iri: "http://example.org/people#Person".into(),
538            value: "Human".into(),
539        }];
540        let result = apply_patches_to_text(ttl, &patches, true, &ex_ns()).expect("patch");
541        let preview = result.preview_text.expect("preview");
542        let person_block_start = preview.find("ex:Person a owl:Class").expect("class decl");
543        let human_pos = preview.find("Human").expect("label");
544        assert!(human_pos > person_block_start);
545        let trailing = preview.find("ex:Person rdfs:subClassOf ex:Thing");
546        if let Some(t) = trailing {
547            assert!(human_pos < t, "label must be in class block, not trailing triple");
548        }
549    }
550
551    #[test]
552    fn statement_end_respects_dots_in_absolute_iris() {
553        let ttl = "<http://example.org/people#Person> a owl:Class ;\n    rdfs:label \"Person\" .\n";
554        let end = statement_end_byte(ttl, 0).expect("end");
555        let block = &ttl[..end];
556        assert!(block.contains("rdfs:label"));
557        assert!(block.trim_end().ends_with('.'));
558        assert!(!block.ends_with("example."));
559    }
560
561    #[test]
562    fn statement_end_respects_dots_in_comments() {
563        let ttl = "ex:Person a owl:Class ; # see docs.\n    rdfs:label \"Person\" .\n";
564        let end = statement_end_byte(ttl, 0).expect("end");
565        let block = &ttl[..end];
566        assert!(block.contains("rdfs:label"));
567        assert!(block.contains("# see docs."));
568    }
569
570    #[test]
571    fn statement_end_respects_dots_in_local_names() {
572        let ttl = "ex:foo.bar a owl:Class .\n";
573        let end = statement_end_byte(ttl, 0).expect("end");
574        assert_eq!(&ttl[..end], "ex:foo.bar a owl:Class .");
575    }
576
577    #[test]
578    fn is_in_comment_or_string_detects_all_turtle_string_forms() {
579        let double = "rdfs:label \"http://example.org#Person\" .";
580        let person_in_double = double.find("http://example.org#Person").unwrap();
581        assert!(is_in_comment_or_string(double, person_in_double));
582
583        let single = "rdfs:comment 'http://example.org#Person' .";
584        let person_in_single = single.find("http://example.org#Person").unwrap();
585        assert!(is_in_comment_or_string(single, person_in_single));
586
587        let long_double = "rdfs:comment \"\"\"http://example.org#Person\"\"\" .";
588        let person_in_long_double = long_double.find("http://example.org#Person").unwrap();
589        assert!(is_in_comment_or_string(long_double, person_in_long_double));
590
591        let long_single = "rdfs:comment '''http://example.org#Person''' .";
592        let person_in_long_single = long_single.find("http://example.org#Person").unwrap();
593        assert!(is_in_comment_or_string(long_single, person_in_long_single));
594
595        let outside = "ex:Person a owl:Class .";
596        let person_outside = outside.find("ex:Person").unwrap();
597        assert!(!is_in_comment_or_string(outside, person_outside));
598    }
599
600    #[test]
601    fn statement_end_respects_dots_in_long_strings() {
602        let ttl = "ex:Person a owl:Class ;\n    rdfs:comment '''See Dr. Smith.''' .\n";
603        let end = statement_end_byte(ttl, 0).expect("end");
604        let block = &ttl[..end];
605        assert!(block.contains("Dr. Smith"));
606        assert!(block.trim_end().ends_with('.'));
607    }
608
609    #[test]
610    fn subject_needles_use_longest_namespace_local_not_short_name() {
611        let ns = BTreeMap::from([("ex".into(), "http://example.org/".into())]);
612        let iri = "http://example.org/foo/Person";
613        let needles = subject_needles(iri, "Person", &ns);
614        assert!(needles.contains(&format!("<{iri}>")));
615        assert!(
616            !needles.iter().any(|n| n == "ex:Person"),
617            "must not emit CURIE that expands to a different IRI: {needles:?}"
618        );
619        // `foo/Person` is not a valid PN_LOCAL, so no prefixed needle.
620        assert!(!needles.iter().any(|n| n.starts_with("ex:")));
621    }
622
623    #[test]
624    fn subject_needles_prefer_longest_prefix_match() {
625        let ns = BTreeMap::from([
626            ("ex".into(), "http://example.org/".into()),
627            ("foo".into(), "http://example.org/foo/".into()),
628        ]);
629        let iri = "http://example.org/foo/Person";
630        let needles = subject_needles(iri, "Person", &ns);
631        assert!(needles.contains(&"foo:Person".to_string()));
632        assert!(
633            !needles.iter().any(|n| n == "ex:Person"),
634            "shorter prefix must not produce a colliding CURIE: {needles:?}"
635        );
636    }
637
638    #[test]
639    fn find_subject_does_not_match_wrong_curie_under_short_prefix() {
640        let ttl = r#"@prefix ex: <http://example.org/> .
641@prefix owl: <http://www.w3.org/2002/07/owl#> .
642
643ex:Person a owl:Class .
644<http://example.org/foo/Person> a owl:Class .
645"#;
646        let ns = BTreeMap::from([
647            ("ex".into(), "http://example.org/".into()),
648            ("owl".into(), "http://www.w3.org/2002/07/owl#".into()),
649        ]);
650        let range = find_subject_statement(ttl, "http://example.org/foo/Person", "Person", &ns)
651            .expect("nested path subject");
652        let line_start = ttl[..range.start as usize].rfind('\n').map(|p| p + 1).unwrap_or(0);
653        let subject_line = &ttl[line_start..];
654        assert!(
655            subject_line.trim_start().starts_with("<http://example.org/foo/Person>"),
656            "must locate the absolute IRI subject, not ex:Person: {subject_line}"
657        );
658    }
659
660    #[test]
661    fn statement_end_includes_single_quoted_label() {
662        let ttl = r#"ex:Person a owl:Class ;
663    rdfs:label 'Human' ."#;
664        let start = ttl.find("ex:Person").unwrap();
665        let end = statement_end_byte(ttl, start).expect("end");
666        assert!(ttl[start..end].contains("'Human'"));
667    }
668
669    #[test]
670    fn statement_end_respects_escaped_triple_quotes_in_long_string() {
671        let ttl = r#"@prefix ex: <http://example.org/> .
672@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
673ex:A a owl:Class ;
674  rdfs:comment """contains \""" quote""" .
675"#;
676        let start = ttl.find("ex:A").unwrap();
677        let end = statement_end_byte(ttl, start).expect("end");
678        assert!(ttl[start..end].contains(r#"\""" quote"""#));
679    }
680
681    #[test]
682    fn prefixes_from_turtle_accepts_prefix_keyword() {
683        let ttl = "PREFIX ex: <http://example.org/people#>\n";
684        let map = prefixes_from_turtle(ttl);
685        assert_eq!(map.get("ex"), Some(&"http://example.org/people#".to_string()));
686    }
687
688    #[test]
689    fn prefixes_from_turtle_accepts_uppercase_at_prefix() {
690        let ttl = "@PREFIX owl: <http://www.w3.org/2002/07/owl#> .\n";
691        let map = prefixes_from_turtle(ttl);
692        assert_eq!(map.get("owl"), Some(&"http://www.w3.org/2002/07/owl#".to_string()));
693    }
694
695    #[test]
696    fn prefixes_from_turtle_scans_after_prefix_form_and_default_prefix() {
697        let ttl = concat!(
698            "PREFIX ex: <http://example.org/people#>\n",
699            "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n",
700            "@prefix : <http://example.org/org#> .\n",
701            ":Person a owl:Class .\n"
702        );
703        let map = prefixes_from_turtle(ttl);
704        assert_eq!(map.get("ex"), Some(&"http://example.org/people#".to_string()));
705        assert_eq!(map.get("owl"), Some(&"http://www.w3.org/2002/07/owl#".to_string()));
706        assert_eq!(map.get(""), Some(&"http://example.org/org#".to_string()));
707    }
708}