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