Skip to main content

ontocore_refactor/
rename.rs

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
28    for doc in &catalog.data().documents {
29        if doc.format != OntologyFormat::Turtle || doc.parse_status != ParseStatus::Ok {
30            if text_contains_iri(doc, from_iri, document_overrides) {
31                warnings
32                    .push(format!("skipping non-Turtle or errored file: {}", doc.path.display()));
33            }
34            continue;
35        }
36        let original = read_source_text(&doc.path, document_overrides)?;
37        if !original.contains(from_iri)
38            && !contains_prefixed_ref(&original, from_iri, &doc.namespaces)
39        {
40            continue;
41        }
42        let (preview_text, raw_hunks) =
43            replace_iri_in_text(&original, from_iri, to_iri, &doc.namespaces);
44        if preview_text == original {
45            continue;
46        }
47        let hunks: Vec<Hunk> = raw_hunks
48            .into_iter()
49            .map(|(start, end, old_text, new_text)| Hunk {
50                start_byte: start as u64,
51                end_byte: end as u64,
52                old_text,
53                new_text,
54            })
55            .collect();
56        file_changes.insert(
57            doc.path.clone(),
58            FileChange { path: doc.path.clone(), preview_text, original_text: original, hunks },
59        );
60    }
61
62    if file_changes.is_empty() {
63        warnings.push(format!("no Turtle files changed for IRI {from_iri}"));
64    }
65
66    Ok(RefactorPlan { changes: file_changes.into_values().collect(), warnings })
67}
68
69pub fn preview_migrate_namespace(
70    catalog: &OntologyCatalog,
71    from_base: &str,
72    to_base: &str,
73    document_overrides: &HashMap<PathBuf, String>,
74) -> Result<RefactorPlan> {
75    let from = normalize_namespace_base(from_base);
76    let to = normalize_namespace_base(to_base);
77    if from == to {
78        return Err(RefactorError::Invalid("from and to namespace must differ".to_string()));
79    }
80
81    let all_iris: Vec<String> = catalog
82        .data()
83        .entities
84        .iter()
85        .filter(|e| remap_iri(&e.iri, &from, &to).is_some())
86        .map(|e| e.iri.clone())
87        .chain(catalog.data().axioms.iter().flat_map(|a| {
88            let mut v = Vec::new();
89            if remap_iri(&a.subject, &from, &to).is_some() {
90                v.push(a.subject.clone());
91            }
92            if remap_iri(&a.object, &from, &to).is_some() {
93                v.push(a.object.clone());
94            }
95            v
96        }))
97        .collect::<BTreeSet<_>>()
98        .into_iter()
99        .collect();
100
101    let mut changes: BTreeMap<PathBuf, FileChange> = BTreeMap::new();
102    let mut warnings = Vec::new();
103
104    for doc in &catalog.data().documents {
105        if doc.format != OntologyFormat::Turtle || doc.parse_status != ParseStatus::Ok {
106            continue;
107        }
108        let original = read_source_text(&doc.path, document_overrides)?;
109        let mut preview = original.clone();
110        let mut hunks = Vec::new();
111        let mut changed = false;
112
113        for old_iri in &all_iris {
114            let new_iri = remap_iri(old_iri, &from, &to).unwrap_or_else(|| old_iri.clone());
115            if !preview.contains(old_iri)
116                && !contains_prefixed_ref(&preview, old_iri, &doc.namespaces)
117            {
118                continue;
119            }
120            let (next, raw_hunks) =
121                replace_iri_in_text(&preview, old_iri, &new_iri, &doc.namespaces);
122            if next != preview {
123                preview = next;
124                changed = true;
125                hunks.extend(raw_hunks.into_iter().map(|(s, e, o, n)| Hunk {
126                    start_byte: s as u64,
127                    end_byte: e as u64,
128                    old_text: o,
129                    new_text: n,
130                }));
131            }
132        }
133
134        for (prefix, ns) in &doc.namespaces {
135            if normalize_namespace_base(ns) == from {
136                let terminator = if ns.ends_with('/') { '/' } else { '#' };
137                let new_ns = format!("{to}{terminator}");
138                let (next, raw_hunks) = replace_prefix_uri(&preview, prefix, ns, &new_ns);
139                if next != preview {
140                    preview = next;
141                    changed = true;
142                    hunks.extend(raw_hunks.into_iter().map(|(s, e, o, n)| Hunk {
143                        start_byte: s as u64,
144                        end_byte: e as u64,
145                        old_text: o,
146                        new_text: n,
147                    }));
148                }
149            }
150        }
151
152        if changed {
153            changes.insert(
154                doc.path.clone(),
155                FileChange {
156                    path: doc.path.clone(),
157                    preview_text: preview,
158                    original_text: original,
159                    hunks,
160                },
161            );
162        }
163    }
164
165    if changes.is_empty() {
166        warnings.push(format!("no Turtle files changed for namespace migration {from} -> {to}"));
167    }
168
169    Ok(RefactorPlan { changes: changes.into_values().collect(), warnings })
170}
171
172fn replace_prefix_uri(
173    text: &str,
174    prefix: &str,
175    old_uri: &str,
176    new_uri: &str,
177) -> (String, Vec<(usize, usize, String, String)>) {
178    let old_decl = format!("@prefix {prefix}: <{old_uri}>");
179    let new_decl = format!("@prefix {prefix}: <{new_uri}>");
180    let result = text.replacements(old_decl.as_str(), new_decl.as_str());
181    let mut hunks = Vec::new();
182    if result != text {
183        if let Some(pos) = text.find(&old_decl) {
184            hunks.push((pos, pos + old_decl.len(), old_decl, new_decl.clone()));
185        }
186    }
187    (result, hunks)
188}
189
190trait Replacements {
191    fn replacements(&self, from: &str, to: &str) -> String;
192}
193
194impl Replacements for str {
195    fn replacements(&self, from: &str, to: &str) -> String {
196        self.replace(from, to)
197    }
198}
199
200fn find_usages_in_catalog(
201    catalog: &OntologyCatalog,
202    iri: &str,
203    document_overrides: &HashMap<PathBuf, String>,
204) -> Vec<()> {
205    let u = crate::usages::find_usages_with_overrides(catalog, iri, document_overrides);
206    vec![(); u.len()]
207}
208
209fn text_contains_iri(
210    doc: &ontocore_core::OntologyDocument,
211    iri: &str,
212    document_overrides: &HashMap<PathBuf, String>,
213) -> bool {
214    read_source_text(&doc.path, document_overrides).map(|t| t.contains(iri)).unwrap_or(false)
215}
216
217fn contains_prefixed_ref(text: &str, iri: &str, namespaces: &BTreeMap<String, String>) -> bool {
218    let short = ontocore_owl::short_name_from_iri(iri);
219    for (prefix, ns) in namespaces {
220        if iri.starts_with(ns) && text.contains(&format!("{prefix}:{short}")) {
221            return true;
222        }
223    }
224    false
225}
226
227fn prefixed_curie(iri: &str, namespaces: &BTreeMap<String, String>) -> String {
228    let short = ontocore_owl::short_name_from_iri(iri);
229    for (prefix, ns) in namespaces {
230        if iri.starts_with(ns) && !prefix.is_empty() {
231            return format!("{prefix}:{short}");
232        }
233    }
234    format!("<{iri}>")
235}
236
237fn owl_type_for_kind(kind: EntityKind) -> &'static str {
238    match kind {
239        EntityKind::Class => "owl:Class",
240        EntityKind::ObjectProperty => "owl:ObjectProperty",
241        EntityKind::DataProperty => "owl:DatatypeProperty",
242        EntityKind::AnnotationProperty => "owl:AnnotationProperty",
243        EntityKind::Individual => "owl:NamedIndividual",
244        EntityKind::Ontology | EntityKind::Other => "owl:Class",
245    }
246}
247
248struct EntityRemoval {
249    path: PathBuf,
250    start: u64,
251    end: u64,
252    replacement: String,
253}
254
255/// Preview a refactor. Client-supplied paths (`target_file` / `output_file`) are jailed under
256/// `workspace_roots` **before** any filesystem read.
257pub fn preview_refactor(
258    catalog: &OntologyCatalog,
259    request: &crate::model::RefactorRequest,
260    document_overrides: &HashMap<PathBuf, String>,
261    workspace_roots: &[PathBuf],
262) -> Result<RefactorPlan> {
263    match request {
264        crate::model::RefactorRequest::RenameIri { from_iri, to_iri } => {
265            preview_rename_iri(catalog, from_iri, to_iri, document_overrides)
266        }
267        crate::model::RefactorRequest::MigrateNamespace { from_base, to_base } => {
268            preview_migrate_namespace(catalog, from_base, to_base, document_overrides)
269        }
270        crate::model::RefactorRequest::MoveEntity { entity_iri, target_file } => {
271            preview_move_entity(
272                catalog,
273                entity_iri,
274                target_file,
275                document_overrides,
276                workspace_roots,
277            )
278        }
279        crate::model::RefactorRequest::ExtractModule { entity_iris, output_file, leave_stub } => {
280            preview_extract_module(
281                catalog,
282                entity_iris,
283                output_file,
284                *leave_stub,
285                document_overrides,
286                workspace_roots,
287            )
288        }
289    }
290}
291
292fn require_path_in_workspace(path: &Path, workspace_roots: &[PathBuf]) -> Result<()> {
293    validate_workspace_scope_any(path, workspace_roots).map_err(RefactorError::Invalid)?;
294    Ok(())
295}
296
297fn canonical_path(path: &Path) -> PathBuf {
298    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
299}
300
301pub fn preview_move_entity(
302    catalog: &OntologyCatalog,
303    entity_iri: &str,
304    target_file: &Path,
305    document_overrides: &HashMap<PathBuf, String>,
306    workspace_roots: &[PathBuf],
307) -> Result<RefactorPlan> {
308    // Jail before any filesystem read of the client-supplied path.
309    require_path_in_workspace(target_file, workspace_roots)?;
310    catalog
311        .find_entity(entity_iri)
312        .ok_or_else(|| RefactorError::EntityNotFound(entity_iri.to_string()))?;
313    let source_doc = catalog
314        .entity_document(entity_iri)
315        .ok_or_else(|| RefactorError::Invalid(format!("no document for {entity_iri}")))?;
316    if source_doc.format != OntologyFormat::Turtle {
317        return Err(RefactorError::UnsupportedFormat(source_doc.format.as_str().to_string()));
318    }
319
320    let source_canon = canonical_path(&source_doc.path);
321    let target_canon = canonical_path(target_file);
322    if source_canon == target_canon {
323        return Err(RefactorError::Invalid(
324            "target file must differ from source document".to_string(),
325        ));
326    }
327
328    let source_text = read_source_text(&source_doc.path, document_overrides)?;
329    let namespaces = ontocore_owl::namespaces_for_text(&source_text, &source_doc.namespaces);
330    let short = ontocore_owl::short_name_from_iri(entity_iri);
331    let mut ranges =
332        ontocore_owl::all_entity_statement_ranges(&source_text, entity_iri, &short, &namespaces);
333    if ranges.is_empty() {
334        return Err(RefactorError::Invalid(format!("entity block not found for {entity_iri}")));
335    }
336    ranges.sort_by_key(|r| r.start);
337
338    let mut block_parts = Vec::new();
339    let mut hunks = Vec::new();
340    for range in &ranges {
341        let part = source_text[range.start as usize..range.end as usize].to_string();
342        block_parts.push(part.clone());
343        hunks.push(Hunk {
344            start_byte: range.start,
345            end_byte: range.end,
346            old_text: part,
347            new_text: String::new(),
348        });
349    }
350    let block_text = block_parts.join("\n");
351    let mut source_without = source_text.clone();
352    for range in ranges.into_iter().rev() {
353        source_without.replace_range(range.start as usize..range.end as usize, "");
354    }
355
356    let target_original = if target_file.exists() {
357        read_source_text(target_file, document_overrides)?
358    } else {
359        String::new()
360    };
361    let target_preview = if target_original.is_empty() {
362        format!("{block_text}\n")
363    } else {
364        format!("{target_original}\n\n{block_text}")
365    };
366
367    Ok(RefactorPlan {
368        changes: vec![
369            FileChange {
370                path: source_doc.path.clone(),
371                preview_text: source_without,
372                original_text: source_text,
373                hunks,
374            },
375            FileChange {
376                path: target_file.to_path_buf(),
377                preview_text: target_preview,
378                original_text: target_original,
379                hunks: vec![Hunk {
380                    start_byte: 0,
381                    end_byte: 0,
382                    old_text: String::new(),
383                    new_text: block_text,
384                }],
385            },
386        ],
387        warnings: Vec::new(),
388    })
389}
390
391pub fn preview_extract_module(
392    catalog: &OntologyCatalog,
393    entity_iris: &[String],
394    output_file: &Path,
395    leave_stub: bool,
396    document_overrides: &HashMap<PathBuf, String>,
397    workspace_roots: &[PathBuf],
398) -> Result<RefactorPlan> {
399    // Jail before any filesystem read of the client-supplied path.
400    require_path_in_workspace(output_file, workspace_roots)?;
401    if entity_iris.is_empty() {
402        return Err(RefactorError::Invalid("no entities selected".to_string()));
403    }
404
405    let mut blocks = Vec::new();
406    let mut removals: Vec<EntityRemoval> = Vec::new();
407    let mut source_texts: BTreeMap<PathBuf, String> = BTreeMap::new();
408    let mut prefix_lines = BTreeSet::new();
409    let mut warnings = Vec::new();
410
411    for iri in entity_iris {
412        let entity =
413            catalog.find_entity(iri).ok_or_else(|| RefactorError::EntityNotFound(iri.clone()))?;
414        let doc = catalog
415            .entity_document(iri)
416            .ok_or_else(|| RefactorError::Invalid(format!("no document for {iri}")))?;
417        if doc.format != OntologyFormat::Turtle {
418            return Err(RefactorError::UnsupportedFormat(doc.format.as_str().to_string()));
419        }
420        let text = if let Some(existing) = source_texts.get(&doc.path) {
421            existing.clone()
422        } else {
423            read_source_text(&doc.path, document_overrides)?
424        };
425        source_texts.insert(doc.path.clone(), text.clone());
426        for line in text.lines() {
427            if line.trim_start().starts_with("@prefix") {
428                prefix_lines.insert(line.trim().to_string());
429            }
430        }
431        let namespaces = ontocore_owl::namespaces_for_text(&text, &doc.namespaces);
432        let short = ontocore_owl::short_name_from_iri(iri);
433        let mut ranges = ontocore_owl::all_entity_statement_ranges(&text, iri, &short, &namespaces);
434        if ranges.is_empty() {
435            return Err(RefactorError::Invalid(format!("block not found for {iri}")));
436        }
437        ranges.sort_by_key(|r| r.start);
438        let mut entity_blocks = Vec::new();
439        for (idx, range) in ranges.iter().enumerate() {
440            let block = text[range.start as usize..range.end as usize].to_string();
441            entity_blocks.push(block);
442            let replacement = if leave_stub && idx == 0 {
443                let owl_type = owl_type_for_kind(entity.kind);
444                format!(
445                    "{} a {owl_type} ;\n    owl:deprecated true ;\n    rdfs:comment \"Moved to {}\" .\n",
446                    prefixed_curie(iri, &namespaces),
447                    output_file.display()
448                )
449            } else {
450                String::new()
451            };
452            removals.push(EntityRemoval {
453                path: doc.path.clone(),
454                start: range.start,
455                end: range.end,
456                replacement,
457            });
458        }
459        blocks.push(entity_blocks.join("\n"));
460    }
461
462    let mut source_changes: BTreeMap<PathBuf, (String, String, Vec<Hunk>)> = BTreeMap::new();
463    let mut removals_by_path: BTreeMap<PathBuf, Vec<EntityRemoval>> = BTreeMap::new();
464    for removal in removals {
465        removals_by_path.entry(removal.path.clone()).or_default().push(removal);
466    }
467    for (path, mut path_removals) in removals_by_path {
468        path_removals.sort_by_key(|b| std::cmp::Reverse(b.start));
469        let original = source_texts.remove(&path).ok_or_else(|| {
470            RefactorError::Invalid(format!("missing source text for {}", path.display()))
471        })?;
472        let mut preview = original.clone();
473        let mut hunks = Vec::new();
474        for removal in path_removals {
475            let start = removal.start as usize;
476            let end = removal.end as usize;
477            let old_text = preview[start..end].to_string();
478            preview.replace_range(start..end, &removal.replacement);
479            hunks.push(Hunk {
480                start_byte: removal.start,
481                end_byte: removal.end,
482                old_text,
483                new_text: removal.replacement.clone(),
484            });
485        }
486        source_changes.insert(path, (original, preview, hunks));
487    }
488
489    let mut module_body = blocks.join("\n\n");
490    if !module_body.ends_with('\n') {
491        module_body.push('\n');
492    }
493    let prefix_header: String = prefix_lines.into_iter().collect::<Vec<_>>().join("\n");
494    let module_text = if prefix_header.is_empty() {
495        module_body
496    } else {
497        format!("{prefix_header}\n\n{module_body}")
498    };
499
500    let mut changes: Vec<FileChange> = source_changes
501        .into_iter()
502        .map(|(path, (original, preview, hunks))| FileChange {
503            path: path.clone(),
504            preview_text: preview,
505            original_text: original,
506            hunks,
507        })
508        .collect();
509
510    let output_original = if output_file.exists() {
511        read_source_text(output_file, document_overrides)?
512    } else {
513        String::new()
514    };
515    let output_preview = if output_original.is_empty() {
516        module_text.clone()
517    } else {
518        format!("{output_original}\n\n{module_text}")
519    };
520    changes.push(FileChange {
521        path: output_file.to_path_buf(),
522        preview_text: output_preview,
523        original_text: output_original,
524        hunks: vec![Hunk {
525            start_byte: 0,
526            end_byte: 0,
527            old_text: String::new(),
528            new_text: module_text,
529        }],
530    });
531
532    if leave_stub {
533        warnings.push("left deprecated stubs in source files".to_string());
534    }
535
536    Ok(RefactorPlan { changes, warnings })
537}