use super::decompile;
use super::editing::ChangeTracker;
use super::model::{ElementId, Model};
use std::collections::HashMap;
#[derive(Clone, Debug, Default)]
pub struct SourceMap {
spans: HashMap<ElementId, (usize, usize)>,
}
impl SourceMap {
pub fn build(model: &Model) -> (String, Self) {
let mut ctx = SourceMapBuilder::new(model);
ctx.build();
(ctx.output, ctx.source_map)
}
pub fn span(&self, id: &ElementId) -> Option<(usize, usize)> {
self.spans.get(id).copied()
}
pub fn mapped_ids(&self) -> impl Iterator<Item = &ElementId> {
self.spans.keys()
}
pub fn len(&self) -> usize {
self.spans.len()
}
pub fn is_empty(&self) -> bool {
self.spans.is_empty()
}
pub fn find_mapped_ancestor(&self, id: &ElementId, model: &Model) -> Option<ElementId> {
let el = model.get(id)?;
let owner_id = el.owner.as_ref()?;
if self.spans.contains_key(owner_id) {
Some(owner_id.clone())
} else {
self.find_mapped_ancestor(owner_id, model)
}
}
}
struct SourceMapBuilder<'a> {
model: &'a Model,
output: String,
source_map: SourceMap,
}
impl<'a> SourceMapBuilder<'a> {
fn new(model: &'a Model) -> Self {
Self {
model,
output: String::new(),
source_map: SourceMap::default(),
}
}
fn build(&mut self) {
let result = decompile(self.model);
self.output = result.text.clone();
self.map_root_elements(&result.text);
}
fn map_root_elements(&mut self, full_text: &str) {
let mut search_from = 0;
for root_id in &self.model.roots {
if let Some(root_el) = self.model.get(root_id) {
let sub_model = build_subtree_model(self.model, root_id);
let sub_result = decompile(&sub_model);
let sub_text = sub_result.text.trim();
if sub_text.is_empty() {
continue;
}
if let Some(pos) = full_text[search_from..].find(sub_text) {
let start = search_from + pos;
let end = start + sub_text.len();
self.source_map.spans.insert(root_id.clone(), (start, end));
search_from = end;
self.map_children(root_el, start, &full_text[start..end]);
}
}
}
}
fn map_children(
&mut self,
parent: &super::model::Element,
parent_start: usize,
parent_text: &str,
) {
for child in self.model.owned_members(&parent.id) {
if child.kind.is_relationship() {
continue;
}
let sub_model = build_subtree_model(self.model, &child.id);
let sub_result = decompile(&sub_model);
let sub_text = sub_result.text.trim();
if sub_text.is_empty() {
continue;
}
if let Some(pos) = parent_text.find(sub_text) {
let start = parent_start + pos;
let end = start + sub_text.len();
self.source_map.spans.insert(child.id.clone(), (start, end));
}
}
}
}
fn build_subtree_model(model: &Model, root_id: &ElementId) -> Model {
use super::model::Model as M;
let mut sub = M::new();
fn collect_element(model: &Model, id: &ElementId, sub: &mut M, is_root: bool) {
if let Some(el) = model.get(id) {
let mut cloned = el.clone();
if is_root {
cloned.owner = None; }
sub.elements.insert(id.clone(), cloned);
if is_root {
sub.roots.push(id.clone());
}
for child_id in &el.owned_elements {
collect_element(model, child_id, sub, false);
}
}
}
collect_element(model, root_id, &mut sub, true);
let rel_elements: Vec<_> = model
.elements
.values()
.filter(|e| {
e.relationship.as_ref().is_some_and(|rd| {
rd.source.iter().any(|s| sub.elements.contains_key(s))
&& rd.target.iter().any(|t| sub.elements.contains_key(t))
})
})
.cloned()
.collect();
for re in rel_elements {
sub.elements.entry(re.id.clone()).or_insert(re);
}
sub
}
pub fn render_dirty(
original_text: &str,
source_map: &SourceMap,
model: &Model,
tracker: &ChangeTracker,
) -> String {
if !tracker.has_changes() {
return original_text.to_string();
}
let mut regions_to_patch: Vec<PatchRegion> = Vec::new();
for dirty_id in tracker.dirty_elements() {
let target_id = if source_map.span(dirty_id).is_some() {
dirty_id.clone()
} else if let Some(ancestor) = source_map.find_mapped_ancestor(dirty_id, model) {
ancestor
} else {
return decompile(model).text;
};
if regions_to_patch.iter().any(|r| r.id == target_id) {
continue;
}
if let Some((start, end)) = source_map.span(&target_id) {
let sub_model = build_subtree_model(model, &target_id);
let new_text = decompile(&sub_model).text;
let trimmed = new_text.trim().to_string();
regions_to_patch.push(PatchRegion {
id: target_id,
start,
end,
replacement: trimmed,
});
}
}
for removed_id in tracker.removed_elements() {
if let Some((start, end)) = source_map.span(removed_id) {
if !regions_to_patch.iter().any(|r| r.start == start) {
regions_to_patch.push(PatchRegion {
id: removed_id.clone(),
start,
end,
replacement: String::new(),
});
}
}
}
if regions_to_patch.is_empty() {
return decompile(model).text;
}
regions_to_patch.sort_by(|a, b| b.start.cmp(&a.start));
let mut result = original_text.to_string();
for patch in ®ions_to_patch {
let start = patch.start.min(result.len());
let end = patch.end.min(result.len());
result.replace_range(start..end, &patch.replacement);
}
result = result.lines().collect::<Vec<_>>().join("\n");
result
}
struct PatchRegion {
id: ElementId,
start: usize,
end: usize,
replacement: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::interchange::editing::ChangeTracker;
use crate::interchange::host::ModelHost;
use crate::interchange::model::{Element, ElementKind};
#[test]
fn source_map_captures_root_spans() {
let host = ModelHost::from_text("package P { part def A; }").expect("should parse");
let (text, sm) = SourceMap::build(host.model());
assert!(!sm.is_empty(), "source map should have entries");
assert!(text.contains("package P"));
let p_id = host.find_by_name("P")[0].id().clone();
let span = sm.span(&p_id);
assert!(span.is_some(), "should have a span for P");
let (start, end) = span.unwrap();
let region = &text[start..end];
assert!(
region.contains("package P"),
"region should contain package P, got: {region}"
);
}
#[test]
fn source_map_maps_children() {
let host =
ModelHost::from_text("package P { part def A; part def B; }").expect("should parse");
let (text, sm) = SourceMap::build(host.model());
let a_id = host.find_by_name("A")[0].id().clone();
let _b_id = host.find_by_name("B")[0].id().clone();
assert!(!sm.is_empty(), "should have at least the root mapped");
if let Some((start, end)) = sm.span(&a_id) {
let region = &text[start..end];
assert!(
region.contains("A"),
"A's region should contain 'A', got: {region}"
);
}
}
#[test]
fn render_dirty_renames_element() {
let mut host = ModelHost::from_text("package P { part def Vehicle; part def Wheel; }")
.expect("should parse");
let (text, sm) = SourceMap::build(host.model());
let mut tracker = ChangeTracker::new();
let v_id = host.find_by_name("Vehicle")[0].id().clone();
tracker.rename(host.model_mut(), &v_id, "Car");
let patched = render_dirty(&text, &sm, host.model(), &tracker);
assert!(
patched.contains("Car"),
"should contain renamed 'Car': {patched}"
);
assert!(
patched.contains("Wheel"),
"should still contain 'Wheel': {patched}"
);
}
#[test]
fn render_dirty_no_changes_returns_original() {
let host = ModelHost::from_text("package P;").expect("should parse");
let (text, sm) = SourceMap::build(host.model());
let tracker = ChangeTracker::new();
let result = render_dirty(&text, &sm, host.model(), &tracker);
assert_eq!(result, text);
}
#[test]
fn render_dirty_add_element_falls_back_to_full() {
let mut host = ModelHost::from_text("package P;").expect("should parse");
let (text, sm) = SourceMap::build(host.model());
let mut tracker = ChangeTracker::new();
let p_id = host.find_by_name("P")[0].id().clone();
let new_el = Element::new("new1", ElementKind::PartDefinition).with_name("Widget");
tracker.add_element(host.model_mut(), new_el, Some(&p_id));
let patched = render_dirty(&text, &sm, host.model(), &tracker);
assert!(
patched.contains("Widget") || patched.contains("package P"),
"patched should reflect changes: {patched}"
);
}
#[test]
fn render_dirty_remove_element() {
let mut host =
ModelHost::from_text("package P { part def A; part def B; }").expect("should parse");
let (text, sm) = SourceMap::build(host.model());
let mut tracker = ChangeTracker::new();
let a_id = host.find_by_name("A")[0].id().clone();
tracker.remove_element(host.model_mut(), &a_id);
let patched = render_dirty(&text, &sm, host.model(), &tracker);
assert!(patched.contains("B"), "B should still be there: {patched}");
}
#[test]
fn source_map_multiple_roots() {
let host = ModelHost::from_text("package A; package B;").expect("should parse");
let (_text, sm) = SourceMap::build(host.model());
let a_id = host.find_by_name("A")[0].id().clone();
let b_id = host.find_by_name("B")[0].id().clone();
let a_span = sm.span(&a_id);
let b_span = sm.span(&b_id);
assert!(a_span.is_some(), "should have span for A");
assert!(b_span.is_some(), "should have span for B");
if let (Some((a_start, _)), Some((b_start, _))) = (a_span, b_span) {
assert!(a_start < b_start, "A should appear before B in text");
}
}
#[test]
fn build_subtree_model_preserves_relationships() {
let host = ModelHost::from_text("package P { part def Base; part def Derived :> Base; }")
.expect("should parse");
let p_id = host.find_by_name("P")[0].id().clone();
let sub = build_subtree_model(host.model(), &p_id);
assert!(
sub.element_count() >= 3,
"should have P, Base, Derived (and possibly rel elements)"
);
assert!(sub.relationship_count() >= 1, "should have specialization");
}
}