1use crate::error::{RefactorError, Result};
22use crate::model::{FileChange, Hunk, RefactorPlan};
23use crate::source::read_source_text;
24use std::collections::{BTreeMap, BTreeSet, HashMap};
25use std::path::{Path, PathBuf};
26use strixonomy_catalog::OntologyCatalog;
27use strixonomy_core::{
28 document_lookup::{document_matches_ontology_id, normalize_iri},
29 OntologyFormat, ParseStatus,
30};
31
32pub fn expand_signature_locality(catalog: &OntologyCatalog, seeds: &[String]) -> Vec<String> {
34 let mut sigma: BTreeSet<String> = seeds.iter().cloned().collect();
35 let known: BTreeSet<&str> = catalog.data().entities.iter().map(|e| e.iri.as_str()).collect();
36
37 let mut changed = true;
38 while changed {
39 changed = false;
40 for axiom in &catalog.data().axioms {
41 let sig = axiom_named_signature(axiom, &known);
42 if sig.is_empty() {
43 continue;
44 }
45 let intersects = sig.iter().any(|iri| sigma.contains(iri));
46 let subset = sig.iter().all(|iri| sigma.contains(iri));
47 if subset {
48 continue;
50 }
51 if intersects {
52 for iri in sig {
53 if !known.contains(iri.as_str()) {
55 continue;
56 }
57 if sigma.insert(iri) {
58 changed = true;
59 }
60 }
61 }
62 }
63 }
64 sigma.into_iter().collect()
65}
66
67fn axiom_named_signature(
68 axiom: &strixonomy_core::Axiom,
69 known: &BTreeSet<&str>,
70) -> BTreeSet<String> {
71 let mut out = BTreeSet::new();
72 for candidate in [&axiom.subject, &axiom.object, &axiom.predicate] {
73 if is_builtin_iri(candidate) {
74 continue;
75 }
76 if known.contains(candidate.as_str()) || looks_like_absolute_iri(candidate) {
78 out.insert(candidate.clone());
79 }
80 }
81 out
82}
83
84fn looks_like_absolute_iri(s: &str) -> bool {
85 s.starts_with("http://") || s.starts_with("https://") || s.starts_with("urn:")
86}
87
88fn is_builtin_iri(iri: &str) -> bool {
89 iri.starts_with("http://www.w3.org/1999/02/22-rdf-syntax-ns#")
90 || iri.starts_with("http://www.w3.org/2000/01/rdf-schema#")
91 || iri.starts_with("http://www.w3.org/2002/07/owl#")
92 || iri.starts_with("http://www.w3.org/2001/XMLSchema#")
93 || iri == "http://www.w3.org/2002/07/owl#Thing"
94 || iri == "http://www.w3.org/2002/07/owl#Nothing"
95}
96
97fn require_path_in_workspace(path: &Path, workspace_roots: &[PathBuf]) -> Result<()> {
98 strixonomy_core::validate_workspace_scope_any(path, workspace_roots)
99 .map_err(RefactorError::Invalid)?;
100 Ok(())
101}
102
103fn canonical_path(path: &Path) -> PathBuf {
104 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
105}
106
107fn find_doc_by_path<'a>(
108 catalog: &'a OntologyCatalog,
109 path: &Path,
110) -> Option<&'a strixonomy_core::OntologyDocument> {
111 let canon = canonical_path(path);
112 catalog.data().documents.iter().find(|d| {
113 d.path == path || canonical_path(&d.path) == canon || canonical_path(&d.path) == path
114 })
115}
116
117fn resolve_import_doc<'a>(
118 catalog: &'a OntologyCatalog,
119 import_iri: &str,
120) -> Option<&'a strixonomy_core::OntologyDocument> {
121 let norm = normalize_iri(import_iri);
122 if let Some(doc) = catalog.data().documents.iter().find(|d| {
123 document_matches_ontology_id(import_iri, d)
124 || d.base_iri.as_ref().is_some_and(|b| normalize_iri(b) == norm || b == import_iri)
125 || normalize_iri(&d.id) == norm
126 || d.id == import_iri
127 }) {
128 return Some(doc);
129 }
130 resolve_import_via_xml_catalog(catalog, import_iri)
132}
133
134pub fn resolve_import_document<'a>(
136 catalog: &'a OntologyCatalog,
137 import_iri: &str,
138) -> Option<&'a strixonomy_core::OntologyDocument> {
139 resolve_import_doc(catalog, import_iri)
140}
141
142fn resolve_import_via_xml_catalog<'a>(
143 catalog: &'a OntologyCatalog,
144 import_iri: &str,
145) -> Option<&'a strixonomy_core::OntologyDocument> {
146 use std::collections::BTreeSet;
147 let mut roots = BTreeSet::new();
148 for doc in &catalog.data().documents {
149 if let Some(parent) = doc.path.parent() {
150 roots.insert(parent.to_path_buf());
151 if let Some(grand) = parent.parent() {
152 roots.insert(grand.to_path_buf());
153 }
154 }
155 }
156 for root in roots {
157 let Ok(xml) = strixonomy_catalog::load_workspace_xml_catalogs(&root) else {
158 continue;
159 };
160 if xml.uri_entries.is_empty() && xml.rewrite_entries.is_empty() {
161 continue;
162 }
163 let Some(target) = xml.resolve(import_iri) else {
164 continue;
165 };
166 if let Some(doc) = find_doc_by_path(catalog, &target) {
167 return Some(doc);
168 }
169 if let Some(name) = target.file_name() {
171 if let Some(doc) =
172 catalog.data().documents.iter().find(|d| d.path.file_name() == Some(name))
173 {
174 return Some(doc);
175 }
176 }
177 }
178 None
179}
180
181fn is_prefix_declaration_line(line: &str) -> bool {
182 let keyword = line.split_whitespace().next().unwrap_or("");
183 keyword.eq_ignore_ascii_case("@prefix") || keyword.eq_ignore_ascii_case("PREFIX")
184}
185
186fn prefix_declaration_name(line: &str) -> Option<&str> {
187 let mut parts = line.split_whitespace();
188 let keyword = parts.next()?;
189 if !(keyword.eq_ignore_ascii_case("@prefix") || keyword.eq_ignore_ascii_case("PREFIX")) {
190 return None;
191 }
192 parts.next()?.strip_suffix(':')
193}
194
195fn whole_file_change(path: PathBuf, original_text: String, preview_text: String) -> FileChange {
196 FileChange {
197 path,
198 hunks: vec![Hunk {
199 start_byte: 0,
200 end_byte: original_text.len() as u64,
201 old_text: original_text.clone(),
202 new_text: preview_text.clone(),
203 }],
204 preview_text,
205 original_text,
206 }
207}
208
209fn split_prefixes_and_body(text: &str) -> (Vec<String>, String) {
210 let mut prefixes = Vec::new();
211 let mut body_lines = Vec::new();
212 let mut in_prefix_header = true;
213 for line in text.lines() {
214 if in_prefix_header && (is_prefix_declaration_line(line) || line.trim().is_empty()) {
215 if is_prefix_declaration_line(line) {
216 prefixes.push(line.to_string());
217 }
218 continue;
219 }
220 in_prefix_header = false;
221 body_lines.push(line);
222 }
223 let body = body_lines.join("\n").trim().to_string();
224 (prefixes, body)
225}
226
227fn merge_missing_prefixes(target: &str, source_prefixes: &[String]) -> (String, Vec<String>) {
228 let target_names: BTreeSet<String> =
229 target.lines().filter_map(prefix_declaration_name).map(str::to_string).collect();
230 let mut missing = Vec::new();
231 for line in source_prefixes {
232 let Some(name) = prefix_declaration_name(line) else {
233 continue;
234 };
235 if !target_names.contains(name) {
236 missing.push(line.clone());
237 }
238 }
239 if missing.is_empty() {
240 return (target.to_string(), missing);
241 }
242 let mut out = String::new();
244 let mut inserted = false;
245 let mut saw_prefix = false;
246 for line in target.split_inclusive('\n') {
247 if is_prefix_declaration_line(line) {
248 saw_prefix = true;
249 out.push_str(line);
250 continue;
251 }
252 if saw_prefix && !inserted && !is_prefix_declaration_line(line) {
253 for p in &missing {
254 out.push_str(p);
255 if !p.ends_with('\n') {
256 out.push('\n');
257 }
258 }
259 out.push('\n');
260 inserted = true;
261 }
262 out.push_str(line);
263 }
264 if !inserted {
265 let mut header = missing.join("\n");
266 if !header.ends_with('\n') {
267 header.push('\n');
268 }
269 out = format!("{header}\n{target}");
270 }
271 (out, missing)
272}
273
274fn body_already_present(target: &str, body: &str) -> bool {
275 let trimmed = body.trim();
276 if trimmed.is_empty() {
277 return true;
278 }
279 target.contains(trimmed)
281}
282
283fn is_turtle_path(path: &Path) -> bool {
284 path.extension()
285 .and_then(|e| e.to_str())
286 .is_some_and(|e| e.eq_ignore_ascii_case("ttl") || e.eq_ignore_ascii_case("turtle"))
287}
288
289pub fn preview_merge_ontologies(
291 catalog: &OntologyCatalog,
292 source_paths: &[PathBuf],
293 target_file: &Path,
294 document_overrides: &HashMap<PathBuf, String>,
295 workspace_roots: &[PathBuf],
296) -> Result<RefactorPlan> {
297 require_path_in_workspace(target_file, workspace_roots)?;
298 if source_paths.is_empty() {
299 return Err(RefactorError::Invalid("no source ontology paths provided".to_string()));
300 }
301 for src in source_paths {
302 require_path_in_workspace(src, workspace_roots)?;
303 }
304
305 let target_canon = canonical_path(target_file);
306 let mut warnings = Vec::new();
307 let original = if target_file.exists() || document_overrides.contains_key(target_file) {
308 read_source_text(target_file, document_overrides)?
309 } else {
310 String::new()
311 };
312 let mut preview = original.clone();
313 let mut appended_entities = Vec::new();
314
315 for src in source_paths {
316 if canonical_path(src) == target_canon {
317 warnings.push(format!("skipping source equal to target: {}", src.display()));
318 continue;
319 }
320 if !is_turtle_path(src) {
321 warnings.push(format!("skipping non-Turtle source: {}", src.display()));
322 continue;
323 }
324 if let Some(doc) = find_doc_by_path(catalog, src) {
325 if doc.format != OntologyFormat::Turtle || doc.parse_status != ParseStatus::Ok {
326 warnings.push(format!("skipping non-Turtle or errored file: {}", src.display()));
327 continue;
328 }
329 }
330
331 let source_text = read_source_text(src, document_overrides)?;
332 let (prefixes, body) = split_prefixes_and_body(&source_text);
333 if body.is_empty() {
334 warnings.push(format!("empty body in source: {}", src.display()));
335 continue;
336 }
337 if body_already_present(&preview, &body) {
338 warnings.push(format!("source body already present in target: {}", src.display()));
339 continue;
340 }
341 let (with_prefixes, missing) = merge_missing_prefixes(&preview, &prefixes);
342 preview = with_prefixes;
343 if !missing.is_empty() {
344 warnings.push(format!(
345 "added {} missing @prefix declaration(s) from {}",
346 missing.len(),
347 src.display()
348 ));
349 }
350 if preview.trim().is_empty() {
351 preview = format!("{body}\n");
352 } else {
353 let mut next = preview.trim_end().to_string();
354 next.push_str("\n\n");
355 next.push_str(&body);
356 if !body.ends_with('\n') {
357 next.push('\n');
358 }
359 preview = next;
360 }
361 appended_entities.push(src.display().to_string());
362 }
363
364 if preview == original {
365 warnings.push("no ontology content merged into target".to_string());
366 }
367
368 let changes = if preview == original {
369 vec![]
370 } else {
371 vec![whole_file_change(target_file.to_path_buf(), original, preview)]
372 };
373
374 Ok(RefactorPlan { changes, warnings, ..Default::default() }.with_metrics(appended_entities))
375}
376
377fn collect_import_closure<'a>(
378 catalog: &'a OntologyCatalog,
379 root: &'a strixonomy_core::OntologyDocument,
380) -> (Vec<&'a strixonomy_core::OntologyDocument>, Vec<String>) {
381 let mut warnings = Vec::new();
382 let mut visited = BTreeSet::new();
383 let mut queue: Vec<String> = root.imports.clone();
384 let mut ordered = Vec::new();
385
386 visited.insert(canonical_path(&root.path));
387
388 while let Some(import_iri) = queue.pop() {
389 let Some(doc) = resolve_import_doc(catalog, &import_iri) else {
390 warnings.push(format!("unresolved import: {import_iri}"));
391 continue;
392 };
393 let canon = canonical_path(&doc.path);
394 if !visited.insert(canon) {
395 continue;
396 }
397 if doc.format != OntologyFormat::Turtle || doc.parse_status != ParseStatus::Ok {
398 warnings.push(format!(
399 "skipping non-Turtle or errored import: {} ({})",
400 import_iri,
401 doc.path.display()
402 ));
403 continue;
404 }
405 for child in &doc.imports {
406 queue.push(child.clone());
407 }
408 ordered.push(doc);
409 }
410 (ordered, warnings)
411}
412
413fn strip_owl_imports_lines(text: &str) -> (String, usize) {
414 let mut removed = 0usize;
415 let mut out = String::with_capacity(text.len());
416 for line in text.split_inclusive('\n') {
417 let trimmed = line.trim();
418 let is_import = trimmed.contains("owl:imports")
420 || trimmed.contains("owl#imports>")
421 || trimmed.to_ascii_lowercase().contains("owl:imports");
422 if is_import {
423 removed += 1;
424 continue;
425 }
426 out.push_str(line);
427 }
428 let cleaned = cleanup_ontology_block_after_import_strip(&out);
430 (cleaned, removed)
431}
432
433fn cleanup_ontology_block_after_import_strip(text: &str) -> String {
434 let mut result = text.to_string();
436 while result.contains(";\n.") || result.contains(";\r\n.") {
438 result = result.replace(";\n.", ".");
439 result = result.replace(";\r\n.", ".");
440 }
441 result = result.replace("a owl:Ontology ;\n\n", "a owl:Ontology .\n\n");
444 result = result.replace("a owl:Ontology ;\n", "a owl:Ontology .\n");
445 while result.contains("\n\n\n") {
446 result = result.replace("\n\n\n", "\n\n");
447 }
448 result
449}
450
451fn extract_import_iris_from_text(text: &str) -> Vec<String> {
452 let mut iris = Vec::new();
453 for line in text.lines() {
454 let trimmed = line.trim();
455 if !(trimmed.contains("owl:imports") || trimmed.contains("owl#imports>")) {
456 continue;
457 }
458 if let Some(start) = trimmed.rfind('<') {
460 if let Some(end) = trimmed[start + 1..].find('>') {
461 let iri = &trimmed[start + 1..start + 1 + end];
462 if !iri.is_empty() {
463 iris.push(iri.to_string());
464 }
465 }
466 }
467 }
468 iris
469}
470
471pub fn preview_flatten_imports(
473 catalog: &OntologyCatalog,
474 ontology_file: &Path,
475 document_overrides: &HashMap<PathBuf, String>,
476 workspace_roots: &[PathBuf],
477) -> Result<RefactorPlan> {
478 require_path_in_workspace(ontology_file, workspace_roots)?;
479 let doc = find_doc_by_path(catalog, ontology_file).ok_or_else(|| {
480 RefactorError::Invalid(format!("ontology file not in catalog: {}", ontology_file.display()))
481 })?;
482 if doc.format != OntologyFormat::Turtle || doc.parse_status != ParseStatus::Ok {
483 return Err(RefactorError::UnsupportedFormat(doc.format.as_str().to_string()));
484 }
485
486 let original = read_source_text(&doc.path, document_overrides)?;
487 let mut warnings = Vec::new();
488 let (imports, mut import_warnings) = collect_import_closure(catalog, doc);
489 warnings.append(&mut import_warnings);
490
491 if imports.is_empty() && doc.imports.is_empty() {
492 warnings.push("no owl:imports to flatten".to_string());
493 return Ok(RefactorPlan { changes: vec![], warnings, ..Default::default() }
494 .with_metrics(Vec::<String>::new()));
495 }
496
497 let (mut preview, removed) = strip_owl_imports_lines(&original);
498 if removed == 0 && !doc.imports.is_empty() {
499 warnings.push(
500 "catalog lists imports but no owl:imports lines matched; left stub warnings".into(),
501 );
502 for iri in &doc.imports {
503 warnings.push(format!("# TODO: flatten unresolved/unmatched import {iri}"));
504 }
505 }
506
507 for imported in imports {
508 let source_text = read_source_text(&imported.path, document_overrides)?;
509 let (prefixes, body) = split_prefixes_and_body(&source_text);
510 if body.is_empty() {
511 continue;
512 }
513 if body_already_present(&preview, &body) {
514 warnings.push(format!(
515 "imported body already present, skipped: {}",
516 imported.path.display()
517 ));
518 continue;
519 }
520 let (with_prefixes, missing) = merge_missing_prefixes(&preview, &prefixes);
521 preview = with_prefixes;
522 if !missing.is_empty() {
523 warnings.push(format!(
524 "added {} missing @prefix declaration(s) from {}",
525 missing.len(),
526 imported.path.display()
527 ));
528 }
529 let mut next = preview.trim_end().to_string();
530 next.push_str("\n\n");
531 next.push_str("# inlined from ");
532 next.push_str(&imported.path.display().to_string());
533 next.push('\n');
534 next.push_str(&body);
535 if !body.ends_with('\n') {
536 next.push('\n');
537 }
538 preview = next;
539 }
540
541 if preview == original {
542 warnings.push("flatten produced no textual changes".to_string());
543 return Ok(RefactorPlan { changes: vec![], warnings, ..Default::default() }
544 .with_metrics(Vec::<String>::new()));
545 }
546
547 Ok(RefactorPlan {
548 changes: vec![whole_file_change(doc.path.clone(), original, preview)],
549 warnings,
550 ..Default::default()
551 }
552 .with_metrics(doc.imports.clone()))
553}
554
555fn entity_iris_for_document(
556 catalog: &OntologyCatalog,
557 doc: &strixonomy_core::OntologyDocument,
558) -> Vec<String> {
559 catalog
560 .data()
561 .entities
562 .iter()
563 .filter(|e| {
564 e.ontology_id == doc.id
565 || doc
566 .base_iri
567 .as_ref()
568 .is_some_and(|b| normalize_iri(b) == normalize_iri(&e.ontology_id))
569 || catalog
570 .entity_document(&e.iri)
571 .is_some_and(|d| canonical_path(&d.path) == canonical_path(&doc.path))
572 })
573 .map(|e| e.iri.clone())
574 .collect()
575}
576
577fn text_references_any_entity(
578 text: &str,
579 entity_iris: &[String],
580 namespaces: &BTreeMap<String, String>,
581) -> bool {
582 for iri in entity_iris {
583 if text.contains(iri) {
584 return true;
585 }
586 let short = strixonomy_owl::short_name_from_iri(iri);
587 for (prefix, ns) in namespaces {
588 if iri.starts_with(ns.as_str()) {
589 let curie = if prefix.is_empty() {
590 format!(":{short}")
591 } else {
592 format!("{prefix}:{short}")
593 };
594 if text.contains(&curie) {
595 return true;
596 }
597 }
598 }
599 if text.contains(&format!("<{iri}>")) {
601 return true;
602 }
603 }
604 false
605}
606
607fn remove_import_lines_for_iri(text: &str, import_iri: &str) -> String {
608 let mut out = String::with_capacity(text.len());
609 for line in text.split_inclusive('\n') {
610 if line.contains(import_iri)
611 && (line.contains("owl:imports") || line.contains("owl#imports>"))
612 {
613 continue;
614 }
615 out.push_str(line);
616 }
617 cleanup_ontology_block_after_import_strip(&out)
618}
619
620pub fn preview_cleanup_imports(
622 catalog: &OntologyCatalog,
623 ontology_file: &Path,
624 document_overrides: &HashMap<PathBuf, String>,
625 workspace_roots: &[PathBuf],
626) -> Result<RefactorPlan> {
627 require_path_in_workspace(ontology_file, workspace_roots)?;
628 let doc = find_doc_by_path(catalog, ontology_file).ok_or_else(|| {
629 RefactorError::Invalid(format!("ontology file not in catalog: {}", ontology_file.display()))
630 })?;
631 if doc.format != OntologyFormat::Turtle || doc.parse_status != ParseStatus::Ok {
632 return Err(RefactorError::UnsupportedFormat(doc.format.as_str().to_string()));
633 }
634
635 let original = read_source_text(&doc.path, document_overrides)?;
636 let namespaces = strixonomy_owl::namespaces_for_text(&original, &doc.namespaces);
637 let mut warnings = Vec::new();
638 let mut preview = original.clone();
639 let mut removed_iris = Vec::new();
640
641 let mut import_iris: BTreeSet<String> = doc.imports.iter().cloned().collect();
642 for iri in extract_import_iris_from_text(&original) {
643 import_iris.insert(iri);
644 }
645
646 for import_iri in import_iris {
647 let Some(imported) = resolve_import_doc(catalog, &import_iri) else {
648 warnings.push(format!("leaving unresolved import: {import_iri}"));
649 continue;
650 };
651 if imported.format != OntologyFormat::Turtle {
652 warnings.push(format!("skipping non-Turtle import for cleanup: {import_iri}"));
653 continue;
654 }
655 let entities = entity_iris_for_document(catalog, imported);
656 let without_this_import = remove_import_lines_for_iri(&preview, &import_iri);
658 if entities.is_empty() {
659 warnings.push(format!(
661 "imported ontology has no indexed entities; removing unused import {import_iri}"
662 ));
663 preview = without_this_import;
664 removed_iris.push(import_iri);
665 continue;
666 }
667 if text_references_any_entity(&without_this_import, &entities, &namespaces) {
668 continue;
669 }
670 preview = without_this_import;
671 removed_iris.push(import_iri);
672 }
673
674 if preview == original {
675 warnings.push("no unused owl:imports found".to_string());
676 return Ok(RefactorPlan { changes: vec![], warnings, ..Default::default() }
677 .with_metrics(Vec::<String>::new()));
678 }
679
680 if !removed_iris.is_empty() {
681 warnings.push(format!("removed {} unused owl:imports", removed_iris.len()));
682 }
683
684 Ok(RefactorPlan {
685 changes: vec![whole_file_change(doc.path.clone(), original, preview)],
686 warnings,
687 ..Default::default()
688 }
689 .with_metrics(removed_iris))
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695
696 #[test]
697 fn is_builtin_recognizes_owl_rdf() {
698 assert!(is_builtin_iri("http://www.w3.org/2002/07/owl#Class"));
699 assert!(is_builtin_iri("http://www.w3.org/2000/01/rdf-schema#subClassOf"));
700 assert!(!is_builtin_iri("http://example.org#Person"));
701 }
702
703 #[test]
704 fn strip_owl_imports_removes_lines() {
705 let text = concat!(
706 "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n",
707 "<http://example.org/root> a owl:Ontology ;\n",
708 " owl:imports <http://example.org/lib> .\n",
709 "ex:A a owl:Class .\n"
710 );
711 let (out, n) = strip_owl_imports_lines(text);
712 assert_eq!(n, 1);
713 assert!(!out.contains("owl:imports"));
714 assert!(out.contains("ex:A"));
715 }
716}