syster-base 0.4.0-alpha

Core library for SysML v2 and KerML parsing, AST, and semantic analysis
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
//! Source-map and region-based re-rendering for incremental edits.
//!
//! After mutating elements in a [`Model`] through [`ChangeTracker`],
//! the renderer can update only the dirty subtrees, preserving
//! formatting and byte offsets of unchanged regions.
//!
//! ## Architecture
//!
//! ```text
//!  ┌──────────┐              ┌────────────┐       ┌───────────┐
//!  │ Original │  decompile   │ SourceMap   │       │ Dirty IDs │
//!  │ Model    │────────────▶ │ id → span  │       │ from      │
//!  │          │              │             │       │ tracker   │
//!  └──────────┘              └──────┬──────┘       └─────┬─────┘
//!                                   │                    │
//!                                   ▼                    ▼
//!                           ┌──────────────────────────────────┐
//!                           │   render_dirty()                 │
//!                           │   • re-decompile dirty subtrees  │
//!                           │   • splice into original text    │
//!                           └──────────────────────────────────┘
//! ```
//!
//! ## Usage
//!
//! ```ignore
//! use syster::ide::AnalysisHost;
//!
//! let mut host = AnalysisHost::new();
//! host.set_file_content("model.sysml", "package P { part def A; }");
//!
//! let result = host.apply_model_edit("model.sysml", |model, tracker| {
//!     let a_id = model.find_by_name("A")[0].id().clone();
//!     tracker.rename(model, &a_id, "B");
//! });
//! assert!(result.rendered_text.contains("part def B"));
//! ```

use super::decompile;
use super::editing::ChangeTracker;
use super::model::{ElementId, Model};
use std::collections::HashMap;

/// A mapping from element IDs to their byte spans in generated text.
///
/// Built by decompiling a model with span tracking enabled.
#[derive(Clone, Debug, Default)]
pub struct SourceMap {
    /// ElementId → (start_byte, end_byte) in the generated text.
    spans: HashMap<ElementId, (usize, usize)>,
}

impl SourceMap {
    /// Build a source map by decompiling the model and recording spans.
    ///
    /// Returns `(generated_text, source_map)`.
    pub fn build(model: &Model) -> (String, Self) {
        let mut ctx = SourceMapBuilder::new(model);
        ctx.build();
        (ctx.output, ctx.source_map)
    }

    /// Get the byte span for an element.
    pub fn span(&self, id: &ElementId) -> Option<(usize, usize)> {
        self.spans.get(id).copied()
    }

    /// All mapped element IDs.
    pub fn mapped_ids(&self) -> impl Iterator<Item = &ElementId> {
        self.spans.keys()
    }

    /// Number of mapped elements.
    pub fn len(&self) -> usize {
        self.spans.len()
    }

    /// Whether the map is empty.
    pub fn is_empty(&self) -> bool {
        self.spans.is_empty()
    }

    /// Find the nearest ancestor of `id` that has a span mapping.
    /// This is used when a dirty element was newly created and has
    /// no span, but its parent does — we re-render the parent region.
    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)
        }
    }
}

/// Builder that wraps the decompiler to track spans.
///
/// Strategy: Decompile each root element individually so we can
/// record the byte offset before and after each one. Then for
/// nested elements, we use the decompiler's output and track
/// at the top-level granularity (plus direct children).
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) {
        // Decompile the full model first to get the complete text
        let result = decompile(self.model);
        self.output = result.text.clone();

        // Now build the span map by finding each element's text region.
        // We do this by decompiling individual root subtrees and matching.
        self.map_root_elements(&result.text);
    }

    /// Map root elements to their byte spans in the full text.
    fn map_root_elements(&mut self, full_text: &str) {
        // Strategy: decompile each root element individually as a
        // single-root model, then find that text in the full output.
        // For roots this is reliable since they appear in order.
        let mut search_from = 0;

        for root_id in &self.model.roots {
            if let Some(root_el) = self.model.get(root_id) {
                // Build a single-root sub-model
                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;
                }

                // Find this text in the full output starting from search_from
                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;

                    // Also map direct children within this root
                    self.map_children(root_el, start, &full_text[start..end]);
                }
            }
        }
    }

    /// Map direct children of an element to spans within the parent's text.
    fn map_children(
        &mut self,
        parent: &super::model::Element,
        parent_start: usize,
        parent_text: &str,
    ) {
        // Use owned_members() to look through membership wrappers
        for child in self.model.owned_members(&parent.id) {
            // Skip relationship/transparent elements
            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;
            }

            // Find within parent text
            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));
            }
        }
    }
}

/// Build a minimal sub-model containing just the subtree rooted at `root_id`.
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; // Make it a root in the sub-model
            }
            sub.elements.insert(id.clone(), cloned);
            if is_root {
                sub.roots.push(id.clone());
            }

            // Recursively include children
            for child_id in &el.owned_elements {
                collect_element(model, child_id, sub, false);
            }
        }
    }

    collect_element(model, root_id, &mut sub, true);

    // Include relevant relationships (from element-based store)
    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
}

// ============================================================================
// DIRTY RENDERING
// ============================================================================

/// Re-render only the dirty subtrees and splice into the original text.
///
/// Elements that have changed (according to the tracker) are
/// re-decompiled and their text is patched into the original at
/// the recorded byte offsets. Unchanged regions are preserved.
///
/// Returns the patched text.
pub fn render_dirty(
    original_text: &str,
    source_map: &SourceMap,
    model: &Model,
    tracker: &ChangeTracker,
) -> String {
    if !tracker.has_changes() {
        return original_text.to_string();
    }

    // Collect the elements that need re-rendering.
    // For each dirty element, find its nearest mapped ancestor
    // (or itself if mapped) and re-render that region.
    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 {
            // No mapped span at all — will need a full re-render
            return decompile(model).text;
        };

        // Avoid duplicates
        if regions_to_patch.iter().any(|r| r.id == target_id) {
            continue;
        }

        if let Some((start, end)) = source_map.span(&target_id) {
            // Re-decompile just this subtree
            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,
            });
        }
    }

    // Handle removed elements
    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() {
        // Dirty elements had no spans — do full re-render
        return decompile(model).text;
    }

    // Sort by start offset (descending) so we can splice from back to front
    regions_to_patch.sort_by(|a, b| b.start.cmp(&a.start));

    let mut result = original_text.to_string();
    for patch in &regions_to_patch {
        let start = patch.start.min(result.len());
        let end = patch.end.min(result.len());
        result.replace_range(start..end, &patch.replacement);
    }

    // Clean up excessive blank lines from removals (collapse 3+ blank lines to 2)
    result = result.lines().collect::<Vec<_>>().join("\n");

    result
}

/// Internal: a region of text to replace.
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"));

        // Find the root package
        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();

        // Children may or may not have spans depending on decompiler output matching
        // At minimum, verify the source map is non-empty
        assert!(!sm.is_empty(), "should have at least the root mapped");

        // If A has a span, verify it's reasonable
        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}"
        );
        // Wheel should be preserved
        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);
        // The patched text should contain Widget (via full re-render or parent patch)
        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);
        // B should survive
        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");

        // A should come before 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");
    }
}