Skip to main content

hypen_engine/ir/
icon.rs

1use indexmap::IndexMap;
2use serde::{Deserialize, Serialize};
3
4/// A single SVG path within an icon.
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct IconPath {
7    /// SVG path data (the `d` attribute)
8    pub d: String,
9    /// Fill color (default: "none")
10    #[serde(default = "default_fill")]
11    pub fill: String,
12    /// Stroke color (default: "currentColor")
13    #[serde(default = "default_stroke")]
14    pub stroke: String,
15    /// Stroke width (default: 2.0)
16    #[serde(default = "default_stroke_width")]
17    pub stroke_width: f64,
18    /// Stroke line cap (default: "round")
19    #[serde(default = "default_stroke_linecap")]
20    pub stroke_linecap: String,
21    /// Stroke line join (default: "round")
22    #[serde(default = "default_stroke_linejoin")]
23    pub stroke_linejoin: String,
24}
25
26fn default_fill() -> String {
27    "none".to_string()
28}
29fn default_stroke() -> String {
30    "currentColor".to_string()
31}
32fn default_stroke_width() -> f64 {
33    2.0
34}
35fn default_stroke_linecap() -> String {
36    "round".to_string()
37}
38fn default_stroke_linejoin() -> String {
39    "round".to_string()
40}
41
42/// A resolved icon containing its SVG path data.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct IconData {
45    /// SVG viewBox (default: "0 0 24 24")
46    #[serde(default = "default_viewbox")]
47    pub view_box: String,
48    /// SVG paths making up this icon
49    pub paths: Vec<IconPath>,
50}
51
52fn default_viewbox() -> String {
53    "0 0 24 24".to_string()
54}
55
56/// Registry that maps resource names to their SVG data.
57///
58/// Resources are a flat name → parsed SVG map. The engine resolves
59/// `Icon("heart")` or `Icon(@resources.heart)` into concrete SVG paths
60/// at render time, so the patch sent to the client carries pre-resolved data.
61#[derive(Debug, Default)]
62pub struct ResourceRegistry {
63    /// Resources indexed by name
64    resources: IndexMap<String, IconData>,
65}
66
67impl ResourceRegistry {
68    pub fn new() -> Self {
69        Self {
70            resources: IndexMap::new(),
71        }
72    }
73
74    /// Register a single resource from raw SVG content.
75    pub fn register(&mut self, name: &str, svg: &str) {
76        self.resources.insert(name.to_string(), parse_svg(svg));
77    }
78
79    /// Register multiple resources from a name → SVG map.
80    pub fn register_map(&mut self, map: IndexMap<String, String>) {
81        for (name, svg) in map {
82            self.resources.insert(name, parse_svg(&svg));
83        }
84    }
85
86    /// Resolve a resource by name.
87    pub fn resolve(&self, name: &str) -> Option<&IconData> {
88        self.resources.get(name)
89    }
90
91    /// Convert resolved icon data to props that will be sent in the Create patch.
92    /// Returns a serde_json::Value map containing `paths`, `viewBox`, etc.
93    pub fn to_props(icon: &IconData) -> serde_json::Value {
94        let paths: Vec<serde_json::Value> = icon
95            .paths
96            .iter()
97            .map(|p| {
98                serde_json::json!({
99                    "d": p.d,
100                    "fill": p.fill,
101                    "stroke": p.stroke,
102                    "strokeWidth": p.stroke_width,
103                    "strokeLinecap": p.stroke_linecap,
104                    "strokeLinejoin": p.stroke_linejoin,
105                })
106            })
107            .collect();
108
109        serde_json::json!({
110            "paths": paths,
111            "viewBox": icon.view_box,
112        })
113    }
114
115    /// Check if the registry has any registered resources.
116    pub fn is_empty(&self) -> bool {
117        self.resources.is_empty()
118    }
119}
120
121// ── SVG Parsing ─────────────────────────────────────────────────────────
122
123/// Parse an SVG string and extract icon data (viewBox + paths).
124///
125/// Extracts `<path>` elements and converts `<circle>`, `<rect>`, `<line>`,
126/// `<polyline>`, and `<polygon>` to path equivalents.
127///
128/// ```rust
129/// # use hypen_engine::ir::icon::parse_svg;
130/// let svg = r#"<svg viewBox="0 0 24 24"><path d="M5 12h14"/></svg>"#;
131/// let icon = parse_svg(svg);
132/// assert_eq!(icon.view_box, "0 0 24 24");
133/// assert_eq!(icon.paths.len(), 1);
134/// assert_eq!(icon.paths[0].d, "M5 12h14");
135/// ```
136pub fn parse_svg(svg: &str) -> IconData {
137    let root = RootDefaults::extract(svg);
138    let view_box = root.view_box.clone().unwrap_or_else(|| "0 0 24 24".to_string());
139    let mut paths = Vec::new();
140
141    // Extract <path> elements
142    for cap in RegexLite::new(r#"<path\s+([^>]*?)/?>(?:</path>)?"#, svg) {
143        if let Some(attrs) = cap {
144            if let Some(path) = parse_path_attrs(&attrs, &root) {
145                paths.push(path);
146            }
147        }
148    }
149
150    // Extract <circle> elements
151    for cap in RegexLite::new(r#"<circle\s+([^>]*?)/?>(?:</circle>)?"#, svg) {
152        if let Some(attrs) = cap {
153            if let Some(path) = circle_to_path(&attrs, &root) {
154                paths.push(path);
155            }
156        }
157    }
158
159    // Extract <line> elements
160    for cap in RegexLite::new(r#"<line\s+([^>]*?)/?>(?:</line>)?"#, svg) {
161        if let Some(attrs) = cap {
162            if let Some(path) = line_to_path(&attrs, &root) {
163                paths.push(path);
164            }
165        }
166    }
167
168    // Extract <rect> elements
169    for cap in RegexLite::new(r#"<rect\s+([^>]*?)/?>(?:</rect>)?"#, svg) {
170        if let Some(attrs) = cap {
171            if let Some(path) = rect_to_path(&attrs, &root) {
172                paths.push(path);
173            }
174        }
175    }
176
177    IconData { view_box, paths }
178}
179
180/// Presentation attributes extracted from the `<svg>` root element.
181///
182/// SVG allows presentation attributes like `stroke-width` on the root to
183/// apply as defaults to any child element that doesn't specify its own.
184/// Heroicons v2 relies on this — the `<path>` elements are bare and all
185/// styling lives on `<svg>`. Without inheritance the child defaults kick
186/// in (e.g. `stroke_width = 2.0`) and icons render at the wrong weight.
187#[derive(Debug, Default, Clone)]
188struct RootDefaults {
189    view_box: Option<String>,
190    fill: Option<String>,
191    stroke: Option<String>,
192    stroke_width: Option<f64>,
193    stroke_linecap: Option<String>,
194    stroke_linejoin: Option<String>,
195}
196
197impl RootDefaults {
198    /// Extract presentation attributes from the `<svg ...>` opening tag.
199    ///
200    /// Scoping matters: `extract_attr` does a plain substring search over its
201    /// input, so calling it on the whole SVG would match the first occurrence
202    /// of e.g. `stroke-width=` — which might live on a child `<path>` element.
203    /// We isolate the opening-tag substring first so root lookups only see
204    /// root attributes.
205    fn extract(svg: &str) -> Self {
206        let lower = svg.to_lowercase();
207        let Some(svg_start) = lower.find("<svg") else {
208            return Self::default();
209        };
210        let after = &svg[svg_start + 4..];
211        // Find the end of the opening tag. `>` always terminates it; `/>`
212        // also terminates it but is a prefix of `>`, so `find('>')` catches
213        // both cases correctly.
214        let Some(end) = after.find('>') else {
215            return Self::default();
216        };
217        let root_attrs = &after[..end];
218
219        Self {
220            view_box: extract_attr(root_attrs, "viewBox"),
221            fill: extract_attr(root_attrs, "fill"),
222            stroke: extract_attr(root_attrs, "stroke"),
223            stroke_width: extract_attr(root_attrs, "stroke-width")
224                .and_then(|s| s.parse().ok()),
225            stroke_linecap: extract_attr(root_attrs, "stroke-linecap"),
226            stroke_linejoin: extract_attr(root_attrs, "stroke-linejoin"),
227        }
228    }
229}
230
231/// Load an icon pack from a directory of SVG files.
232///
233// ── SVG Parsing Helpers ─────────────────────────────────────────────
234
235/// Minimal regex-like matcher for SVG attribute extraction.
236/// We avoid pulling in the regex crate by using simple string searching.
237struct RegexLite<'a> {
238    tag_start: &'a str,
239    source: &'a str,
240    pos: usize,
241}
242
243impl<'a> RegexLite<'a> {
244    fn new(pattern: &'a str, source: &'a str) -> Self {
245        // Extract the tag name from pattern like `<path\s+...`
246        let tag_start = if pattern.contains("<path") {
247            "<path"
248        } else if pattern.contains("<circle") {
249            "<circle"
250        } else if pattern.contains("<line") {
251            "<line"
252        } else if pattern.contains("<rect") {
253            "<rect"
254        } else {
255            "<unknown"
256        };
257        Self {
258            tag_start,
259            source,
260            pos: 0,
261        }
262    }
263}
264
265impl<'a> Iterator for RegexLite<'a> {
266    type Item = Option<String>;
267
268    fn next(&mut self) -> Option<Self::Item> {
269        let remaining = &self.source[self.pos..];
270        // Case-insensitive search for tag
271        let lower = remaining.to_lowercase();
272        let tag_lower = self.tag_start.to_lowercase();
273
274        if let Some(start) = lower.find(&tag_lower) {
275            let abs_start = self.pos + start + self.tag_start.len();
276            let after = &self.source[abs_start..];
277
278            // Find the end of the tag (either /> or >)
279            if let Some(end) = after.find("/>").or_else(|| after.find('>')) {
280                let attrs = after[..end].trim().to_string();
281                self.pos = abs_start + end + 2;
282                Some(Some(attrs))
283            } else {
284                self.pos = self.source.len();
285                None
286            }
287        } else {
288            None
289        }
290    }
291}
292
293fn extract_attr(source: &str, name: &str) -> Option<String> {
294    // Look for name="value" or name='value'
295    let patterns = [
296        format!("{}=\"", name),
297        format!("{}='", name),
298    ];
299
300    for pattern in &patterns {
301        if let Some(start) = source.find(pattern.as_str()) {
302            let value_start = start + pattern.len();
303            let delim = if pattern.ends_with('"') { '"' } else { '\'' };
304            if let Some(end) = source[value_start..].find(delim) {
305                return Some(source[value_start..value_start + end].to_string());
306            }
307        }
308    }
309    None
310}
311
312/// Resolve a string presentation attribute with element → root → fallback precedence.
313fn resolve_str(attrs: &str, name: &str, root: Option<&String>, fallback: &str) -> String {
314    extract_attr(attrs, name)
315        .or_else(|| root.cloned())
316        .unwrap_or_else(|| fallback.to_string())
317}
318
319/// Resolve stroke-width with element → root → fallback precedence.
320fn resolve_stroke_width(attrs: &str, root: Option<f64>) -> f64 {
321    extract_attr(attrs, "stroke-width")
322        .and_then(|s| s.parse().ok())
323        .or(root)
324        .unwrap_or(2.0)
325}
326
327fn parse_path_attrs(attrs: &str, root: &RootDefaults) -> Option<IconPath> {
328    let d = extract_attr(attrs, "d")?;
329    if d.is_empty() {
330        return None;
331    }
332
333    Some(IconPath {
334        d,
335        fill: resolve_str(attrs, "fill", root.fill.as_ref(), "none"),
336        stroke: resolve_str(attrs, "stroke", root.stroke.as_ref(), "currentColor"),
337        stroke_width: resolve_stroke_width(attrs, root.stroke_width),
338        stroke_linecap: resolve_str(attrs, "stroke-linecap", root.stroke_linecap.as_ref(), "round"),
339        stroke_linejoin: resolve_str(attrs, "stroke-linejoin", root.stroke_linejoin.as_ref(), "round"),
340    })
341}
342
343fn circle_to_path(attrs: &str, root: &RootDefaults) -> Option<IconPath> {
344    let cx: f64 = extract_attr(attrs, "cx").and_then(|s| s.parse().ok()).unwrap_or(0.0);
345    let cy: f64 = extract_attr(attrs, "cy").and_then(|s| s.parse().ok()).unwrap_or(0.0);
346    let r: f64 = extract_attr(attrs, "r").and_then(|s| s.parse().ok()).unwrap_or(0.0);
347    if r <= 0.0 {
348        return None;
349    }
350
351    let d = format!(
352        "M{},{} a{},{} 0 1,0 {},0 a{},{} 0 1,0 -{},0",
353        cx - r, cy, r, r, r * 2.0, r, r, r * 2.0
354    );
355
356    Some(IconPath {
357        d,
358        fill: resolve_str(attrs, "fill", root.fill.as_ref(), "none"),
359        stroke: resolve_str(attrs, "stroke", root.stroke.as_ref(), "currentColor"),
360        stroke_width: resolve_stroke_width(attrs, root.stroke_width),
361        stroke_linecap: resolve_str(attrs, "stroke-linecap", root.stroke_linecap.as_ref(), "round"),
362        stroke_linejoin: resolve_str(attrs, "stroke-linejoin", root.stroke_linejoin.as_ref(), "round"),
363    })
364}
365
366fn line_to_path(attrs: &str, root: &RootDefaults) -> Option<IconPath> {
367    let x1 = extract_attr(attrs, "x1").unwrap_or_else(|| "0".to_string());
368    let y1 = extract_attr(attrs, "y1").unwrap_or_else(|| "0".to_string());
369    let x2 = extract_attr(attrs, "x2").unwrap_or_else(|| "0".to_string());
370    let y2 = extract_attr(attrs, "y2").unwrap_or_else(|| "0".to_string());
371
372    let d = format!("M{},{}L{},{}", x1, y1, x2, y2);
373
374    Some(IconPath {
375        d,
376        fill: resolve_str(attrs, "fill", root.fill.as_ref(), "none"),
377        stroke: resolve_str(attrs, "stroke", root.stroke.as_ref(), "currentColor"),
378        stroke_width: resolve_stroke_width(attrs, root.stroke_width),
379        stroke_linecap: resolve_str(attrs, "stroke-linecap", root.stroke_linecap.as_ref(), "round"),
380        stroke_linejoin: resolve_str(attrs, "stroke-linejoin", root.stroke_linejoin.as_ref(), "round"),
381    })
382}
383
384fn rect_to_path(attrs: &str, root: &RootDefaults) -> Option<IconPath> {
385    let x: f64 = extract_attr(attrs, "x").and_then(|s| s.parse().ok()).unwrap_or(0.0);
386    let y: f64 = extract_attr(attrs, "y").and_then(|s| s.parse().ok()).unwrap_or(0.0);
387    let w: f64 = extract_attr(attrs, "width").and_then(|s| s.parse().ok()).unwrap_or(0.0);
388    let h: f64 = extract_attr(attrs, "height").and_then(|s| s.parse().ok()).unwrap_or(0.0);
389    if w <= 0.0 || h <= 0.0 {
390        return None;
391    }
392
393    let d = format!("M{},{}h{}v{}h-{}z", x, y, w, h, w);
394
395    Some(IconPath {
396        d,
397        fill: resolve_str(attrs, "fill", root.fill.as_ref(), "none"),
398        stroke: resolve_str(attrs, "stroke", root.stroke.as_ref(), "currentColor"),
399        stroke_width: resolve_stroke_width(attrs, root.stroke_width),
400        stroke_linecap: resolve_str(attrs, "stroke-linecap", root.stroke_linecap.as_ref(), "round"),
401        stroke_linejoin: resolve_str(attrs, "stroke-linejoin", root.stroke_linejoin.as_ref(), "round"),
402    })
403}
404
405/// Walk an IR tree and resolve all `Icon` elements and `@resources` references
406/// against the given resource registry.
407///
408/// For each `Icon` element found, this extracts the icon name from:
409/// - Static string: `Icon("heart")` → positional arg "0" or named "name"
410/// - Resource reference: `Icon(@resources.heart)` → `Value::Resource("heart")`
411///
412/// Then resolves via the registry and injects `__iconPaths` and `__iconViewBox`
413/// props so the renderer receives pre-resolved SVG path data in the patch.
414///
415/// This is the shared implementation used by all engine variants (WASM, WASI,
416/// native Rust, UniFFI).
417pub fn resolve_icons_in_ir(registry: &ResourceRegistry, node: &mut super::IRNode) {
418    use super::{IRNode, Value};
419
420    crate::ir::walk::walk_ir_mut(node, &mut |n| {
421        let IRNode::Element(element) = n else { return };
422        if element.element_type != "Icon" {
423            return;
424        }
425
426        // Extract icon name from:
427        // 1. Resource reference: Icon(@resources.heart) → Value::Resource("heart")
428        // 2. Static string: Icon("heart") → Value::Static("heart")
429        let icon_name = element
430            .props
431            .get("0")
432            .or_else(|| element.props.get("name"))
433            .and_then(|v| match v {
434                Value::Resource(name) => Some(name.clone()),
435                Value::Static(serde_json::Value::String(s)) => Some(s.clone()),
436                _ => None,
437            });
438
439        let Some(name) = icon_name else { return };
440        let Some(icon_data) = registry.resolve(&name) else { return };
441        let icon_props = ResourceRegistry::to_props(icon_data);
442
443        if let Some(paths) = icon_props.get("paths") {
444            element
445                .props
446                .insert("__iconPaths".to_string(), Value::Static(paths.clone()));
447        }
448
449        if let Some(view_box) = icon_props.get("viewBox") {
450            element
451                .props
452                .insert("__iconViewBox".to_string(), Value::Static(view_box.clone()));
453        }
454    });
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    #[test]
462    fn test_parse_svg_inherits_root_presentation_attributes() {
463        // Heroicons v2 layout: all presentation attributes on <svg>, bare <path>.
464        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z"/></svg>"#;
465        let icon = parse_svg(svg);
466        assert_eq!(icon.paths.len(), 1);
467        let p = &icon.paths[0];
468        assert_eq!(p.stroke_width, 1.5, "stroke-width=1.5 from <svg> root should be inherited, got {}", p.stroke_width);
469        assert_eq!(p.stroke, "currentColor");
470        assert_eq!(p.fill, "none");
471        assert_eq!(p.stroke_linecap, "round");
472        assert_eq!(p.stroke_linejoin, "round");
473    }
474
475    #[test]
476    fn test_parse_svg_child_attributes_override_root() {
477        // When a child element has its own presentation attributes, they must
478        // win over the inherited root values.
479        let svg = r#"<svg viewBox="0 0 24 24" stroke="red" stroke-width="1.5"><path d="M0 0L10 10" stroke="blue" stroke-width="3"/></svg>"#;
480        let icon = parse_svg(svg);
481        assert_eq!(icon.paths[0].stroke, "blue");
482        assert_eq!(icon.paths[0].stroke_width, 3.0);
483    }
484
485    #[test]
486    fn test_parse_svg_root_scoping_does_not_leak_from_children() {
487        // If root-attribute extraction ran over the whole document instead of
488        // the opening-tag substring, this path's `stroke-width="5"` would
489        // incorrectly become the "root" default for other children.
490        let svg = r#"<svg viewBox="0 0 24 24"><path d="M0 0L10 10" stroke-width="5"/><path d="M1 1L2 2"/></svg>"#;
491        let icon = parse_svg(svg);
492        assert_eq!(icon.paths.len(), 2);
493        assert_eq!(icon.paths[0].stroke_width, 5.0, "first path carries its own width");
494        assert_eq!(
495            icon.paths[1].stroke_width, 2.0,
496            "second path must fall back to hardcoded 2.0, not inherit from sibling"
497        );
498    }
499
500    #[test]
501    fn test_parse_svg_no_root_tag_falls_back_to_defaults() {
502        // Defensive: a fragment with no <svg> wrapper should still parse, with
503        // all presentation attributes falling back to hardcoded defaults.
504        let svg = r#"<path d="M5 12h14"/>"#;
505        let icon = parse_svg(svg);
506        assert_eq!(icon.paths.len(), 1);
507        assert_eq!(icon.paths[0].stroke_width, 2.0);
508        assert_eq!(icon.paths[0].stroke, "currentColor");
509        assert_eq!(icon.paths[0].fill, "none");
510    }
511
512    #[test]
513    fn test_register_and_resolve() {
514        let mut registry = ResourceRegistry::new();
515
516        // Register from raw SVG
517        registry.register(
518            "heart",
519            r#"<svg viewBox="0 0 24 24"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke="currentColor"/></svg>"#,
520        );
521
522        // Resolve by name
523        let icon = registry.resolve("heart");
524        assert!(icon.is_some());
525        assert_eq!(icon.unwrap().paths.len(), 1);
526
527        // Missing resource
528        let icon = registry.resolve("missing");
529        assert!(icon.is_none());
530    }
531
532    #[test]
533    fn test_register_map() {
534        let mut registry = ResourceRegistry::new();
535        let mut map = IndexMap::new();
536        map.insert(
537            "arrow".to_string(),
538            r#"<svg viewBox="0 0 24 24"><path d="M5 12h14"/></svg>"#.to_string(),
539        );
540        map.insert(
541            "star".to_string(),
542            r#"<svg viewBox="0 0 24 24"><path d="M12 2l3 7h7"/></svg>"#.to_string(),
543        );
544        registry.register_map(map);
545
546        assert!(registry.resolve("arrow").is_some());
547        assert!(registry.resolve("star").is_some());
548        assert!(registry.resolve("missing").is_none());
549    }
550
551    #[test]
552    fn test_to_props() {
553        let icon = IconData {
554            view_box: "0 0 24 24".to_string(),
555            paths: vec![IconPath {
556                d: "M5 12h14".to_string(),
557                fill: "none".to_string(),
558                stroke: "currentColor".to_string(),
559                stroke_width: 2.0,
560                stroke_linecap: "round".to_string(),
561                stroke_linejoin: "round".to_string(),
562            }],
563        };
564
565        let props = ResourceRegistry::to_props(&icon);
566        assert_eq!(props["viewBox"], "0 0 24 24");
567        assert!(props["paths"].is_array());
568        assert_eq!(props["paths"][0]["d"], "M5 12h14");
569        assert_eq!(props["paths"][0]["stroke"], "currentColor");
570    }
571
572    #[test]
573    fn test_parse_svg_basic_path() {
574        let svg = r#"<svg viewBox="0 0 24 24"><path d="M5 12h14" stroke="currentColor"/></svg>"#;
575        let icon = parse_svg(svg);
576        assert_eq!(icon.view_box, "0 0 24 24");
577        assert_eq!(icon.paths.len(), 1);
578        assert_eq!(icon.paths[0].d, "M5 12h14");
579    }
580
581    #[test]
582    fn test_parse_svg_multiple_paths() {
583        let svg = r#"<svg viewBox="0 0 24 24">
584            <path d="M5 12h14" stroke="currentColor"/>
585            <path d="M12 5v14" stroke="red" stroke-width="3"/>
586        </svg>"#;
587        let icon = parse_svg(svg);
588        assert_eq!(icon.paths.len(), 2);
589        assert_eq!(icon.paths[0].d, "M5 12h14");
590        assert_eq!(icon.paths[1].d, "M12 5v14");
591        assert_eq!(icon.paths[1].stroke, "red");
592        assert_eq!(icon.paths[1].stroke_width, 3.0);
593    }
594
595    #[test]
596    fn test_parse_svg_default_viewbox() {
597        let svg = r#"<svg><path d="M0 0L10 10"/></svg>"#;
598        let icon = parse_svg(svg);
599        assert_eq!(icon.view_box, "0 0 24 24");
600    }
601
602    #[test]
603    fn test_parse_svg_circle() {
604        let svg = r#"<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>"#;
605        let icon = parse_svg(svg);
606        assert_eq!(icon.paths.len(), 1);
607        assert!(icon.paths[0].d.starts_with("M2,12"));
608    }
609
610    #[test]
611    fn test_parse_svg_rect() {
612        let svg = r#"<svg viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="20"/></svg>"#;
613        let icon = parse_svg(svg);
614        assert_eq!(icon.paths.len(), 1);
615        assert_eq!(icon.paths[0].d, "M2,2h20v20h-20z");
616    }
617
618    #[test]
619    fn test_parse_svg_line() {
620        let svg = r#"<svg viewBox="0 0 24 24"><line x1="0" y1="0" x2="24" y2="24"/></svg>"#;
621        let icon = parse_svg(svg);
622        assert_eq!(icon.paths.len(), 1);
623        assert_eq!(icon.paths[0].d, "M0,0L24,24");
624    }
625
626    #[test]
627    fn test_parse_svg_empty_path_skipped() {
628        let svg = r#"<svg><path d=""/></svg>"#;
629        let icon = parse_svg(svg);
630        assert_eq!(icon.paths.len(), 0);
631    }
632
633    #[test]
634    fn test_parse_svg_zero_radius_circle_skipped() {
635        let svg = r#"<svg><circle cx="12" cy="12" r="0"/></svg>"#;
636        let icon = parse_svg(svg);
637        assert_eq!(icon.paths.len(), 0);
638    }
639
640    #[test]
641    fn test_resolve_icon_via_resource_reference() {
642        use crate::ir::{Element, IRNode, Value};
643
644        let mut registry = ResourceRegistry::new();
645        registry.register(
646            "heart",
647            r#"<svg viewBox="0 0 24 24"><path d="M20 4.6L12 21z" stroke="currentColor"/></svg>"#,
648        );
649
650        // Simulate Icon(@resources.heart) — prop "0" is Value::Resource("heart")
651        let mut element = Element::new("Icon");
652        element
653            .props
654            .insert("0".to_string(), Value::Resource("heart".to_string()));
655        let mut node = IRNode::Element(element);
656
657        resolve_icons_in_ir(&registry, &mut node);
658
659        if let IRNode::Element(el) = &node {
660            assert!(el.props.contains_key("__iconPaths"), "Should inject __iconPaths");
661            assert!(el.props.contains_key("__iconViewBox"), "Should inject __iconViewBox");
662            match el.props.get("__iconViewBox").unwrap() {
663                Value::Static(v) => assert_eq!(v, "0 0 24 24"),
664                other => panic!("Expected Static viewBox, got: {:?}", other),
665            }
666        } else {
667            panic!("Expected Element");
668        }
669    }
670
671    #[test]
672    fn test_resolve_icon_via_static_string() {
673        use crate::ir::{Element, IRNode, Value};
674
675        let mut registry = ResourceRegistry::new();
676        registry.register(
677            "star",
678            r#"<svg viewBox="0 0 24 24"><path d="M12 2l3 7h7"/></svg>"#,
679        );
680
681        // Simulate Icon("star") — prop "0" is Value::Static("star")
682        let mut element = Element::new("Icon");
683        element.props.insert(
684            "0".to_string(),
685            Value::Static(serde_json::json!("star")),
686        );
687        let mut node = IRNode::Element(element);
688
689        resolve_icons_in_ir(&registry, &mut node);
690
691        if let IRNode::Element(el) = &node {
692            assert!(el.props.contains_key("__iconPaths"));
693        } else {
694            panic!("Expected Element");
695        }
696    }
697
698    #[test]
699    fn test_resolve_icon_missing_resource() {
700        use crate::ir::{Element, IRNode, Value};
701
702        let registry = ResourceRegistry::new(); // empty
703
704        let mut element = Element::new("Icon");
705        element
706            .props
707            .insert("0".to_string(), Value::Resource("nonexistent".to_string()));
708        let mut node = IRNode::Element(element);
709
710        resolve_icons_in_ir(&registry, &mut node);
711
712        if let IRNode::Element(el) = &node {
713            assert!(!el.props.contains_key("__iconPaths"), "Should not inject paths for missing resource");
714        } else {
715            panic!("Expected Element");
716        }
717    }
718}