Skip to main content

fresh/primitives/
text_property.rs

1//! Text properties for embedding metadata in text ranges
2//!
3//! This module provides Emacs-style text properties that allow embedding
4//! arbitrary metadata (like source locations, severity levels, etc.) in
5//! specific ranges of text. This is essential for virtual buffers where
6//! each line might represent a diagnostic, search result, or other structured data.
7
8use std::ops::Range;
9
10// Re-export types from fresh-core for shared type usage
11pub use fresh_core::text_property::{TextProperty, TextPropertyEntry};
12
13/// A collected overlay from inline styling in TextPropertyEntry,
14/// with byte offsets converted to absolute positions in the full text.
15#[derive(Debug, Clone)]
16pub struct CollectedOverlay {
17    /// Absolute byte range in the assembled text
18    pub range: Range<usize>,
19    /// The overlay styling options
20    pub options: fresh_core::api::OverlayOptions,
21}
22
23/// Manager for text properties in a buffer
24///
25/// Stores and queries text properties efficiently. Properties can overlap
26/// and are sorted by start position for fast lookup.
27#[derive(Debug, Clone, Default)]
28pub struct TextPropertyManager {
29    /// All properties, sorted by start position
30    properties: Vec<TextProperty>,
31}
32
33impl TextPropertyManager {
34    /// Create a new empty property manager
35    pub fn new() -> Self {
36        Self {
37            properties: Vec::new(),
38        }
39    }
40
41    /// Add a text property
42    pub fn add(&mut self, property: TextProperty) {
43        // Insert in sorted order by start position
44        let pos = self
45            .properties
46            .binary_search_by_key(&property.start, |p| p.start)
47            .unwrap_or_else(|e| e);
48        self.properties.insert(pos, property);
49    }
50
51    /// Get all properties at a specific byte position
52    pub fn get_at(&self, pos: usize) -> Vec<&TextProperty> {
53        self.properties.iter().filter(|p| p.contains(pos)).collect()
54    }
55
56    /// Get all properties overlapping a range
57    pub fn get_overlapping(&self, range: &Range<usize>) -> Vec<&TextProperty> {
58        self.properties
59            .iter()
60            .filter(|p| p.overlaps(range))
61            .collect()
62    }
63
64    /// Clear all properties
65    pub fn clear(&mut self) {
66        self.properties.clear();
67    }
68
69    /// Remove all properties in a range
70    pub fn remove_in_range(&mut self, range: &Range<usize>) {
71        self.properties
72            .retain(|p| !p.overlaps(range) && !range.contains(&p.start));
73    }
74
75    /// Get all properties
76    pub fn all(&self) -> &[TextProperty] {
77        &self.properties
78    }
79
80    /// Check if there are any properties
81    pub fn is_empty(&self) -> bool {
82        self.properties.is_empty()
83    }
84
85    /// Get the number of properties
86    pub fn len(&self) -> usize {
87        self.properties.len()
88    }
89
90    /// Set all properties at once (replaces existing)
91    pub fn set_all(&mut self, properties: Vec<TextProperty>) {
92        self.properties = properties;
93        // Ensure sorted by start position
94        self.properties.sort_by_key(|p| p.start);
95    }
96
97    /// Merge properties from another source
98    ///
99    /// This is useful when setting buffer content with properties.
100    /// Returns the assembled text, the property manager, and any collected
101    /// inline overlay specifications (with absolute byte offsets).
102    pub fn from_entries(entries: Vec<TextPropertyEntry>) -> (String, Self, Vec<CollectedOverlay>) {
103        let mut text = String::new();
104        let mut manager = Self::new();
105        let mut collected_overlays = Vec::new();
106        let mut offset = 0;
107
108        for entry in entries {
109            let start = offset;
110            let entry_len = entry.text.len();
111            text.push_str(&entry.text);
112            let end = offset + entry_len;
113
114            if !entry.properties.is_empty() {
115                let property = TextProperty {
116                    start,
117                    end,
118                    properties: entry.properties,
119                };
120                manager.add(property);
121            }
122
123            // Collect whole-entry style
124            if let Some(style) = entry.style {
125                collected_overlays.push(CollectedOverlay {
126                    range: start..end,
127                    options: style,
128                });
129            }
130
131            // Collect sub-range inline overlays, converting to absolute offsets
132            for inline in entry.inline_overlays {
133                let abs_start = start + inline.start.min(entry_len);
134                let abs_end = start + inline.end.min(entry_len);
135                if abs_start < abs_end {
136                    collected_overlays.push(CollectedOverlay {
137                        range: abs_start..abs_end,
138                        options: inline.style,
139                    });
140                    // Create a TextProperty for inline overlays with properties
141                    if !inline.properties.is_empty() {
142                        let property = TextProperty {
143                            start: abs_start,
144                            end: abs_end,
145                            properties: inline.properties,
146                        };
147                        manager.add(property);
148                    }
149                }
150            }
151
152            offset = end;
153        }
154
155        (text, manager, collected_overlays)
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use serde::Deserialize;
163    use serde_json::json;
164
165    #[test]
166    fn test_text_property_contains() {
167        let prop = TextProperty::new(10, 20);
168        assert!(prop.contains(10));
169        assert!(prop.contains(15));
170        assert!(prop.contains(19));
171        assert!(!prop.contains(9));
172        assert!(!prop.contains(20));
173    }
174
175    #[test]
176    fn test_text_property_overlaps() {
177        let prop = TextProperty::new(10, 20);
178        assert!(prop.overlaps(&(5..15)));
179        assert!(prop.overlaps(&(15..25)));
180        assert!(prop.overlaps(&(10..20)));
181        assert!(prop.overlaps(&(12..18)));
182        assert!(!prop.overlaps(&(0..10)));
183        assert!(!prop.overlaps(&(20..30)));
184    }
185
186    #[test]
187    fn test_text_property_with_properties() {
188        let prop = TextProperty::new(0, 10)
189            .with_property("severity", json!("error"))
190            .with_property(
191                "location",
192                json!({"file": "test.rs", "line": 42, "column": 5}),
193            );
194
195        assert_eq!(prop.get("severity"), Some(&json!("error")));
196        assert_eq!(
197            prop.get("location"),
198            Some(&json!({"file": "test.rs", "line": 42, "column": 5}))
199        );
200        assert_eq!(prop.get("nonexistent"), None);
201    }
202
203    #[test]
204    fn test_text_property_get_as() {
205        let prop = TextProperty::new(0, 10)
206            .with_property("count", json!(42))
207            .with_property(
208                "location",
209                json!({"file": "test.rs", "line": 42, "column": 5}),
210            );
211
212        let count: Option<i64> = prop.get_as("count");
213        assert_eq!(count, Some(42));
214
215        #[derive(Debug, Deserialize, PartialEq)]
216        struct Location {
217            file: String,
218            line: u32,
219            column: u32,
220        }
221
222        let loc: Option<Location> = prop.get_as("location");
223        assert_eq!(
224            loc,
225            Some(Location {
226                file: "test.rs".to_string(),
227                line: 42,
228                column: 5,
229            })
230        );
231    }
232
233    #[test]
234    fn test_manager_add_and_get_at() {
235        let mut manager = TextPropertyManager::new();
236
237        manager.add(TextProperty::new(0, 10).with_property("id", json!("first")));
238        manager.add(TextProperty::new(5, 15).with_property("id", json!("second")));
239        manager.add(TextProperty::new(20, 30).with_property("id", json!("third")));
240
241        // Position 7 is covered by first and second
242        let props = manager.get_at(7);
243        assert_eq!(props.len(), 2);
244        assert_eq!(props[0].get("id"), Some(&json!("first")));
245        assert_eq!(props[1].get("id"), Some(&json!("second")));
246
247        // Position 25 is covered by third only
248        let props = manager.get_at(25);
249        assert_eq!(props.len(), 1);
250        assert_eq!(props[0].get("id"), Some(&json!("third")));
251
252        // Position 17 is not covered by any
253        let props = manager.get_at(17);
254        assert_eq!(props.len(), 0);
255    }
256
257    #[test]
258    fn test_manager_get_overlapping() {
259        let mut manager = TextPropertyManager::new();
260
261        manager.add(TextProperty::new(0, 10).with_property("id", json!("first")));
262        manager.add(TextProperty::new(20, 30).with_property("id", json!("second")));
263
264        // Range overlaps with first
265        let props = manager.get_overlapping(&(5..15));
266        assert_eq!(props.len(), 1);
267        assert_eq!(props[0].get("id"), Some(&json!("first")));
268
269        // Range overlaps with second
270        let props = manager.get_overlapping(&(25..35));
271        assert_eq!(props.len(), 1);
272        assert_eq!(props[0].get("id"), Some(&json!("second")));
273
274        // Range overlaps with neither
275        let props = manager.get_overlapping(&(12..18));
276        assert_eq!(props.len(), 0);
277
278        // Range overlaps with both
279        let props = manager.get_overlapping(&(0..30));
280        assert_eq!(props.len(), 2);
281    }
282
283    #[test]
284    fn test_manager_from_entries() {
285        let entries = vec![
286            TextPropertyEntry::text("Error at line 42\n")
287                .with_property("severity", json!("error"))
288                .with_property("line", json!(42)),
289            TextPropertyEntry::text("Warning at line 100\n")
290                .with_property("severity", json!("warning"))
291                .with_property("line", json!(100)),
292        ];
293
294        let (text, manager, _overlays) = TextPropertyManager::from_entries(entries);
295
296        assert_eq!(text, "Error at line 42\nWarning at line 100\n");
297        assert_eq!(manager.len(), 2);
298
299        // First property covers "Error at line 42\n" (17 bytes)
300        let first_props = manager.get_at(0);
301        assert_eq!(first_props.len(), 1);
302        assert_eq!(first_props[0].get("severity"), Some(&json!("error")));
303        assert_eq!(first_props[0].get("line"), Some(&json!(42)));
304        assert_eq!(first_props[0].start, 0);
305        assert_eq!(first_props[0].end, 17);
306
307        // Second property covers "Warning at line 100\n" (20 bytes)
308        let second_props = manager.get_at(17);
309        assert_eq!(second_props.len(), 1);
310        assert_eq!(second_props[0].get("severity"), Some(&json!("warning")));
311        assert_eq!(second_props[0].get("line"), Some(&json!(100)));
312        assert_eq!(second_props[0].start, 17);
313        assert_eq!(second_props[0].end, 37);
314    }
315
316    #[test]
317    fn test_manager_clear() {
318        let mut manager = TextPropertyManager::new();
319        manager.add(TextProperty::new(0, 10));
320        manager.add(TextProperty::new(20, 30));
321
322        assert_eq!(manager.len(), 2);
323        manager.clear();
324        assert_eq!(manager.len(), 0);
325        assert!(manager.is_empty());
326    }
327
328    #[test]
329    fn test_manager_remove_in_range() {
330        let mut manager = TextPropertyManager::new();
331        manager.add(TextProperty::new(0, 10).with_property("id", json!("first")));
332        manager.add(TextProperty::new(20, 30).with_property("id", json!("second")));
333        manager.add(TextProperty::new(40, 50).with_property("id", json!("third")));
334
335        // Remove properties overlapping with range 15-35
336        manager.remove_in_range(&(15..35));
337
338        // Should have removed second (20-30 overlaps with 15-35)
339        assert_eq!(manager.len(), 2);
340        let all = manager.all();
341        assert_eq!(all[0].get("id"), Some(&json!("first")));
342        assert_eq!(all[1].get("id"), Some(&json!("third")));
343    }
344}