Skip to main content

strixonomy_diagnostics/
location.rs

1use std::collections::BTreeMap;
2use strixonomy_core::SourceLocation;
3
4pub fn find_in_source(source_text: &str, needles: &[String]) -> SourceLocation {
5    for (line_idx, line) in source_text.lines().enumerate() {
6        for needle in needles {
7            if let Some(col) = find_token_col(line, needle) {
8                return SourceLocation::at_line_col((line_idx + 1) as u64, col as u64);
9            }
10        }
11    }
12    SourceLocation::default()
13}
14
15/// Locate an import IRI, preferring `owl:imports` lines over earlier comment/string hits.
16pub fn find_import_in_source(source_text: &str, import_iri: &str) -> SourceLocation {
17    let needles = [import_iri.to_string(), format!("<{import_iri}>")];
18    for (line_idx, line) in source_text.lines().enumerate() {
19        let lower = line.to_ascii_lowercase();
20        if !lower.contains("owl:imports") {
21            continue;
22        }
23        for needle in &needles {
24            if let Some(col) = find_token_col(line, needle) {
25                return SourceLocation::at_line_col((line_idx + 1) as u64, col as u64);
26            }
27        }
28    }
29    find_in_source(source_text, &needles)
30}
31
32pub fn find_prefix_in_source(source_text: &str, prefix: &str) -> SourceLocation {
33    let needles = vec![
34        format!("@prefix {prefix}:"),
35        format!("@PREFIX {prefix}:"),
36        format!("PREFIX {prefix}:"),
37        format!("{prefix}:"),
38    ];
39    find_in_source(source_text, &needles)
40}
41
42pub fn entity_needles(
43    iri: &str,
44    short_name: &str,
45    namespaces: &BTreeMap<String, String>,
46) -> Vec<String> {
47    let mut needles = vec![iri.to_string(), format!("<{iri}>")];
48    for (prefix, ns) in namespaces {
49        if iri.starts_with(ns) && !prefix.is_empty() {
50            needles.push(format!("{prefix}:{short_name}"));
51        }
52    }
53    // Bare `:LocalName` last — it is a substring of `{prefix}:{short_name}`.
54    needles.push(format!(":{short_name}"));
55    needles
56}
57
58fn find_token_col(line: &str, needle: &str) -> Option<usize> {
59    if needle.is_empty() {
60        return None;
61    }
62    let mut start = 0usize;
63    while let Some(rel) = line[start..].find(needle) {
64        let col = start + rel;
65        if is_token_match_at(line, needle, col) {
66            return Some(col);
67        }
68        start = col + 1;
69    }
70    None
71}
72
73/// True when `needle` at `col` in `line` is a standalone Turtle token (not a substring).
74fn is_token_match_at(line: &str, needle: &str, col: usize) -> bool {
75    if !line[col..].starts_with(needle) {
76        return false;
77    }
78    is_safe_replacement_boundary(line, col, col + needle.len())
79}
80
81fn is_iri_continuation(b: u8) -> bool {
82    b.is_ascii_alphanumeric()
83        || matches!(b, b'_' | b'-' | b'.' | b'~' | b':' | b'#' | b'/' | b'%' | b'?' | b'=' | b'&')
84}
85
86fn is_safe_replacement_boundary(text: &str, start: usize, end: usize) -> bool {
87    let before = text.as_bytes().get(start.wrapping_sub(1)).copied();
88    let after = text.as_bytes().get(end).copied();
89    let ok_before = before.is_none_or(|b| !is_iri_continuation(b));
90    let ok_after = after.is_none_or(|b| !is_iri_continuation(b));
91    ok_before && ok_after
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use std::collections::BTreeMap;
98
99    #[test]
100    fn entity_needles_prefixed_match_before_bare_local() {
101        let mut namespaces = BTreeMap::new();
102        namespaces.insert("ex".to_string(), "http://example.org/ex#".to_string());
103        let needles = entity_needles("http://example.org/ex#Person", "Person", &namespaces);
104        let line = "ex:Person a owl:Class .";
105        let loc = find_in_source(line, &needles);
106        assert_eq!(loc.line, Some(1));
107        assert_eq!(loc.column, Some(0));
108    }
109
110    #[test]
111    fn entity_needles_bare_local_still_matches_default_prefix() {
112        let namespaces = BTreeMap::new();
113        let needles = entity_needles("http://example.org/ex#Person", "Person", &namespaces);
114        let line = ":Person a owl:Class .";
115        let loc = find_in_source(line, &needles);
116        assert_eq!(loc.line, Some(1));
117        assert_eq!(loc.column, Some(0));
118    }
119
120    #[test]
121    fn find_in_source_skips_substring_inside_longer_qname() {
122        let mut namespaces = BTreeMap::new();
123        namespaces.insert("ex".to_string(), "http://example.org/org#".to_string());
124        let needles = entity_needles("http://example.org/org#Person", "Person", &namespaces);
125        let text = "ex:PersonType a owl:Class .\nex:Person a owl:Class .\n";
126        let loc = find_in_source(text, &needles);
127        assert_eq!(loc.line, Some(2));
128        assert_eq!(loc.column, Some(0));
129    }
130
131    #[test]
132    fn find_import_prefers_owl_imports_over_earlier_comment() {
133        let iri = "http://example.org/missing";
134        let text = format!(
135            "# see <{iri}>\n@prefix owl: <http://www.w3.org/2002/07/owl#> .\n<> owl:imports <{iri}> .\n"
136        );
137        let loc = find_import_in_source(&text, iri);
138        assert_eq!(loc.line, Some(3));
139    }
140}