1use crate::error::{RefactorError, Result};
2use crate::model::{FileChange, Hunk, RefactorPlan};
3use crate::source::read_source_text;
4use crate::text::{normalize_namespace_base, remap_iri, replace_iri_in_text};
5use ontocore_catalog::OntologyCatalog;
6use ontocore_core::{validate_workspace_scope_any, EntityKind, OntologyFormat, ParseStatus};
7use std::collections::{BTreeMap, BTreeSet, HashMap};
8use std::path::{Path, PathBuf};
9
10pub fn preview_rename_iri(
11 catalog: &OntologyCatalog,
12 from_iri: &str,
13 to_iri: &str,
14 document_overrides: &HashMap<PathBuf, String>,
15) -> Result<RefactorPlan> {
16 if from_iri == to_iri {
17 return Err(RefactorError::Invalid("from and to IRI must differ".to_string()));
18 }
19 if catalog.find_entity(from_iri).is_none()
20 && find_usages_in_catalog(catalog, from_iri, document_overrides).is_empty()
21 {
22 return Err(RefactorError::EntityNotFound(from_iri.to_string()));
23 }
24
25 let mut file_changes: BTreeMap<PathBuf, FileChange> = BTreeMap::new();
26 let mut warnings = Vec::new();
27 let from_obo = catalog.find_entity(from_iri).and_then(|e| e.obo_id.clone());
28 let to_obo = catalog.find_entity(to_iri).and_then(|e| e.obo_id.clone()).or_else(|| {
29 short_obo_id_from_iri(to_iri).or_else(|| {
31 from_obo.as_ref().map(|_| {
32 short_obo_id_from_iri(to_iri).unwrap_or_else(|| {
33 to_iri.rsplit(['#', '/']).next().unwrap_or(to_iri).to_string()
34 })
35 })
36 })
37 });
38
39 for doc in &catalog.data().documents {
40 if doc.parse_status != ParseStatus::Ok {
41 if text_contains_iri(doc, from_iri, document_overrides) {
42 warnings.push(format!("skipping errored file: {}", doc.path.display()));
43 }
44 continue;
45 }
46 let original = read_source_text(&doc.path, document_overrides)?;
47 let preview = match doc.format {
48 OntologyFormat::Turtle => {
49 if !original.contains(from_iri)
50 && !contains_prefixed_ref(&original, from_iri, &doc.namespaces)
51 {
52 continue;
53 }
54 let (mut preview_text, raw_hunks) =
55 replace_iri_in_text(&original, from_iri, to_iri, &doc.namespaces);
56 let (swrl_text, swrl_hits) =
57 ontocore_swrl::rewrite_swrl_iris_in_turtle(&preview_text, from_iri, to_iri);
58 if swrl_hits > 0 {
59 preview_text = swrl_text;
60 warnings.push(format!(
61 "rewrote {swrl_hits} SWRL rule(s) in {}",
62 doc.path.display()
63 ));
64 }
65 if preview_text == original {
66 continue;
67 }
68 let hunks: Vec<Hunk> = raw_hunks
69 .into_iter()
70 .map(|(start, end, old_text, new_text)| Hunk {
71 start_byte: start as u64,
72 end_byte: end as u64,
73 old_text,
74 new_text,
75 })
76 .collect();
77 file_changes.insert(
78 doc.path.clone(),
79 FileChange {
80 path: doc.path.clone(),
81 preview_text,
82 original_text: original,
83 hunks,
84 },
85 );
86 continue;
87 }
88 OntologyFormat::Owl | OntologyFormat::RdfXml => {
89 if !original.contains(from_iri) {
90 continue;
91 }
92 match ontocore_owl::remap_entity_iri_in_xml_text(
93 &original,
94 "rdfxml",
95 from_iri,
96 to_iri,
97 &doc.namespaces,
98 ) {
99 Ok(text) => text,
100 Err(e) => {
101 warnings.push(format!("skipping RDF/XML {}: {e}", doc.path.display()));
102 continue;
103 }
104 }
105 }
106 OntologyFormat::OwlXml => {
107 if !original.contains(from_iri) {
108 continue;
109 }
110 match ontocore_owl::remap_entity_iri_in_xml_text(
111 &original,
112 "owlxml",
113 from_iri,
114 to_iri,
115 &doc.namespaces,
116 ) {
117 Ok(text) => text,
118 Err(e) => {
119 warnings.push(format!("skipping OWL/XML {}: {e}", doc.path.display()));
120 continue;
121 }
122 }
123 }
124 OntologyFormat::Obo => {
125 let from_id =
126 from_obo.clone().or_else(|| short_obo_id_from_iri(from_iri)).unwrap_or_else(
127 || from_iri.rsplit(['#', '/']).next().unwrap_or(from_iri).to_string(),
128 );
129 let to_id =
130 to_obo.clone().or_else(|| short_obo_id_from_iri(to_iri)).unwrap_or_else(|| {
131 to_iri.rsplit(['#', '/']).next().unwrap_or(to_iri).to_string()
132 });
133 if !original.contains(&from_id) {
134 continue;
135 }
136 match ontocore_obo::remap_obo_id_in_text(&original, &from_id, &to_id) {
137 Ok(text) => text,
138 Err(e) => {
139 warnings.push(format!("skipping OBO {}: {e}", doc.path.display()));
140 continue;
141 }
142 }
143 }
144 other => {
145 if text_contains_iri(doc, from_iri, document_overrides) {
146 warnings.push(format!(
147 "skipping unsupported format {} in {}",
148 other.as_str(),
149 doc.path.display()
150 ));
151 }
152 continue;
153 }
154 };
155
156 if preview == original {
157 continue;
158 }
159 file_changes
160 .insert(doc.path.clone(), whole_file_change(doc.path.clone(), original, preview));
161 }
162
163 if file_changes.is_empty() {
164 warnings.push(format!("no files changed for IRI {from_iri}"));
165 }
166
167 Ok(RefactorPlan {
168 changes: file_changes.into_values().collect(),
169 warnings,
170 ..Default::default()
171 }
172 .with_metrics([from_iri, to_iri]))
173}
174
175fn short_obo_id_from_iri(iri: &str) -> Option<String> {
176 let local = iri.rsplit(['#', '/']).next()?;
178 if local.contains('_') && !local.contains(':') {
179 let (ns, id) = local.split_once('_')?;
180 return Some(format!("{ns}:{id}"));
181 }
182 if local.contains(':') {
183 return Some(local.to_string());
184 }
185 None
186}
187
188pub fn preview_merge_entities(
189 catalog: &OntologyCatalog,
190 keep_iri: &str,
191 merge_iri: &str,
192 document_overrides: &HashMap<PathBuf, String>,
193) -> Result<RefactorPlan> {
194 if keep_iri == merge_iri {
195 return Err(RefactorError::Invalid("keep and merge IRI must differ".to_string()));
196 }
197
198 let mut warnings = Vec::new();
199 if catalog.find_entity(keep_iri).is_none() {
200 warnings.push(format!("keep entity not found: {keep_iri}"));
201 }
202 if catalog.find_entity(merge_iri).is_none() {
203 warnings.push(format!("merge entity not found: {merge_iri}"));
204 }
205
206 let mut changes = BTreeMap::new();
207 for doc in &catalog.data().documents {
208 if doc.parse_status != ParseStatus::Ok {
209 if text_contains_iri(doc, merge_iri, document_overrides) {
210 warnings.push(format!("skipping errored file: {}", doc.path.display()));
211 }
212 continue;
213 }
214
215 let original = read_source_text(&doc.path, document_overrides)?;
216 let preview_text = match doc.format {
217 OntologyFormat::Turtle => {
218 if !original.contains(merge_iri)
219 && !contains_prefixed_ref(&original, merge_iri, &doc.namespaces)
220 {
221 continue;
222 }
223 let namespaces = ontocore_owl::namespaces_for_text(&original, &doc.namespaces);
224 let short = ontocore_owl::short_name_from_iri(merge_iri);
225 let mut declaration_ranges = ontocore_owl::all_entity_statement_ranges(
226 &original,
227 merge_iri,
228 &short,
229 &namespaces,
230 );
231 declaration_ranges.sort_by_key(|range| std::cmp::Reverse(range.start));
232
233 let mut without_merge_declaration = original.clone();
234 for range in declaration_ranges {
235 without_merge_declaration
236 .replace_range(range.start as usize..range.end as usize, "");
237 }
238 let (mut preview_text, _) = replace_iri_in_text(
239 &without_merge_declaration,
240 merge_iri,
241 keep_iri,
242 &doc.namespaces,
243 );
244 let (swrl_text, swrl_hits) =
245 ontocore_swrl::rewrite_swrl_iris_in_turtle(&preview_text, merge_iri, keep_iri);
246 if swrl_hits > 0 {
247 preview_text = swrl_text;
248 warnings.push(format!(
249 "rewrote {swrl_hits} SWRL rule(s) in {}",
250 doc.path.display()
251 ));
252 }
253 preview_text
254 }
255 OntologyFormat::Owl | OntologyFormat::RdfXml | OntologyFormat::OwlXml => {
256 if !original.contains(merge_iri) {
257 continue;
258 }
259 let fmt = if doc.format == OntologyFormat::OwlXml { "owlxml" } else { "rdfxml" };
260 match ontocore_owl::remap_entity_iri_in_xml_text(
261 &original,
262 fmt,
263 merge_iri,
264 keep_iri,
265 &doc.namespaces,
266 ) {
267 Ok(text) => text,
268 Err(e) => {
269 warnings.push(format!("skipping {} {}: {e}", fmt, doc.path.display()));
270 continue;
271 }
272 }
273 }
274 OntologyFormat::Obo => {
275 let from_id = catalog
276 .find_entity(merge_iri)
277 .and_then(|e| e.obo_id.clone())
278 .or_else(|| short_obo_id_from_iri(merge_iri))
279 .unwrap_or_else(|| {
280 merge_iri.rsplit(['#', '/']).next().unwrap_or(merge_iri).to_string()
281 });
282 let to_id = catalog
283 .find_entity(keep_iri)
284 .and_then(|e| e.obo_id.clone())
285 .or_else(|| short_obo_id_from_iri(keep_iri))
286 .unwrap_or_else(|| {
287 keep_iri.rsplit(['#', '/']).next().unwrap_or(keep_iri).to_string()
288 });
289 if !original.contains(&from_id) {
290 continue;
291 }
292 match ontocore_obo::remap_obo_id_in_text(&original, &from_id, &to_id) {
293 Ok(text) => text,
294 Err(e) => {
295 warnings.push(format!("skipping OBO {}: {e}", doc.path.display()));
296 continue;
297 }
298 }
299 }
300 other => {
301 if text_contains_iri(doc, merge_iri, document_overrides) {
302 warnings.push(format!(
303 "skipping unsupported format {} in {}",
304 other.as_str(),
305 doc.path.display()
306 ));
307 }
308 continue;
309 }
310 };
311
312 if preview_text == original {
313 continue;
314 }
315
316 changes
317 .insert(doc.path.clone(), whole_file_change(doc.path.clone(), original, preview_text));
318 }
319
320 if changes.is_empty() {
321 warnings.push(format!("no files changed for entity merge {merge_iri} -> {keep_iri}"));
322 }
323
324 Ok(RefactorPlan { changes: changes.into_values().collect(), warnings, ..Default::default() }
325 .with_metrics([keep_iri, merge_iri]))
326}
327
328pub fn preview_replace_entity(
329 catalog: &OntologyCatalog,
330 from_iri: &str,
331 to_iri: &str,
332 document_overrides: &HashMap<PathBuf, String>,
333) -> Result<RefactorPlan> {
334 if catalog.find_entity(to_iri).is_none() {
335 return preview_rename_iri(catalog, from_iri, to_iri, document_overrides);
336 }
337 if from_iri == to_iri {
338 return Err(RefactorError::Invalid("from and to IRI must differ".to_string()));
339 }
340 if catalog.find_entity(from_iri).is_none()
341 && find_usages_in_catalog(catalog, from_iri, document_overrides).is_empty()
342 {
343 return Err(RefactorError::EntityNotFound(from_iri.to_string()));
344 }
345
346 let mut changes = BTreeMap::new();
347 let mut warnings = Vec::new();
348 for doc in &catalog.data().documents {
349 if doc.parse_status != ParseStatus::Ok {
350 if text_contains_iri(doc, from_iri, document_overrides) {
351 warnings.push(format!("skipping errored file: {}", doc.path.display()));
352 }
353 continue;
354 }
355
356 let original = read_source_text(&doc.path, document_overrides)?;
357 let preview_text = match doc.format {
358 OntologyFormat::Turtle => {
359 if !original.contains(from_iri)
360 && !contains_prefixed_ref(&original, from_iri, &doc.namespaces)
361 {
362 continue;
363 }
364 let namespaces = ontocore_owl::namespaces_for_text(&original, &doc.namespaces);
365 let declaration_start =
366 ontocore_owl::entity_primary_block_range(&original, from_iri, &namespaces)
367 .map(|range| range.start as usize);
368 let mut preview_text = replace_iri_preserving_subject(
369 &original,
370 from_iri,
371 to_iri,
372 &doc.namespaces,
373 declaration_start,
374 );
375 let (swrl_text, swrl_hits) =
376 ontocore_swrl::rewrite_swrl_iris_in_turtle(&preview_text, from_iri, to_iri);
377 if swrl_hits > 0 {
378 preview_text = swrl_text;
379 warnings.push(format!(
380 "rewrote {swrl_hits} SWRL rule(s) in {}",
381 doc.path.display()
382 ));
383 }
384 preview_text
385 }
386 OntologyFormat::Owl | OntologyFormat::RdfXml | OntologyFormat::OwlXml => {
387 if !original.contains(from_iri) {
388 continue;
389 }
390 warnings.push(format!(
392 "non-Turtle replace rewrites all IRI mentions in {}",
393 doc.path.display()
394 ));
395 let fmt = if doc.format == OntologyFormat::OwlXml { "owlxml" } else { "rdfxml" };
396 match ontocore_owl::remap_entity_iri_in_xml_text(
397 &original,
398 fmt,
399 from_iri,
400 to_iri,
401 &doc.namespaces,
402 ) {
403 Ok(text) => text,
404 Err(e) => {
405 warnings.push(format!("skipping {} {}: {e}", fmt, doc.path.display()));
406 continue;
407 }
408 }
409 }
410 OntologyFormat::Obo => {
411 let from_id = catalog
412 .find_entity(from_iri)
413 .and_then(|e| e.obo_id.clone())
414 .or_else(|| short_obo_id_from_iri(from_iri))
415 .unwrap_or_else(|| {
416 from_iri.rsplit(['#', '/']).next().unwrap_or(from_iri).to_string()
417 });
418 let to_id = catalog
419 .find_entity(to_iri)
420 .and_then(|e| e.obo_id.clone())
421 .or_else(|| short_obo_id_from_iri(to_iri))
422 .unwrap_or_else(|| {
423 to_iri.rsplit(['#', '/']).next().unwrap_or(to_iri).to_string()
424 });
425 if !original.contains(&from_id) {
426 continue;
427 }
428 warnings.push(format!(
429 "non-Turtle replace rewrites all IRI mentions in {}",
430 doc.path.display()
431 ));
432 match ontocore_obo::remap_obo_id_in_text(&original, &from_id, &to_id) {
433 Ok(text) => text,
434 Err(e) => {
435 warnings.push(format!("skipping OBO {}: {e}", doc.path.display()));
436 continue;
437 }
438 }
439 }
440 other => {
441 if text_contains_iri(doc, from_iri, document_overrides) {
442 warnings.push(format!(
443 "skipping unsupported format {} in {}",
444 other.as_str(),
445 doc.path.display()
446 ));
447 }
448 continue;
449 }
450 };
451
452 if preview_text == original {
453 continue;
454 }
455
456 changes
457 .insert(doc.path.clone(), whole_file_change(doc.path.clone(), original, preview_text));
458 }
459
460 if changes.is_empty() {
461 warnings.push(format!("no files changed for entity replacement {from_iri} -> {to_iri}"));
462 }
463
464 Ok(RefactorPlan { changes: changes.into_values().collect(), warnings, ..Default::default() }
465 .with_metrics([from_iri, to_iri]))
466}
467
468fn replace_iri_preserving_subject(
469 text: &str,
470 from_iri: &str,
471 to_iri: &str,
472 namespaces: &BTreeMap<String, String>,
473 subject_start: Option<usize>,
474) -> String {
475 let Some(subject_start) = subject_start else {
476 return replace_iri_in_text(text, from_iri, to_iri, namespaces).0;
477 };
478 let subject_text = &text[subject_start..];
479 let subject_end = if subject_text.starts_with('<') {
480 subject_text.find('>').map(|offset| subject_start + offset + 1).unwrap_or(text.len())
481 } else {
482 subject_text
483 .find(|c: char| c.is_whitespace() || c == ';')
484 .map(|offset| subject_start + offset)
485 .unwrap_or(text.len())
486 };
487
488 let before = replace_iri_in_text(&text[..subject_start], from_iri, to_iri, namespaces).0;
489 let after = replace_iri_in_text(&text[subject_end..], from_iri, to_iri, namespaces).0;
490 format!("{before}{}{after}", &text[subject_start..subject_end])
491}
492
493fn whole_file_change(path: PathBuf, original_text: String, preview_text: String) -> FileChange {
494 FileChange {
495 path,
496 hunks: vec![Hunk {
497 start_byte: 0,
498 end_byte: original_text.len() as u64,
499 old_text: original_text.clone(),
500 new_text: preview_text.clone(),
501 }],
502 preview_text,
503 original_text,
504 }
505}
506
507pub fn preview_migrate_namespace(
508 catalog: &OntologyCatalog,
509 from_base: &str,
510 to_base: &str,
511 document_overrides: &HashMap<PathBuf, String>,
512) -> Result<RefactorPlan> {
513 let from = normalize_namespace_base(from_base);
514 let to = normalize_namespace_base(to_base);
515 if from == to {
516 return Err(RefactorError::Invalid("from and to namespace must differ".to_string()));
517 }
518
519 let all_iris: Vec<String> = catalog
520 .data()
521 .entities
522 .iter()
523 .filter(|e| remap_iri(&e.iri, &from, &to).is_some())
524 .map(|e| e.iri.clone())
525 .chain(catalog.data().axioms.iter().flat_map(|a| {
526 let mut v = Vec::new();
527 if remap_iri(&a.subject, &from, &to).is_some() {
528 v.push(a.subject.clone());
529 }
530 if remap_iri(&a.object, &from, &to).is_some() {
531 v.push(a.object.clone());
532 }
533 v
534 }))
535 .collect::<BTreeSet<_>>()
536 .into_iter()
537 .collect();
538
539 let mut changes: BTreeMap<PathBuf, FileChange> = BTreeMap::new();
540 let mut warnings = Vec::new();
541
542 for doc in &catalog.data().documents {
543 if doc.format != OntologyFormat::Turtle || doc.parse_status != ParseStatus::Ok {
544 continue;
545 }
546 let original = read_source_text(&doc.path, document_overrides)?;
547 let mut preview = original.clone();
548 let mut hunks = Vec::new();
549 let mut changed = false;
550
551 for old_iri in &all_iris {
552 let new_iri = remap_iri(old_iri, &from, &to).unwrap_or_else(|| old_iri.clone());
553 if !preview.contains(old_iri)
554 && !contains_prefixed_ref(&preview, old_iri, &doc.namespaces)
555 {
556 continue;
557 }
558 let (next, raw_hunks) =
559 replace_iri_in_text(&preview, old_iri, &new_iri, &doc.namespaces);
560 if next != preview {
561 preview = next;
562 changed = true;
563 hunks.extend(raw_hunks.into_iter().map(|(s, e, o, n)| Hunk {
564 start_byte: s as u64,
565 end_byte: e as u64,
566 old_text: o,
567 new_text: n,
568 }));
569 }
570 }
571
572 for (prefix, ns) in &doc.namespaces {
573 if normalize_namespace_base(ns) == from {
574 let terminator = if ns.ends_with('/') { '/' } else { '#' };
575 let new_ns = format!("{to}{terminator}");
576 let (next, raw_hunks) = replace_prefix_uri(&preview, prefix, ns, &new_ns);
577 if next != preview {
578 preview = next;
579 changed = true;
580 hunks.extend(raw_hunks.into_iter().map(|(s, e, o, n)| Hunk {
581 start_byte: s as u64,
582 end_byte: e as u64,
583 old_text: o,
584 new_text: n,
585 }));
586 }
587 }
588 }
589
590 if changed {
591 changes.insert(
592 doc.path.clone(),
593 FileChange {
594 path: doc.path.clone(),
595 preview_text: preview,
596 original_text: original,
597 hunks,
598 },
599 );
600 }
601 }
602
603 if changes.is_empty() {
604 warnings.push(format!("no Turtle files changed for namespace migration {from} -> {to}"));
605 }
606
607 Ok(RefactorPlan { changes: changes.into_values().collect(), warnings, ..Default::default() }
608 .with_metrics([from, to]))
609}
610
611fn is_prefix_declaration_line(line: &str) -> bool {
612 let keyword = line.split_whitespace().next().unwrap_or("");
613 keyword.eq_ignore_ascii_case("@prefix") || keyword.eq_ignore_ascii_case("PREFIX")
614}
615
616fn prefix_declaration_name(line: &str) -> Option<&str> {
617 let mut parts = line.split_whitespace();
618 let keyword = parts.next()?;
619 if !(keyword.eq_ignore_ascii_case("@prefix") || keyword.eq_ignore_ascii_case("PREFIX")) {
620 return None;
621 }
622 parts.next()?.strip_suffix(':')
623}
624
625fn replace_prefix_uri(
626 text: &str,
627 prefix: &str,
628 old_uri: &str,
629 new_uri: &str,
630) -> (String, Vec<(usize, usize, String, String)>) {
631 let old_term = format!("<{old_uri}>");
632 let new_term = format!("<{new_uri}>");
633 let mut out = String::with_capacity(text.len());
634 let mut hunks = Vec::new();
635 let mut offset = 0usize;
636 for line in text.split_inclusive('\n') {
637 let updated = if prefix_declaration_name(line) == Some(prefix) && line.contains(&old_term) {
638 line.replacen(&old_term, &new_term, 1)
639 } else {
640 line.to_string()
641 };
642 if updated != line {
643 hunks.push((offset, offset + line.len(), line.to_string(), updated.clone()));
644 }
645 out.push_str(&updated);
646 offset += line.len();
647 }
648 (out, hunks)
649}
650
651fn escape_turtle_string(value: &str) -> String {
652 let mut out = String::with_capacity(value.len());
653 for ch in value.chars() {
654 match ch {
655 '\\' => out.push_str("\\\\"),
656 '"' => out.push_str("\\\""),
657 '\n' => out.push_str("\\n"),
658 '\r' => out.push_str("\\r"),
659 '\t' => out.push_str("\\t"),
660 c => out.push(c),
661 }
662 }
663 out
664}
665
666fn find_usages_in_catalog(
667 catalog: &OntologyCatalog,
668 iri: &str,
669 document_overrides: &HashMap<PathBuf, String>,
670) -> Vec<()> {
671 let u = crate::usages::find_usages_with_overrides(catalog, iri, document_overrides);
672 vec![(); u.len()]
673}
674
675fn text_contains_iri(
676 doc: &ontocore_core::OntologyDocument,
677 iri: &str,
678 document_overrides: &HashMap<PathBuf, String>,
679) -> bool {
680 read_source_text(&doc.path, document_overrides).map(|t| t.contains(iri)).unwrap_or(false)
681}
682
683fn contains_prefixed_ref(text: &str, iri: &str, namespaces: &BTreeMap<String, String>) -> bool {
684 let short = ontocore_owl::short_name_from_iri(iri);
685 for (prefix, ns) in namespaces {
686 if iri.starts_with(ns) && text.contains(&format!("{prefix}:{short}")) {
687 return true;
688 }
689 }
690 false
691}
692
693fn prefixed_curie(iri: &str, namespaces: &BTreeMap<String, String>) -> String {
694 let short = ontocore_owl::short_name_from_iri(iri);
695 for (prefix, ns) in namespaces {
696 if iri.starts_with(ns) && !prefix.is_empty() {
697 return format!("{prefix}:{short}");
698 }
699 }
700 format!("<{iri}>")
701}
702
703fn owl_type_for_kind(kind: EntityKind) -> &'static str {
704 match kind {
705 EntityKind::Class => "owl:Class",
706 EntityKind::ObjectProperty => "owl:ObjectProperty",
707 EntityKind::DataProperty => "owl:DatatypeProperty",
708 EntityKind::AnnotationProperty => "owl:AnnotationProperty",
709 EntityKind::Individual => "owl:NamedIndividual",
710 EntityKind::Datatype => "rdfs:Datatype",
711 EntityKind::Ontology => "owl:Ontology",
712 EntityKind::Other => "owl:Class",
713 }
714}
715
716struct EntityRemoval {
717 path: PathBuf,
718 start: u64,
719 end: u64,
720 replacement: String,
721}
722
723pub fn preview_refactor(
726 catalog: &OntologyCatalog,
727 request: &crate::model::RefactorRequest,
728 document_overrides: &HashMap<PathBuf, String>,
729 workspace_roots: &[PathBuf],
730) -> Result<RefactorPlan> {
731 match request {
732 crate::model::RefactorRequest::RenameIri { from_iri, to_iri } => {
733 preview_rename_iri(catalog, from_iri, to_iri, document_overrides)
734 }
735 crate::model::RefactorRequest::MergeEntities { keep_iri, merge_iri } => {
736 preview_merge_entities(catalog, keep_iri, merge_iri, document_overrides)
737 }
738 crate::model::RefactorRequest::ReplaceEntity { from_iri, to_iri } => {
739 preview_replace_entity(catalog, from_iri, to_iri, document_overrides)
740 }
741 crate::model::RefactorRequest::MigrateNamespace { from_base, to_base } => {
742 preview_migrate_namespace(catalog, from_base, to_base, document_overrides)
743 }
744 crate::model::RefactorRequest::MoveEntity { entity_iri, target_file } => {
745 preview_move_entity(
746 catalog,
747 entity_iri,
748 target_file,
749 document_overrides,
750 workspace_roots,
751 )
752 }
753 crate::model::RefactorRequest::ExtractModule {
754 entity_iris,
755 output_file,
756 leave_stub,
757 locality,
758 } => preview_extract_module(
759 catalog,
760 entity_iris,
761 output_file,
762 *leave_stub,
763 *locality,
764 document_overrides,
765 workspace_roots,
766 ),
767 crate::model::RefactorRequest::MoveAxioms {
768 entity_iri,
769 target_file,
770 statement_indexes,
771 exclude_primary,
772 } => preview_move_axioms(
773 catalog,
774 entity_iri,
775 target_file,
776 statement_indexes,
777 *exclude_primary,
778 document_overrides,
779 workspace_roots,
780 ),
781 crate::model::RefactorRequest::MergeOntologies { source_paths, target_file } => {
782 crate::ontology::preview_merge_ontologies(
783 catalog,
784 source_paths,
785 target_file,
786 document_overrides,
787 workspace_roots,
788 )
789 }
790 crate::model::RefactorRequest::FlattenImports { ontology_file } => {
791 crate::ontology::preview_flatten_imports(
792 catalog,
793 ontology_file,
794 document_overrides,
795 workspace_roots,
796 )
797 }
798 crate::model::RefactorRequest::CleanupImports { ontology_file } => {
799 crate::ontology::preview_cleanup_imports(
800 catalog,
801 ontology_file,
802 document_overrides,
803 workspace_roots,
804 )
805 }
806 }
807}
808
809fn require_path_in_workspace(path: &Path, workspace_roots: &[PathBuf]) -> Result<()> {
810 validate_workspace_scope_any(path, workspace_roots).map_err(RefactorError::Invalid)?;
811 Ok(())
812}
813
814fn canonical_path(path: &Path) -> PathBuf {
815 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
816}
817
818pub fn preview_move_entity(
819 catalog: &OntologyCatalog,
820 entity_iri: &str,
821 target_file: &Path,
822 document_overrides: &HashMap<PathBuf, String>,
823 workspace_roots: &[PathBuf],
824) -> Result<RefactorPlan> {
825 require_path_in_workspace(target_file, workspace_roots)?;
827 catalog
828 .find_entity(entity_iri)
829 .ok_or_else(|| RefactorError::EntityNotFound(entity_iri.to_string()))?;
830 let source_doc = catalog
831 .entity_document(entity_iri)
832 .ok_or_else(|| RefactorError::Invalid(format!("no document for {entity_iri}")))?;
833 if source_doc.format != OntologyFormat::Turtle {
834 return Err(RefactorError::UnsupportedFormat(source_doc.format.as_str().to_string()));
835 }
836
837 let source_canon = canonical_path(&source_doc.path);
838 let target_canon = canonical_path(target_file);
839 if source_canon == target_canon {
840 return Err(RefactorError::Invalid(
841 "target file must differ from source document".to_string(),
842 ));
843 }
844
845 let source_text = read_source_text(&source_doc.path, document_overrides)?;
846 let namespaces = ontocore_owl::namespaces_for_text(&source_text, &source_doc.namespaces);
847 let short = ontocore_owl::short_name_from_iri(entity_iri);
848 let mut ranges =
849 ontocore_owl::all_entity_statement_ranges(&source_text, entity_iri, &short, &namespaces);
850 if ranges.is_empty() {
851 return Err(RefactorError::Invalid(format!("entity block not found for {entity_iri}")));
852 }
853 ranges.sort_by_key(|r| r.start);
854
855 let mut block_parts = Vec::new();
856 let mut hunks = Vec::new();
857 for range in &ranges {
858 let part = source_text[range.start as usize..range.end as usize].to_string();
859 block_parts.push(part.clone());
860 hunks.push(Hunk {
861 start_byte: range.start,
862 end_byte: range.end,
863 old_text: part,
864 new_text: String::new(),
865 });
866 }
867 let block_text = block_parts.join("\n");
868 let mut source_without = source_text.clone();
869 for range in ranges.into_iter().rev() {
870 source_without.replace_range(range.start as usize..range.end as usize, "");
871 }
872
873 let mut prefix_lines = BTreeSet::new();
874 for line in source_text.lines() {
875 if is_prefix_declaration_line(line) {
876 prefix_lines.insert(line.trim().to_string());
877 }
878 }
879 let prefix_header: String = prefix_lines.iter().cloned().collect::<Vec<_>>().join("\n");
880 let block_with_prefixes = if prefix_header.is_empty() {
881 block_text.clone()
882 } else {
883 format!("{prefix_header}\n\n{block_text}")
884 };
885
886 let target_original = if target_file.exists() {
887 read_source_text(target_file, document_overrides)?
888 } else {
889 String::new()
890 };
891 let target_was_empty = target_original.is_empty();
892 let mut warnings = Vec::new();
893 let (target_preview, target_hunk_new) = if target_was_empty {
894 let mut out = block_with_prefixes.clone();
895 if !out.ends_with('\n') {
896 out.push('\n');
897 }
898 (out, block_with_prefixes)
899 } else {
900 let target_prefix_names: BTreeSet<String> = target_original
902 .lines()
903 .filter_map(prefix_declaration_name)
904 .map(str::to_string)
905 .collect();
906 let mut missing_prefixes = Vec::new();
907 for line in &prefix_lines {
908 let Some(name) = prefix_declaration_name(line) else {
909 continue;
910 };
911 if !target_prefix_names.contains(name) {
912 missing_prefixes.push(line.clone());
913 }
914 }
915 let inserted = if missing_prefixes.is_empty() {
916 String::new()
917 } else {
918 format!("{}\n\n", missing_prefixes.join("\n"))
919 };
920 if !missing_prefixes.is_empty() {
921 warnings.push(format!(
922 "added {} missing @prefix declaration(s) to {}",
923 missing_prefixes.len(),
924 target_file.display()
925 ));
926 }
927 let hunk_new = format!("{inserted}{block_text}");
928 (format!("{target_original}\n\n{hunk_new}"), hunk_new)
929 };
930
931 Ok(RefactorPlan {
932 changes: vec![
933 FileChange {
934 path: source_doc.path.clone(),
935 preview_text: source_without,
936 original_text: source_text,
937 hunks,
938 },
939 FileChange {
940 path: target_file.to_path_buf(),
941 preview_text: target_preview,
942 original_text: target_original,
943 hunks: vec![Hunk {
944 start_byte: 0,
945 end_byte: 0,
946 old_text: String::new(),
947 new_text: target_hunk_new,
948 }],
949 },
950 ],
951 warnings,
952 ..Default::default()
953 }
954 .with_metrics([entity_iri]))
955}
956
957pub fn preview_move_axioms(
959 catalog: &OntologyCatalog,
960 entity_iri: &str,
961 target_file: &Path,
962 statement_indexes: &[usize],
963 exclude_primary: bool,
964 document_overrides: &HashMap<PathBuf, String>,
965 workspace_roots: &[PathBuf],
966) -> Result<RefactorPlan> {
967 require_path_in_workspace(target_file, workspace_roots)?;
968 catalog
969 .find_entity(entity_iri)
970 .ok_or_else(|| RefactorError::EntityNotFound(entity_iri.to_string()))?;
971 let Some(source_doc) = catalog.entity_document(entity_iri) else {
972 return Err(RefactorError::EntityNotFound(entity_iri.to_string()));
973 };
974 if source_doc.format != OntologyFormat::Turtle || source_doc.parse_status != ParseStatus::Ok {
975 return Err(RefactorError::Invalid(
976 "move axioms currently supports Turtle documents only".to_string(),
977 ));
978 }
979 let source_canon = canonical_path(&source_doc.path);
980 let target_canon = canonical_path(target_file);
981 if source_canon == target_canon {
982 return Err(RefactorError::Invalid(
983 "target file must differ from the source document".to_string(),
984 ));
985 }
986
987 let source_text = read_source_text(&source_doc.path, document_overrides)?;
988 let namespaces = ontocore_owl::namespaces_for_text(&source_text, &source_doc.namespaces);
989 let short = ontocore_owl::short_name_from_iri(entity_iri);
990 let ranges =
991 ontocore_owl::all_entity_statement_ranges(&source_text, entity_iri, &short, &namespaces);
992 if ranges.is_empty() {
993 return Err(RefactorError::Invalid(format!(
994 "no Turtle statements found for entity {entity_iri}"
995 )));
996 }
997
998 let primary = ontocore_owl::entity_primary_block_range(&source_text, entity_iri, &namespaces);
999 let selectable: Vec<(usize, ontocore_owl::ByteRange)> = ranges
1000 .into_iter()
1001 .enumerate()
1002 .filter(|(_, range)| {
1003 if !exclude_primary {
1004 return true;
1005 }
1006 primary.map(|p| p.start != range.start || p.end != range.end).unwrap_or(true)
1007 })
1008 .collect();
1009
1010 if selectable.is_empty() {
1011 return Err(RefactorError::Invalid(
1012 "no movable axiom statements (only primary declaration remains)".to_string(),
1013 ));
1014 }
1015
1016 let chosen: Vec<ontocore_owl::ByteRange> = if statement_indexes.is_empty() {
1017 selectable.into_iter().map(|(_, r)| r).collect()
1018 } else {
1019 let mut out = Vec::new();
1020 for idx in statement_indexes {
1021 let Some((_, range)) = selectable.iter().find(|(i, _)| *i == *idx) else {
1022 return Err(RefactorError::Invalid(format!(
1023 "statement index {idx} out of range for entity {entity_iri}"
1024 )));
1025 };
1026 out.push(*range);
1027 }
1028 out
1029 };
1030
1031 let mut warnings = Vec::new();
1032 let mut blocks = Vec::new();
1033 let mut source_without = source_text.clone();
1034 let mut ordered = chosen;
1035 ordered.sort_by_key(|r| std::cmp::Reverse(r.start));
1036 for range in &ordered {
1037 let block = source_text[range.start as usize..range.end as usize].trim().to_string();
1038 if !block.is_empty() {
1039 blocks.push(block);
1040 }
1041 source_without.replace_range(range.start as usize..range.end as usize, "");
1042 }
1043 while source_without.contains("\n\n\n") {
1044 source_without = source_without.replace("\n\n\n", "\n\n");
1045 }
1046 blocks.reverse();
1047 let moved_text = blocks.join("\n\n");
1048 if moved_text.is_empty() {
1049 return Err(RefactorError::Invalid("selected statements produced empty move".to_string()));
1050 }
1051
1052 let target_original = if target_file.exists() {
1053 read_source_text(target_file, document_overrides)?
1054 } else {
1055 String::new()
1056 };
1057 let prefix_lines: Vec<String> = source_text
1058 .lines()
1059 .filter(|line| is_prefix_declaration_line(line))
1060 .map(str::to_string)
1061 .collect();
1062 let target_prefix_names: BTreeSet<String> =
1063 target_original.lines().filter_map(prefix_declaration_name).map(str::to_string).collect();
1064 let mut missing_prefixes = Vec::new();
1065 for line in &prefix_lines {
1066 let Some(name) = prefix_declaration_name(line) else {
1067 continue;
1068 };
1069 if !target_prefix_names.contains(name) {
1070 missing_prefixes.push(line.clone());
1071 }
1072 }
1073 let inserted = if missing_prefixes.is_empty() {
1074 String::new()
1075 } else {
1076 warnings.push(format!(
1077 "added {} missing @prefix declaration(s) to {}",
1078 missing_prefixes.len(),
1079 target_file.display()
1080 ));
1081 format!("{}\n\n", missing_prefixes.join("\n"))
1082 };
1083 let target_preview = if target_original.is_empty() {
1084 format!("{inserted}{moved_text}\n")
1085 } else {
1086 format!("{target_original}\n\n{inserted}{moved_text}\n")
1087 };
1088
1089 Ok(RefactorPlan {
1090 changes: vec![
1091 FileChange {
1092 path: source_doc.path.clone(),
1093 preview_text: source_without,
1094 original_text: source_text,
1095 hunks: vec![],
1096 },
1097 FileChange {
1098 path: target_file.to_path_buf(),
1099 preview_text: target_preview.clone(),
1100 original_text: target_original,
1101 hunks: vec![Hunk {
1102 start_byte: 0,
1103 end_byte: 0,
1104 old_text: String::new(),
1105 new_text: format!("{inserted}{moved_text}"),
1106 }],
1107 },
1108 ],
1109 warnings,
1110 ..Default::default()
1111 }
1112 .with_metrics([entity_iri]))
1113}
1114
1115pub fn preview_extract_module(
1116 catalog: &OntologyCatalog,
1117 entity_iris: &[String],
1118 output_file: &Path,
1119 leave_stub: bool,
1120 locality: bool,
1121 document_overrides: &HashMap<PathBuf, String>,
1122 workspace_roots: &[PathBuf],
1123) -> Result<RefactorPlan> {
1124 require_path_in_workspace(output_file, workspace_roots)?;
1126 if entity_iris.is_empty() {
1127 return Err(RefactorError::Invalid("no entities selected".to_string()));
1128 }
1129
1130 let expanded: Vec<String> = if locality {
1131 crate::ontology::expand_signature_locality(catalog, entity_iris)
1132 } else {
1133 entity_iris.to_vec()
1134 };
1135 let entity_iris = &expanded;
1136
1137 let mut blocks = Vec::new();
1138 let mut removals: Vec<EntityRemoval> = Vec::new();
1139 let mut source_texts: BTreeMap<PathBuf, String> = BTreeMap::new();
1140 let mut prefix_lines = BTreeSet::new();
1141 let mut warnings = Vec::new();
1142 if locality {
1143 warnings.push(format!(
1144 "locality expansion selected {} entit{}",
1145 entity_iris.len(),
1146 if entity_iris.len() == 1 { "y" } else { "ies" }
1147 ));
1148 }
1149
1150 for iri in entity_iris {
1151 let entity =
1152 catalog.find_entity(iri).ok_or_else(|| RefactorError::EntityNotFound(iri.clone()))?;
1153 let doc = catalog
1154 .entity_document(iri)
1155 .ok_or_else(|| RefactorError::Invalid(format!("no document for {iri}")))?;
1156 if doc.format != OntologyFormat::Turtle {
1157 return Err(RefactorError::UnsupportedFormat(doc.format.as_str().to_string()));
1158 }
1159 let text = if let Some(existing) = source_texts.get(&doc.path) {
1160 existing.clone()
1161 } else {
1162 read_source_text(&doc.path, document_overrides)?
1163 };
1164 source_texts.insert(doc.path.clone(), text.clone());
1165 for line in text.lines() {
1166 if is_prefix_declaration_line(line) {
1167 prefix_lines.insert(line.trim().to_string());
1168 }
1169 }
1170 let namespaces = ontocore_owl::namespaces_for_text(&text, &doc.namespaces);
1171 let short = ontocore_owl::short_name_from_iri(iri);
1172 let mut ranges = ontocore_owl::all_entity_statement_ranges(&text, iri, &short, &namespaces);
1173 if ranges.is_empty() {
1174 return Err(RefactorError::Invalid(format!("block not found for {iri}")));
1175 }
1176 ranges.sort_by_key(|r| r.start);
1177 let mut entity_blocks = Vec::new();
1178 for (idx, range) in ranges.iter().enumerate() {
1179 let block = text[range.start as usize..range.end as usize].to_string();
1180 entity_blocks.push(block);
1181 let replacement = if leave_stub && idx == 0 {
1182 let owl_type = owl_type_for_kind(entity.kind);
1183 let moved_path = escape_turtle_string(&output_file.display().to_string());
1184 format!(
1185 "{} a {owl_type} ;\n owl:deprecated true ;\n rdfs:comment \"Moved to {moved_path}\" .\n",
1186 prefixed_curie(iri, &namespaces),
1187 )
1188 } else {
1189 String::new()
1190 };
1191 removals.push(EntityRemoval {
1192 path: doc.path.clone(),
1193 start: range.start,
1194 end: range.end,
1195 replacement,
1196 });
1197 }
1198 blocks.push(entity_blocks.join("\n"));
1199 }
1200
1201 let mut source_changes: BTreeMap<PathBuf, (String, String, Vec<Hunk>)> = BTreeMap::new();
1202 let mut removals_by_path: BTreeMap<PathBuf, Vec<EntityRemoval>> = BTreeMap::new();
1203 for removal in removals {
1204 removals_by_path.entry(removal.path.clone()).or_default().push(removal);
1205 }
1206 for (path, mut path_removals) in removals_by_path {
1207 path_removals.sort_by_key(|b| std::cmp::Reverse(b.start));
1208 let original = source_texts.remove(&path).ok_or_else(|| {
1209 RefactorError::Invalid(format!("missing source text for {}", path.display()))
1210 })?;
1211 let mut preview = original.clone();
1212 let mut hunks = Vec::new();
1213 for removal in path_removals {
1214 let start = removal.start as usize;
1215 let end = removal.end as usize;
1216 let old_text = preview[start..end].to_string();
1217 preview.replace_range(start..end, &removal.replacement);
1218 hunks.push(Hunk {
1219 start_byte: removal.start,
1220 end_byte: removal.end,
1221 old_text,
1222 new_text: removal.replacement.clone(),
1223 });
1224 }
1225 source_changes.insert(path, (original, preview, hunks));
1226 }
1227
1228 let mut module_body = blocks.join("\n\n");
1229 if !module_body.ends_with('\n') {
1230 module_body.push('\n');
1231 }
1232 let prefix_header: String = prefix_lines.into_iter().collect::<Vec<_>>().join("\n");
1233 let module_text = if prefix_header.is_empty() {
1234 module_body
1235 } else {
1236 format!("{prefix_header}\n\n{module_body}")
1237 };
1238
1239 let mut changes: Vec<FileChange> = source_changes
1240 .into_iter()
1241 .map(|(path, (original, preview, hunks))| FileChange {
1242 path: path.clone(),
1243 preview_text: preview,
1244 original_text: original,
1245 hunks,
1246 })
1247 .collect();
1248
1249 let output_original = if output_file.exists() {
1250 read_source_text(output_file, document_overrides)?
1251 } else {
1252 String::new()
1253 };
1254 let output_preview = if output_original.is_empty() {
1255 module_text.clone()
1256 } else {
1257 format!("{output_original}\n\n{module_text}")
1258 };
1259 changes.push(FileChange {
1260 path: output_file.to_path_buf(),
1261 preview_text: output_preview,
1262 original_text: output_original,
1263 hunks: vec![Hunk {
1264 start_byte: 0,
1265 end_byte: 0,
1266 old_text: String::new(),
1267 new_text: module_text,
1268 }],
1269 });
1270
1271 if leave_stub {
1272 warnings.push("left deprecated stubs in source files".to_string());
1273 }
1274
1275 Ok(RefactorPlan { changes, warnings, ..Default::default() }
1276 .with_metrics(entity_iris.iter().map(|s| s.as_str())))
1277}
1278
1279#[cfg(test)]
1280mod tests {
1281 use super::*;
1282
1283 #[test]
1284 fn replace_prefix_uri_updates_at_prefix_uppercase() {
1285 let text = "@PREFIX ex: <http://example.org/org#> .\nex:Person a owl:Class .\n";
1286 let (out, hunks) =
1287 replace_prefix_uri(text, "ex", "http://example.org/org#", "http://example.org/v2/org#");
1288 assert!(out.contains("@PREFIX ex: <http://example.org/v2/org#>"));
1289 assert!(!out.contains("@PREFIX ex: <http://example.org/org#>"));
1290 assert!(!hunks.is_empty());
1291 }
1292
1293 #[test]
1294 fn replace_prefix_uri_updates_sparql_style_prefix() {
1295 let text = "PREFIX ex: <http://example.org/org#>\nex:Person a owl:Class .\n";
1296 let (out, _) =
1297 replace_prefix_uri(text, "ex", "http://example.org/org#", "http://example.org/v2/org#");
1298 assert!(out.contains("PREFIX ex: <http://example.org/v2/org#>"));
1299 }
1300
1301 #[test]
1302 fn owl_type_for_kind_maps_ontology() {
1303 assert_eq!(owl_type_for_kind(EntityKind::Ontology), "owl:Ontology");
1304 assert_eq!(owl_type_for_kind(EntityKind::Other), "owl:Class");
1305 assert_eq!(owl_type_for_kind(EntityKind::Class), "owl:Class");
1306 }
1307
1308 #[test]
1309 fn escape_turtle_string_escapes_path_specials() {
1310 assert_eq!(
1311 escape_turtle_string(r#"C:\ontology\mod"ule.ttl"#),
1312 r#"C:\\ontology\\mod\"ule.ttl"#
1313 );
1314 }
1315}