mathypad_core/core/
highlighting.rs

1//! UI-agnostic syntax highlighting for mathematical expressions
2
3use crate::expression::parser::parse_line_reference;
4use crate::units::parse_unit;
5use std::collections::HashMap;
6
7/// A highlighted text span with semantic type information
8#[derive(Debug, Clone, PartialEq)]
9pub struct HighlightedSpan {
10    pub text: String,
11    pub highlight_type: HighlightType,
12}
13
14/// Types of syntax highlighting
15#[derive(Debug, Clone, PartialEq)]
16pub enum HighlightType {
17    /// Numeric literals (e.g., "123", "3.14", "1,000")
18    Number,
19    /// Unit names (e.g., "kg", "miles", "seconds")
20    Unit,
21    /// Line references (e.g., "line1", "line2")
22    LineReference,
23    /// Keywords (e.g., "to", "in", "of")
24    Keyword,
25    /// Mathematical operators (e.g., "+", "-", "*", "/", "^", "=")
26    Operator,
27    /// Variable names that are defined
28    Variable,
29    /// Function names (e.g., "sqrt")
30    Function,
31    /// Normal text (no special highlighting)
32    Normal,
33}
34
35impl HighlightType {
36    /// Get the standard RGB color values for this highlight type
37    /// Returns (red, green, blue) as u8 values
38    pub fn rgb_color(&self) -> (u8, u8, u8) {
39        match self {
40            HighlightType::Number => (173, 216, 230), // Light blue
41            HighlightType::Unit => (144, 238, 144),   // Light green
42            HighlightType::LineReference => (221, 160, 221), // Plum/magenta
43            HighlightType::Keyword => (255, 255, 0),  // Yellow
44            HighlightType::Operator => (0, 255, 255), // Cyan
45            HighlightType::Variable => (224, 255, 255), // Light cyan
46            HighlightType::Function => (0, 255, 255), // Cyan
47            HighlightType::Normal => (200, 200, 200), // Light gray
48        }
49    }
50}
51
52/// Parse text and return highlighted spans for syntax highlighting
53pub fn highlight_expression(
54    text: &str,
55    variables: &HashMap<String, String>,
56) -> Vec<HighlightedSpan> {
57    let mut spans = Vec::new();
58    let mut current_pos = 0;
59    let chars: Vec<char> = text.chars().collect();
60
61    while current_pos < chars.len() {
62        if chars[current_pos].is_ascii_alphabetic() {
63            // Handle potential units, keywords, and line references first
64            let start_pos = current_pos;
65
66            while current_pos < chars.len()
67                && (chars[current_pos].is_ascii_alphabetic()
68                    || chars[current_pos].is_ascii_digit()
69                    || chars[current_pos] == '_')
70            {
71                current_pos += 1;
72            }
73
74            let word_text: String = chars[start_pos..current_pos].iter().collect();
75
76            // Check if it's a valid unit, keyword, line reference, function, or variable
77            let highlight_type = if parse_line_reference(&word_text).is_some() {
78                HighlightType::LineReference
79            } else if word_text.to_lowercase() == "to"
80                || word_text.to_lowercase() == "in"
81                || word_text.to_lowercase() == "of"
82            {
83                HighlightType::Keyword
84            } else if word_text.to_lowercase() == "sqrt" || word_text.to_lowercase() == "sum_above"
85            {
86                HighlightType::Function
87            } else if parse_unit(&word_text).is_some() {
88                HighlightType::Unit
89            } else if variables.contains_key(&word_text) {
90                HighlightType::Variable
91            } else {
92                HighlightType::Normal
93            };
94
95            spans.push(HighlightedSpan {
96                text: word_text,
97                highlight_type,
98            });
99        } else if chars[current_pos].is_ascii_digit() || chars[current_pos] == '.' {
100            // Handle numbers
101            let start_pos = current_pos;
102            let mut has_digit = false;
103            let mut has_dot = false;
104
105            while current_pos < chars.len() {
106                let ch = chars[current_pos];
107                if ch.is_ascii_digit() {
108                    has_digit = true;
109                    current_pos += 1;
110                } else if ch == '.' && !has_dot {
111                    has_dot = true;
112                    current_pos += 1;
113                } else if ch == ',' {
114                    current_pos += 1;
115                } else {
116                    break;
117                }
118            }
119
120            let number_text: String = chars[start_pos..current_pos].iter().collect();
121
122            if has_digit {
123                spans.push(HighlightedSpan {
124                    text: number_text,
125                    highlight_type: HighlightType::Number,
126                });
127            } else {
128                spans.push(HighlightedSpan {
129                    text: number_text,
130                    highlight_type: HighlightType::Normal,
131                });
132                current_pos = start_pos + 1;
133            }
134        } else if chars[current_pos] == '%' {
135            // Handle percentage symbol as a unit
136            spans.push(HighlightedSpan {
137                text: "%".to_string(),
138                highlight_type: HighlightType::Unit,
139            });
140            current_pos += 1;
141        } else if "$€£¥₹₩".contains(chars[current_pos]) {
142            // Handle currency symbols as units
143            spans.push(HighlightedSpan {
144                text: chars[current_pos].to_string(),
145                highlight_type: HighlightType::Unit,
146            });
147            current_pos += 1;
148        } else if "+-*/()=^".contains(chars[current_pos]) {
149            // Handle operators (including assignment and exponentiation)
150            spans.push(HighlightedSpan {
151                text: chars[current_pos].to_string(),
152                highlight_type: HighlightType::Operator,
153            });
154            current_pos += 1;
155        } else {
156            // Handle other characters
157            spans.push(HighlightedSpan {
158                text: chars[current_pos].to_string(),
159                highlight_type: HighlightType::Normal,
160            });
161            current_pos += 1;
162        }
163    }
164
165    spans
166}
167
168/// Convenience function to highlight a single line with cursor position
169/// Returns the spans and the character index where the cursor should be highlighted
170pub fn highlight_expression_with_cursor(
171    text: &str,
172    cursor_col: usize,
173    variables: &HashMap<String, String>,
174) -> (Vec<HighlightedSpan>, usize) {
175    let spans = highlight_expression(text, variables);
176    // The cursor highlighting would be handled by the UI layer
177    // This function exists for API compatibility
178    (spans, cursor_col)
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn test_number_highlighting() {
187        let variables = HashMap::new();
188        let spans = highlight_expression("123.45", &variables);
189
190        assert_eq!(spans.len(), 1);
191        assert_eq!(spans[0].text, "123.45");
192        assert_eq!(spans[0].highlight_type, HighlightType::Number);
193    }
194
195    #[test]
196    fn test_operator_highlighting() {
197        let variables = HashMap::new();
198        let spans = highlight_expression("5 + 3", &variables);
199
200        assert_eq!(spans.len(), 5); // "5", " ", "+", " ", "3"
201        assert_eq!(spans[0].highlight_type, HighlightType::Number);
202        assert_eq!(spans[1].highlight_type, HighlightType::Normal); // space
203        assert_eq!(spans[2].highlight_type, HighlightType::Operator);
204        assert_eq!(spans[3].highlight_type, HighlightType::Normal); // space
205        assert_eq!(spans[4].highlight_type, HighlightType::Number);
206    }
207
208    #[test]
209    fn test_unit_highlighting() {
210        let variables = HashMap::new();
211        let spans = highlight_expression("100 GB", &variables);
212
213        assert_eq!(spans.len(), 3); // "100", " ", "GB"
214        assert_eq!(spans[0].highlight_type, HighlightType::Number);
215        assert_eq!(spans[1].highlight_type, HighlightType::Normal); // space
216        assert_eq!(spans[2].highlight_type, HighlightType::Unit);
217    }
218
219    #[test]
220    fn test_line_reference_highlighting() {
221        let variables = HashMap::new();
222        let spans = highlight_expression("line1 + 5", &variables);
223
224        assert!(
225            spans
226                .iter()
227                .any(|s| s.highlight_type == HighlightType::LineReference)
228        );
229        assert!(spans.iter().any(|s| s.text == "line1"));
230    }
231
232    #[test]
233    fn test_variable_highlighting() {
234        let mut variables = HashMap::new();
235        variables.insert("x".to_string(), "42".to_string());
236
237        let spans = highlight_expression("x * 2", &variables);
238
239        assert!(
240            spans
241                .iter()
242                .any(|s| s.highlight_type == HighlightType::Variable)
243        );
244        assert!(spans.iter().any(|s| s.text == "x"));
245    }
246
247    #[test]
248    fn test_keyword_highlighting() {
249        let variables = HashMap::new();
250        let spans = highlight_expression("100 GB to MB", &variables);
251
252        assert!(
253            spans
254                .iter()
255                .any(|s| s.highlight_type == HighlightType::Keyword)
256        );
257        assert!(spans.iter().any(|s| s.text == "to"));
258    }
259
260    #[test]
261    fn test_function_highlighting() {
262        let variables = HashMap::new();
263        let spans = highlight_expression("sqrt(16)", &variables);
264
265        assert!(
266            spans
267                .iter()
268                .any(|s| s.highlight_type == HighlightType::Function)
269        );
270        assert!(spans.iter().any(|s| s.text == "sqrt"));
271
272        // Test sum_above function highlighting
273        let spans = highlight_expression("sum_above()", &variables);
274        assert!(
275            spans
276                .iter()
277                .any(|s| s.highlight_type == HighlightType::Function)
278        );
279        assert!(spans.iter().any(|s| s.text == "sum_above"));
280    }
281}