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/// Manager for text properties in a buffer
14///
15/// Stores and queries text properties efficiently. Properties can overlap
16/// and are sorted by start position for fast lookup.
17#[derive(Debug, Clone, Default)]
18pub struct TextPropertyManager {
19    /// All properties, sorted by start position
20    properties: Vec<TextProperty>,
21}
22
23impl TextPropertyManager {
24    /// Create a new empty property manager
25    pub fn new() -> Self {
26        Self {
27            properties: Vec::new(),
28        }
29    }
30
31    /// Add a text property
32    pub fn add(&mut self, property: TextProperty) {
33        // Insert in sorted order by start position
34        let pos = self
35            .properties
36            .binary_search_by_key(&property.start, |p| p.start)
37            .unwrap_or_else(|e| e);
38        self.properties.insert(pos, property);
39    }
40
41    /// Get all properties at a specific byte position
42    pub fn get_at(&self, pos: usize) -> Vec<&TextProperty> {
43        self.properties.iter().filter(|p| p.contains(pos)).collect()
44    }
45
46    /// Get all properties overlapping a range
47    pub fn get_overlapping(&self, range: &Range<usize>) -> Vec<&TextProperty> {
48        self.properties
49            .iter()
50            .filter(|p| p.overlaps(range))
51            .collect()
52    }
53
54    /// Clear all properties
55    pub fn clear(&mut self) {
56        self.properties.clear();
57    }
58
59    /// Remove all properties in a range
60    pub fn remove_in_range(&mut self, range: &Range<usize>) {
61        self.properties
62            .retain(|p| !p.overlaps(range) && !range.contains(&p.start));
63    }
64
65    /// Get all properties
66    pub fn all(&self) -> &[TextProperty] {
67        &self.properties
68    }
69
70    /// Check if there are any properties
71    pub fn is_empty(&self) -> bool {
72        self.properties.is_empty()
73    }
74
75    /// Get the number of properties
76    pub fn len(&self) -> usize {
77        self.properties.len()
78    }
79
80    /// Set all properties at once (replaces existing)
81    pub fn set_all(&mut self, properties: Vec<TextProperty>) {
82        self.properties = properties;
83        // Ensure sorted by start position
84        self.properties.sort_by_key(|p| p.start);
85    }
86
87    /// Merge properties from another source
88    ///
89    /// This is useful when setting buffer content with properties
90    pub fn from_entries(entries: Vec<TextPropertyEntry>) -> (String, Self) {
91        let mut text = String::new();
92        let mut manager = Self::new();
93        let mut offset = 0;
94
95        for entry in entries {
96            let start = offset;
97            text.push_str(&entry.text);
98            let end = offset + entry.text.len();
99
100            if !entry.properties.is_empty() {
101                let property = TextProperty {
102                    start,
103                    end,
104                    properties: entry.properties,
105                };
106                manager.add(property);
107            }
108
109            offset = end;
110        }
111
112        (text, manager)
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use serde::Deserialize;
120    use serde_json::json;
121
122    #[test]
123    fn test_text_property_contains() {
124        let prop = TextProperty::new(10, 20);
125        assert!(prop.contains(10));
126        assert!(prop.contains(15));
127        assert!(prop.contains(19));
128        assert!(!prop.contains(9));
129        assert!(!prop.contains(20));
130    }
131
132    #[test]
133    fn test_text_property_overlaps() {
134        let prop = TextProperty::new(10, 20);
135        assert!(prop.overlaps(&(5..15)));
136        assert!(prop.overlaps(&(15..25)));
137        assert!(prop.overlaps(&(10..20)));
138        assert!(prop.overlaps(&(12..18)));
139        assert!(!prop.overlaps(&(0..10)));
140        assert!(!prop.overlaps(&(20..30)));
141    }
142
143    #[test]
144    fn test_text_property_with_properties() {
145        let prop = TextProperty::new(0, 10)
146            .with_property("severity", json!("error"))
147            .with_property(
148                "location",
149                json!({"file": "test.rs", "line": 42, "column": 5}),
150            );
151
152        assert_eq!(prop.get("severity"), Some(&json!("error")));
153        assert_eq!(
154            prop.get("location"),
155            Some(&json!({"file": "test.rs", "line": 42, "column": 5}))
156        );
157        assert_eq!(prop.get("nonexistent"), None);
158    }
159
160    #[test]
161    fn test_text_property_get_as() {
162        let prop = TextProperty::new(0, 10)
163            .with_property("count", json!(42))
164            .with_property(
165                "location",
166                json!({"file": "test.rs", "line": 42, "column": 5}),
167            );
168
169        let count: Option<i64> = prop.get_as("count");
170        assert_eq!(count, Some(42));
171
172        #[derive(Debug, Deserialize, PartialEq)]
173        struct Location {
174            file: String,
175            line: u32,
176            column: u32,
177        }
178
179        let loc: Option<Location> = prop.get_as("location");
180        assert_eq!(
181            loc,
182            Some(Location {
183                file: "test.rs".to_string(),
184                line: 42,
185                column: 5,
186            })
187        );
188    }
189
190    #[test]
191    fn test_manager_add_and_get_at() {
192        let mut manager = TextPropertyManager::new();
193
194        manager.add(TextProperty::new(0, 10).with_property("id", json!("first")));
195        manager.add(TextProperty::new(5, 15).with_property("id", json!("second")));
196        manager.add(TextProperty::new(20, 30).with_property("id", json!("third")));
197
198        // Position 7 is covered by first and second
199        let props = manager.get_at(7);
200        assert_eq!(props.len(), 2);
201        assert_eq!(props[0].get("id"), Some(&json!("first")));
202        assert_eq!(props[1].get("id"), Some(&json!("second")));
203
204        // Position 25 is covered by third only
205        let props = manager.get_at(25);
206        assert_eq!(props.len(), 1);
207        assert_eq!(props[0].get("id"), Some(&json!("third")));
208
209        // Position 17 is not covered by any
210        let props = manager.get_at(17);
211        assert_eq!(props.len(), 0);
212    }
213
214    #[test]
215    fn test_manager_get_overlapping() {
216        let mut manager = TextPropertyManager::new();
217
218        manager.add(TextProperty::new(0, 10).with_property("id", json!("first")));
219        manager.add(TextProperty::new(20, 30).with_property("id", json!("second")));
220
221        // Range overlaps with first
222        let props = manager.get_overlapping(&(5..15));
223        assert_eq!(props.len(), 1);
224        assert_eq!(props[0].get("id"), Some(&json!("first")));
225
226        // Range overlaps with second
227        let props = manager.get_overlapping(&(25..35));
228        assert_eq!(props.len(), 1);
229        assert_eq!(props[0].get("id"), Some(&json!("second")));
230
231        // Range overlaps with neither
232        let props = manager.get_overlapping(&(12..18));
233        assert_eq!(props.len(), 0);
234
235        // Range overlaps with both
236        let props = manager.get_overlapping(&(0..30));
237        assert_eq!(props.len(), 2);
238    }
239
240    #[test]
241    fn test_manager_from_entries() {
242        let entries = vec![
243            TextPropertyEntry::text("Error at line 42\n")
244                .with_property("severity", json!("error"))
245                .with_property("line", json!(42)),
246            TextPropertyEntry::text("Warning at line 100\n")
247                .with_property("severity", json!("warning"))
248                .with_property("line", json!(100)),
249        ];
250
251        let (text, manager) = TextPropertyManager::from_entries(entries);
252
253        assert_eq!(text, "Error at line 42\nWarning at line 100\n");
254        assert_eq!(manager.len(), 2);
255
256        // First property covers "Error at line 42\n" (17 bytes)
257        let first_props = manager.get_at(0);
258        assert_eq!(first_props.len(), 1);
259        assert_eq!(first_props[0].get("severity"), Some(&json!("error")));
260        assert_eq!(first_props[0].get("line"), Some(&json!(42)));
261        assert_eq!(first_props[0].start, 0);
262        assert_eq!(first_props[0].end, 17);
263
264        // Second property covers "Warning at line 100\n" (20 bytes)
265        let second_props = manager.get_at(17);
266        assert_eq!(second_props.len(), 1);
267        assert_eq!(second_props[0].get("severity"), Some(&json!("warning")));
268        assert_eq!(second_props[0].get("line"), Some(&json!(100)));
269        assert_eq!(second_props[0].start, 17);
270        assert_eq!(second_props[0].end, 37);
271    }
272
273    #[test]
274    fn test_manager_clear() {
275        let mut manager = TextPropertyManager::new();
276        manager.add(TextProperty::new(0, 10));
277        manager.add(TextProperty::new(20, 30));
278
279        assert_eq!(manager.len(), 2);
280        manager.clear();
281        assert_eq!(manager.len(), 0);
282        assert!(manager.is_empty());
283    }
284
285    #[test]
286    fn test_manager_remove_in_range() {
287        let mut manager = TextPropertyManager::new();
288        manager.add(TextProperty::new(0, 10).with_property("id", json!("first")));
289        manager.add(TextProperty::new(20, 30).with_property("id", json!("second")));
290        manager.add(TextProperty::new(40, 50).with_property("id", json!("third")));
291
292        // Remove properties overlapping with range 15-35
293        manager.remove_in_range(&(15..35));
294
295        // Should have removed second (20-30 overlaps with 15-35)
296        assert_eq!(manager.len(), 2);
297        let all = manager.all();
298        assert_eq!(all[0].get("id"), Some(&json!("first")));
299        assert_eq!(all[1].get("id"), Some(&json!("third")));
300    }
301}