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
139        .view_box
140        .clone()
141        .unwrap_or_else(|| "0 0 24 24".to_string());
142    let mut paths = Vec::new();
143
144    // Extract <path> elements
145    for cap in RegexLite::new(r#"<path\s+([^>]*?)/?>(?:</path>)?"#, svg) {
146        if let Some(attrs) = cap {
147            if let Some(path) = parse_path_attrs(&attrs, &root) {
148                paths.push(path);
149            }
150        }
151    }
152
153    // Extract <circle> elements
154    for cap in RegexLite::new(r#"<circle\s+([^>]*?)/?>(?:</circle>)?"#, svg) {
155        if let Some(attrs) = cap {
156            if let Some(path) = circle_to_path(&attrs, &root) {
157                paths.push(path);
158            }
159        }
160    }
161
162    // Extract <line> elements
163    for cap in RegexLite::new(r#"<line\s+([^>]*?)/?>(?:</line>)?"#, svg) {
164        if let Some(attrs) = cap {
165            if let Some(path) = line_to_path(&attrs, &root) {
166                paths.push(path);
167            }
168        }
169    }
170
171    // Extract <rect> elements
172    for cap in RegexLite::new(r#"<rect\s+([^>]*?)/?>(?:</rect>)?"#, svg) {
173        if let Some(attrs) = cap {
174            if let Some(path) = rect_to_path(&attrs, &root) {
175                paths.push(path);
176            }
177        }
178    }
179
180    IconData { view_box, paths }
181}
182
183/// Presentation attributes extracted from the `<svg>` root element.
184///
185/// SVG allows presentation attributes like `stroke-width` on the root to
186/// apply as defaults to any child element that doesn't specify its own.
187/// Heroicons v2 relies on this — the `<path>` elements are bare and all
188/// styling lives on `<svg>`. Without inheritance the child defaults kick
189/// in (e.g. `stroke_width = 2.0`) and icons render at the wrong weight.
190#[derive(Debug, Default, Clone)]
191struct RootDefaults {
192    view_box: Option<String>,
193    fill: Option<String>,
194    stroke: Option<String>,
195    stroke_width: Option<f64>,
196    stroke_linecap: Option<String>,
197    stroke_linejoin: Option<String>,
198}
199
200impl RootDefaults {
201    /// Extract presentation attributes from the `<svg ...>` opening tag.
202    ///
203    /// Scoping matters: `extract_attr` does a plain substring search over its
204    /// input, so calling it on the whole SVG would match the first occurrence
205    /// of e.g. `stroke-width=` — which might live on a child `<path>` element.
206    /// We isolate the opening-tag substring first so root lookups only see
207    /// root attributes.
208    fn extract(svg: &str) -> Self {
209        let lower = svg.to_lowercase();
210        let Some(svg_start) = lower.find("<svg") else {
211            return Self::default();
212        };
213        let after = &svg[svg_start + 4..];
214        // Find the end of the opening tag. `>` always terminates it; `/>`
215        // also terminates it but is a prefix of `>`, so `find('>')` catches
216        // both cases correctly.
217        let Some(end) = after.find('>') else {
218            return Self::default();
219        };
220        let root_attrs = &after[..end];
221
222        Self {
223            view_box: extract_attr(root_attrs, "viewBox"),
224            fill: extract_attr(root_attrs, "fill"),
225            stroke: extract_attr(root_attrs, "stroke"),
226            stroke_width: extract_attr(root_attrs, "stroke-width").and_then(|s| s.parse().ok()),
227            stroke_linecap: extract_attr(root_attrs, "stroke-linecap"),
228            stroke_linejoin: extract_attr(root_attrs, "stroke-linejoin"),
229        }
230    }
231}
232
233/// Load an icon pack from a directory of SVG files.
234///
235// ── SVG Parsing Helpers ─────────────────────────────────────────────
236
237/// Minimal regex-like matcher for SVG attribute extraction.
238/// We avoid pulling in the regex crate by using simple string searching.
239struct RegexLite<'a> {
240    tag_start: &'a str,
241    source: &'a str,
242    pos: usize,
243}
244
245impl<'a> RegexLite<'a> {
246    fn new(pattern: &'a str, source: &'a str) -> Self {
247        // Extract the tag name from pattern like `<path\s+...`
248        let tag_start = if pattern.contains("<path") {
249            "<path"
250        } else if pattern.contains("<circle") {
251            "<circle"
252        } else if pattern.contains("<line") {
253            "<line"
254        } else if pattern.contains("<rect") {
255            "<rect"
256        } else {
257            "<unknown"
258        };
259        Self {
260            tag_start,
261            source,
262            pos: 0,
263        }
264    }
265}
266
267impl<'a> Iterator for RegexLite<'a> {
268    type Item = Option<String>;
269
270    fn next(&mut self) -> Option<Self::Item> {
271        let remaining = &self.source[self.pos..];
272        // Case-insensitive search for tag
273        let lower = remaining.to_lowercase();
274        let tag_lower = self.tag_start.to_lowercase();
275
276        if let Some(start) = lower.find(&tag_lower) {
277            let abs_start = self.pos + start + self.tag_start.len();
278            let after = &self.source[abs_start..];
279
280            // Find the end of the tag (either /> or >)
281            if let Some(end) = after.find("/>").or_else(|| after.find('>')) {
282                let attrs = after[..end].trim().to_string();
283                self.pos = abs_start + end + 2;
284                Some(Some(attrs))
285            } else {
286                self.pos = self.source.len();
287                None
288            }
289        } else {
290            None
291        }
292    }
293}
294
295fn extract_attr(source: &str, name: &str) -> Option<String> {
296    // Look for name="value" or name='value'
297    let patterns = [format!("{}=\"", name), format!("{}='", name)];
298
299    for pattern in &patterns {
300        if let Some(start) = source.find(pattern.as_str()) {
301            let value_start = start + pattern.len();
302            let delim = if pattern.ends_with('"') { '"' } else { '\'' };
303            if let Some(end) = source[value_start..].find(delim) {
304                return Some(source[value_start..value_start + end].to_string());
305            }
306        }
307    }
308    None
309}
310
311/// Resolve a string presentation attribute with element → root → fallback precedence.
312fn resolve_str(attrs: &str, name: &str, root: Option<&String>, fallback: &str) -> String {
313    extract_attr(attrs, name)
314        .or_else(|| root.cloned())
315        .unwrap_or_else(|| fallback.to_string())
316}
317
318/// Resolve stroke-width with element → root → fallback precedence.
319fn resolve_stroke_width(attrs: &str, root: Option<f64>) -> f64 {
320    extract_attr(attrs, "stroke-width")
321        .and_then(|s| s.parse().ok())
322        .or(root)
323        .unwrap_or(2.0)
324}
325
326fn parse_path_attrs(attrs: &str, root: &RootDefaults) -> Option<IconPath> {
327    let d = extract_attr(attrs, "d")?;
328    if d.is_empty() {
329        return None;
330    }
331
332    Some(IconPath {
333        d,
334        fill: resolve_str(attrs, "fill", root.fill.as_ref(), "none"),
335        stroke: resolve_str(attrs, "stroke", root.stroke.as_ref(), "currentColor"),
336        stroke_width: resolve_stroke_width(attrs, root.stroke_width),
337        stroke_linecap: resolve_str(
338            attrs,
339            "stroke-linecap",
340            root.stroke_linecap.as_ref(),
341            "round",
342        ),
343        stroke_linejoin: resolve_str(
344            attrs,
345            "stroke-linejoin",
346            root.stroke_linejoin.as_ref(),
347            "round",
348        ),
349    })
350}
351
352fn circle_to_path(attrs: &str, root: &RootDefaults) -> Option<IconPath> {
353    let cx: f64 = extract_attr(attrs, "cx")
354        .and_then(|s| s.parse().ok())
355        .unwrap_or(0.0);
356    let cy: f64 = extract_attr(attrs, "cy")
357        .and_then(|s| s.parse().ok())
358        .unwrap_or(0.0);
359    let r: f64 = extract_attr(attrs, "r")
360        .and_then(|s| s.parse().ok())
361        .unwrap_or(0.0);
362    if r <= 0.0 {
363        return None;
364    }
365
366    let d = format!(
367        "M{},{} a{},{} 0 1,0 {},0 a{},{} 0 1,0 -{},0",
368        cx - r,
369        cy,
370        r,
371        r,
372        r * 2.0,
373        r,
374        r,
375        r * 2.0
376    );
377
378    Some(IconPath {
379        d,
380        fill: resolve_str(attrs, "fill", root.fill.as_ref(), "none"),
381        stroke: resolve_str(attrs, "stroke", root.stroke.as_ref(), "currentColor"),
382        stroke_width: resolve_stroke_width(attrs, root.stroke_width),
383        stroke_linecap: resolve_str(
384            attrs,
385            "stroke-linecap",
386            root.stroke_linecap.as_ref(),
387            "round",
388        ),
389        stroke_linejoin: resolve_str(
390            attrs,
391            "stroke-linejoin",
392            root.stroke_linejoin.as_ref(),
393            "round",
394        ),
395    })
396}
397
398fn line_to_path(attrs: &str, root: &RootDefaults) -> Option<IconPath> {
399    let x1 = extract_attr(attrs, "x1").unwrap_or_else(|| "0".to_string());
400    let y1 = extract_attr(attrs, "y1").unwrap_or_else(|| "0".to_string());
401    let x2 = extract_attr(attrs, "x2").unwrap_or_else(|| "0".to_string());
402    let y2 = extract_attr(attrs, "y2").unwrap_or_else(|| "0".to_string());
403
404    let d = format!("M{},{}L{},{}", x1, y1, x2, y2);
405
406    Some(IconPath {
407        d,
408        fill: resolve_str(attrs, "fill", root.fill.as_ref(), "none"),
409        stroke: resolve_str(attrs, "stroke", root.stroke.as_ref(), "currentColor"),
410        stroke_width: resolve_stroke_width(attrs, root.stroke_width),
411        stroke_linecap: resolve_str(
412            attrs,
413            "stroke-linecap",
414            root.stroke_linecap.as_ref(),
415            "round",
416        ),
417        stroke_linejoin: resolve_str(
418            attrs,
419            "stroke-linejoin",
420            root.stroke_linejoin.as_ref(),
421            "round",
422        ),
423    })
424}
425
426fn rect_to_path(attrs: &str, root: &RootDefaults) -> Option<IconPath> {
427    let x: f64 = extract_attr(attrs, "x")
428        .and_then(|s| s.parse().ok())
429        .unwrap_or(0.0);
430    let y: f64 = extract_attr(attrs, "y")
431        .and_then(|s| s.parse().ok())
432        .unwrap_or(0.0);
433    let w: f64 = extract_attr(attrs, "width")
434        .and_then(|s| s.parse().ok())
435        .unwrap_or(0.0);
436    let h: f64 = extract_attr(attrs, "height")
437        .and_then(|s| s.parse().ok())
438        .unwrap_or(0.0);
439    if w <= 0.0 || h <= 0.0 {
440        return None;
441    }
442
443    let d = format!("M{},{}h{}v{}h-{}z", x, y, w, h, w);
444
445    Some(IconPath {
446        d,
447        fill: resolve_str(attrs, "fill", root.fill.as_ref(), "none"),
448        stroke: resolve_str(attrs, "stroke", root.stroke.as_ref(), "currentColor"),
449        stroke_width: resolve_stroke_width(attrs, root.stroke_width),
450        stroke_linecap: resolve_str(
451            attrs,
452            "stroke-linecap",
453            root.stroke_linecap.as_ref(),
454            "round",
455        ),
456        stroke_linejoin: resolve_str(
457            attrs,
458            "stroke-linejoin",
459            root.stroke_linejoin.as_ref(),
460            "round",
461        ),
462    })
463}
464
465/// Walk an IR tree and resolve all `Icon` elements and `@resources` references
466/// against the given resource registry.
467///
468/// For each `Icon` element found, this extracts the icon name from:
469/// - Static string: `Icon("heart")` → positional arg "0" or named "name"
470/// - Resource reference: `Icon(@resources.heart)` → `Value::Resource("heart")`
471///
472/// Then resolves via the registry and injects `__iconPaths` and `__iconViewBox`
473/// props so the renderer receives pre-resolved SVG path data in the patch.
474///
475/// This is the shared implementation used by all engine variants (WASM, WASI,
476/// native Rust, UniFFI).
477pub fn resolve_icons_in_ir(registry: &ResourceRegistry, node: &mut super::IRNode) {
478    use super::{IRNode, Value};
479
480    crate::ir::walk::walk_ir_mut(node, &mut |n| {
481        let IRNode::Element(element) = n else { return };
482        if element.element_type != "Icon" {
483            return;
484        }
485
486        // Extract icon name from:
487        // 1. Resource reference: Icon(@resources.heart) → Value::Resource("heart")
488        // 2. Static string: Icon("heart") → Value::Static("heart")
489        let icon_name = element
490            .props
491            .get("0")
492            .or_else(|| element.props.get("name"))
493            .and_then(|v| match v {
494                Value::Resource(name) => Some(name.clone()),
495                Value::Static(serde_json::Value::String(s)) => Some(s.clone()),
496                _ => None,
497            });
498
499        let Some(name) = icon_name else { return };
500        let Some(icon_data) = registry.resolve(&name) else {
501            return;
502        };
503        let icon_props = ResourceRegistry::to_props(icon_data);
504
505        if let Some(paths) = icon_props.get("paths") {
506            element
507                .props
508                .insert("__iconPaths".to_string(), Value::Static(paths.clone()));
509        }
510
511        if let Some(view_box) = icon_props.get("viewBox") {
512            element
513                .props
514                .insert("__iconViewBox".to_string(), Value::Static(view_box.clone()));
515        }
516    });
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    #[test]
524    fn test_parse_svg_inherits_root_presentation_attributes() {
525        // Heroicons v2 layout: all presentation attributes on <svg>, bare <path>.
526        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>"#;
527        let icon = parse_svg(svg);
528        assert_eq!(icon.paths.len(), 1);
529        let p = &icon.paths[0];
530        assert_eq!(
531            p.stroke_width, 1.5,
532            "stroke-width=1.5 from <svg> root should be inherited, got {}",
533            p.stroke_width
534        );
535        assert_eq!(p.stroke, "currentColor");
536        assert_eq!(p.fill, "none");
537        assert_eq!(p.stroke_linecap, "round");
538        assert_eq!(p.stroke_linejoin, "round");
539    }
540
541    #[test]
542    fn test_parse_svg_child_attributes_override_root() {
543        // When a child element has its own presentation attributes, they must
544        // win over the inherited root values.
545        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>"#;
546        let icon = parse_svg(svg);
547        assert_eq!(icon.paths[0].stroke, "blue");
548        assert_eq!(icon.paths[0].stroke_width, 3.0);
549    }
550
551    #[test]
552    fn test_parse_svg_root_scoping_does_not_leak_from_children() {
553        // If root-attribute extraction ran over the whole document instead of
554        // the opening-tag substring, this path's `stroke-width="5"` would
555        // incorrectly become the "root" default for other children.
556        let svg = r#"<svg viewBox="0 0 24 24"><path d="M0 0L10 10" stroke-width="5"/><path d="M1 1L2 2"/></svg>"#;
557        let icon = parse_svg(svg);
558        assert_eq!(icon.paths.len(), 2);
559        assert_eq!(
560            icon.paths[0].stroke_width, 5.0,
561            "first path carries its own width"
562        );
563        assert_eq!(
564            icon.paths[1].stroke_width, 2.0,
565            "second path must fall back to hardcoded 2.0, not inherit from sibling"
566        );
567    }
568
569    #[test]
570    fn test_parse_svg_no_root_tag_falls_back_to_defaults() {
571        // Defensive: a fragment with no <svg> wrapper should still parse, with
572        // all presentation attributes falling back to hardcoded defaults.
573        let svg = r#"<path d="M5 12h14"/>"#;
574        let icon = parse_svg(svg);
575        assert_eq!(icon.paths.len(), 1);
576        assert_eq!(icon.paths[0].stroke_width, 2.0);
577        assert_eq!(icon.paths[0].stroke, "currentColor");
578        assert_eq!(icon.paths[0].fill, "none");
579    }
580
581    #[test]
582    fn test_register_and_resolve() {
583        let mut registry = ResourceRegistry::new();
584
585        // Register from raw SVG
586        registry.register(
587            "heart",
588            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>"#,
589        );
590
591        // Resolve by name
592        let icon = registry.resolve("heart");
593        assert!(icon.is_some());
594        assert_eq!(icon.unwrap().paths.len(), 1);
595
596        // Missing resource
597        let icon = registry.resolve("missing");
598        assert!(icon.is_none());
599    }
600
601    #[test]
602    fn test_register_map() {
603        let mut registry = ResourceRegistry::new();
604        let mut map = IndexMap::new();
605        map.insert(
606            "arrow".to_string(),
607            r#"<svg viewBox="0 0 24 24"><path d="M5 12h14"/></svg>"#.to_string(),
608        );
609        map.insert(
610            "star".to_string(),
611            r#"<svg viewBox="0 0 24 24"><path d="M12 2l3 7h7"/></svg>"#.to_string(),
612        );
613        registry.register_map(map);
614
615        assert!(registry.resolve("arrow").is_some());
616        assert!(registry.resolve("star").is_some());
617        assert!(registry.resolve("missing").is_none());
618    }
619
620    #[test]
621    fn test_to_props() {
622        let icon = IconData {
623            view_box: "0 0 24 24".to_string(),
624            paths: vec![IconPath {
625                d: "M5 12h14".to_string(),
626                fill: "none".to_string(),
627                stroke: "currentColor".to_string(),
628                stroke_width: 2.0,
629                stroke_linecap: "round".to_string(),
630                stroke_linejoin: "round".to_string(),
631            }],
632        };
633
634        let props = ResourceRegistry::to_props(&icon);
635        assert_eq!(props["viewBox"], "0 0 24 24");
636        assert!(props["paths"].is_array());
637        assert_eq!(props["paths"][0]["d"], "M5 12h14");
638        assert_eq!(props["paths"][0]["stroke"], "currentColor");
639    }
640
641    #[test]
642    fn test_parse_svg_basic_path() {
643        let svg = r#"<svg viewBox="0 0 24 24"><path d="M5 12h14" stroke="currentColor"/></svg>"#;
644        let icon = parse_svg(svg);
645        assert_eq!(icon.view_box, "0 0 24 24");
646        assert_eq!(icon.paths.len(), 1);
647        assert_eq!(icon.paths[0].d, "M5 12h14");
648    }
649
650    #[test]
651    fn test_parse_svg_multiple_paths() {
652        let svg = r#"<svg viewBox="0 0 24 24">
653            <path d="M5 12h14" stroke="currentColor"/>
654            <path d="M12 5v14" stroke="red" stroke-width="3"/>
655        </svg>"#;
656        let icon = parse_svg(svg);
657        assert_eq!(icon.paths.len(), 2);
658        assert_eq!(icon.paths[0].d, "M5 12h14");
659        assert_eq!(icon.paths[1].d, "M12 5v14");
660        assert_eq!(icon.paths[1].stroke, "red");
661        assert_eq!(icon.paths[1].stroke_width, 3.0);
662    }
663
664    #[test]
665    fn test_parse_svg_default_viewbox() {
666        let svg = r#"<svg><path d="M0 0L10 10"/></svg>"#;
667        let icon = parse_svg(svg);
668        assert_eq!(icon.view_box, "0 0 24 24");
669    }
670
671    #[test]
672    fn test_parse_svg_circle() {
673        let svg = r#"<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>"#;
674        let icon = parse_svg(svg);
675        assert_eq!(icon.paths.len(), 1);
676        assert!(icon.paths[0].d.starts_with("M2,12"));
677    }
678
679    #[test]
680    fn test_parse_svg_rect() {
681        let svg = r#"<svg viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="20"/></svg>"#;
682        let icon = parse_svg(svg);
683        assert_eq!(icon.paths.len(), 1);
684        assert_eq!(icon.paths[0].d, "M2,2h20v20h-20z");
685    }
686
687    #[test]
688    fn test_parse_svg_line() {
689        let svg = r#"<svg viewBox="0 0 24 24"><line x1="0" y1="0" x2="24" y2="24"/></svg>"#;
690        let icon = parse_svg(svg);
691        assert_eq!(icon.paths.len(), 1);
692        assert_eq!(icon.paths[0].d, "M0,0L24,24");
693    }
694
695    #[test]
696    fn test_parse_svg_empty_path_skipped() {
697        let svg = r#"<svg><path d=""/></svg>"#;
698        let icon = parse_svg(svg);
699        assert_eq!(icon.paths.len(), 0);
700    }
701
702    #[test]
703    fn test_parse_svg_zero_radius_circle_skipped() {
704        let svg = r#"<svg><circle cx="12" cy="12" r="0"/></svg>"#;
705        let icon = parse_svg(svg);
706        assert_eq!(icon.paths.len(), 0);
707    }
708
709    #[test]
710    fn test_resolve_icon_via_resource_reference() {
711        use crate::ir::{Element, IRNode, Value};
712
713        let mut registry = ResourceRegistry::new();
714        registry.register(
715            "heart",
716            r#"<svg viewBox="0 0 24 24"><path d="M20 4.6L12 21z" stroke="currentColor"/></svg>"#,
717        );
718
719        // Simulate Icon(@resources.heart) — prop "0" is Value::Resource("heart")
720        let mut element = Element::new("Icon");
721        element
722            .props
723            .insert("0".to_string(), Value::Resource("heart".to_string()));
724        let mut node = IRNode::Element(element);
725
726        resolve_icons_in_ir(&registry, &mut node);
727
728        if let IRNode::Element(el) = &node {
729            assert!(
730                el.props.contains_key("__iconPaths"),
731                "Should inject __iconPaths"
732            );
733            assert!(
734                el.props.contains_key("__iconViewBox"),
735                "Should inject __iconViewBox"
736            );
737            match el.props.get("__iconViewBox").unwrap() {
738                Value::Static(v) => assert_eq!(v, "0 0 24 24"),
739                other => panic!("Expected Static viewBox, got: {:?}", other),
740            }
741        } else {
742            panic!("Expected Element");
743        }
744    }
745
746    #[test]
747    fn test_resolve_icon_via_static_string() {
748        use crate::ir::{Element, IRNode, Value};
749
750        let mut registry = ResourceRegistry::new();
751        registry.register(
752            "star",
753            r#"<svg viewBox="0 0 24 24"><path d="M12 2l3 7h7"/></svg>"#,
754        );
755
756        // Simulate Icon("star") — prop "0" is Value::Static("star")
757        let mut element = Element::new("Icon");
758        element
759            .props
760            .insert("0".to_string(), Value::Static(serde_json::json!("star")));
761        let mut node = IRNode::Element(element);
762
763        resolve_icons_in_ir(&registry, &mut node);
764
765        if let IRNode::Element(el) = &node {
766            assert!(el.props.contains_key("__iconPaths"));
767        } else {
768            panic!("Expected Element");
769        }
770    }
771
772    #[test]
773    fn test_resolve_icon_missing_resource() {
774        use crate::ir::{Element, IRNode, Value};
775
776        let registry = ResourceRegistry::new(); // empty
777
778        let mut element = Element::new("Icon");
779        element
780            .props
781            .insert("0".to_string(), Value::Resource("nonexistent".to_string()));
782        let mut node = IRNode::Element(element);
783
784        resolve_icons_in_ir(&registry, &mut node);
785
786        if let IRNode::Element(el) = &node {
787            assert!(
788                !el.props.contains_key("__iconPaths"),
789                "Should not inject paths for missing resource"
790            );
791        } else {
792            panic!("Expected Element");
793        }
794    }
795}