Skip to main content

fallow_extract/
iconify.rs

1//! Static Iconify icon-string extraction (issue #608).
2//!
3//! Iconify-based icon components consume icon sets through a build-time string
4//! name (`<Icon name="jam:github" />`, `<List icon="ic:round-home" />`) rather
5//! than a JavaScript `import`, so the `@iconify-json/<prefix>` package that
6//! supplies the `jam:` / `ic:` collection is invisible to import-graph analysis
7//! and gets flagged as an unused dependency.
8//!
9//! This module scans raw markup for icon-prop string values shaped
10//! `<prefix>:<name>` and scans Vue SFC script content for static object
11//! properties shaped `icon: 'i-<collection>-<name>'`. The analysis layer maps
12//! those values to declared `@iconify-json/<prefix>` packages, gated on the
13//! project actually declaring an Iconify-ecosystem dependency. Crediting can
14//! only ever exempt a declared dependency from "unused"; it never produces a
15//! finding.
16
17use std::path::Path;
18use std::sync::LazyLock;
19
20use regex::Regex;
21
22/// Matches an icon prop (`icon` or `name`) whose value starts with an Iconify
23/// collection prefix followed by a colon and an icon name.
24///
25/// The leading `[\s"'/]` requires whitespace, a quote, or a slash before the
26/// attribute name so attribute names that merely end in `icon`/`name`
27/// (`data-name`, `filename`) do not match; the `regex` crate has no lookbehind.
28/// Capture group 1 is the collection prefix (`jam`, `ic`, `simple-icons`,
29/// `fa6-solid`). The trailing `[a-z0-9]` guarantees a real `prefix:name`, not a
30/// bare `prefix:`.
31static ICON_PROP: LazyLock<Regex> = LazyLock::new(|| {
32    crate::static_regex(r#"[\s"'/](?:icon|name)\s*=\s*["']([a-z0-9]+(?:-[a-z0-9]+)*):[a-z0-9]"#)
33});
34
35/// Matches Vue SFC script-side object properties named `icon` whose static
36/// string value uses the Nuxt UI `i-<collection>-<icon>` shape. Capture group 1
37/// is the class suffix without `i-`, e.g. `simple-icons-github`.
38///
39/// The object-property anchor avoids crediting arbitrary strings. This is a raw
40/// source scanner rather than an AST visitor so it also sees `<script setup>`
41/// after SFC block extraction and stays cheap for the narrow Vue-only scope.
42static NUXT_UI_ICON_PROP: LazyLock<Regex> = LazyLock::new(|| {
43    crate::static_regex(
44        r#"(?m)(?:^|[,{]\s*)(?:icon|["']icon["'])\s*:\s*["']i-([a-z0-9]+(?:-[a-z0-9]+)+)["']"#,
45    )
46});
47
48/// Matches HTML markup comments so a commented-out icon usage does not credit
49/// its package. Mirrors the comment-strip-before-scan approach in `css.rs` /
50/// `html.rs`. JS/JSX comment forms (`//`, `/* */`, `{/* */}`) are not stripped:
51/// icon props rarely appear inside them and stripping risks mangling real
52/// attribute lines (e.g. a `//` inside a URL).
53static HTML_COMMENT: LazyLock<Regex> = LazyLock::new(|| crate::static_regex(r"(?s)<!--.*?-->"));
54
55/// File extensions whose source is markup that can carry icon-component props.
56/// Plain `.js`/`.ts`/`.mjs`/`.cjs` are excluded: they have no template markup,
57/// so scanning them would add a regex pass per file on large repos for no gain.
58/// `.js`-with-JSX is a documented limitation.
59const MARKUP_EXTENSIONS: &[&str] = &["astro", "jsx", "tsx", "svelte", "vue", "html", "htm", "mdx"];
60
61fn is_markup_path(path: &Path) -> bool {
62    path.extension()
63        .and_then(|ext| ext.to_str())
64        .is_some_and(|ext| MARKUP_EXTENSIONS.contains(&ext))
65}
66
67fn is_vue_path(path: &Path) -> bool {
68    path.extension().and_then(|ext| ext.to_str()) == Some("vue")
69}
70
71/// Extract deduped Iconify collection prefixes from static icon props in
72/// `source`. Returns an empty `Vec` for non-markup file kinds. See issue #608.
73#[must_use]
74pub fn extract_iconify_prefixes(path: &Path, source: &str) -> Vec<String> {
75    if !is_markup_path(path) {
76        return Vec::new();
77    }
78
79    let scanned = HTML_COMMENT.replace_all(source, "");
80    let mut prefixes: Vec<String> = ICON_PROP
81        .captures_iter(&scanned)
82        .map(|caps| caps[1].to_string())
83        .collect();
84    prefixes.sort_unstable();
85    prefixes.dedup();
86    prefixes
87}
88
89/// Extract deduped Nuxt UI icon class suffixes from static Vue SFC script-side
90/// `icon` properties. Returned names omit the leading `i-`; core resolves them
91/// against declared `@iconify-json/*` packages using longest-prefix matching.
92#[must_use]
93pub fn extract_iconify_icon_names(path: &Path, source: &str) -> Vec<String> {
94    if !is_vue_path(path) {
95        return Vec::new();
96    }
97
98    let mut names: Vec<String> = NUXT_UI_ICON_PROP
99        .captures_iter(source)
100        .map(|caps| caps[1].to_string())
101        .collect();
102    names.sort_unstable();
103    names.dedup();
104    names
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use std::path::Path;
111
112    fn prefixes(source: &str) -> Vec<String> {
113        extract_iconify_prefixes(Path::new("src/pages/index.astro"), source)
114    }
115
116    fn icon_names(source: &str) -> Vec<String> {
117        extract_iconify_icon_names(Path::new("app/layouts/default.vue"), source)
118    }
119
120    #[test]
121    fn extracts_name_prop_double_quoted() {
122        assert_eq!(prefixes(r#"<Icon name="jam:github" />"#), vec!["jam"]);
123    }
124
125    #[test]
126    fn extracts_icon_prop_single_quoted() {
127        assert_eq!(prefixes(r"<List icon='ic:round-home' />"), vec!["ic"]);
128    }
129
130    #[test]
131    fn dedupes_and_sorts_multiple_icons() {
132        let source = r#"
133            <Icon name="jam:github" />
134            <Icon name="jam:linkedin" />
135            <List icon="ic:round-home" />
136        "#;
137        assert_eq!(prefixes(source), vec!["ic", "jam"]);
138    }
139
140    #[test]
141    fn handles_hyphenated_collection_prefixes() {
142        let source = r#"<Icon name="simple-icons:github" /><Icon icon="fa6-solid:house" />"#;
143        assert_eq!(prefixes(source), vec!["fa6-solid", "simple-icons"]);
144    }
145
146    #[test]
147    fn ignores_attribute_names_that_merely_end_in_name() {
148        assert!(prefixes(r#"<div data-name="jam:github" />"#).is_empty());
149        assert!(prefixes(r#"<a filename="ic:home" />"#).is_empty());
150    }
151
152    #[test]
153    fn ignores_values_without_a_colon_prefix() {
154        assert!(prefixes(r#"<input name="email" />"#).is_empty());
155        assert!(prefixes(r#"<Icon name="github" />"#).is_empty());
156    }
157
158    #[test]
159    fn ignores_bare_prefix_with_no_icon_name() {
160        assert!(prefixes(r#"<Icon name="jam:" />"#).is_empty());
161    }
162
163    #[test]
164    fn ignores_dynamic_bindings() {
165        assert!(prefixes(r#"<Icon :name="iconExpr" />"#).is_empty());
166        assert!(prefixes(r"<Icon name={iconExpr} />").is_empty());
167    }
168
169    #[test]
170    fn ignores_icons_inside_html_comments() {
171        assert!(prefixes(r#"<!-- <Icon name="jam:github" /> -->"#).is_empty());
172        let source = "<!--\n  <List icon=\"ic:round-home\" />\n-->\n<Icon name=\"mdi:home\" />";
173        assert_eq!(prefixes(source), vec!["mdi"]);
174    }
175
176    #[test]
177    fn returns_empty_for_non_markup_extensions() {
178        let prefixes = extract_iconify_prefixes(
179            Path::new("src/util.ts"),
180            r#"const x = { name: "jam:github" };"#,
181        );
182        assert!(prefixes.is_empty());
183    }
184
185    #[test]
186    fn extracts_nuxt_ui_script_icon_property() {
187        let source = r#"
188            const links = [{
189                label: 'View page source',
190                icon: 'i-simple-icons-github'
191            }, {
192                "icon": "i-lucide-house"
193            }]
194        "#;
195        assert_eq!(
196            icon_names(source),
197            vec!["lucide-house", "simple-icons-github"]
198        );
199    }
200
201    #[test]
202    fn ignores_nuxt_ui_icon_strings_without_icon_property() {
203        let source = r"
204            const links = [{
205                label: 'i-simple-icons-github',
206                iconName: 'i-lucide-house'
207            }]
208        ";
209        assert!(icon_names(source).is_empty());
210    }
211
212    #[test]
213    fn ignores_nuxt_ui_icon_names_outside_vue_files() {
214        let names = extract_iconify_icon_names(
215            Path::new("app/navigation.ts"),
216            r"const link = { icon: 'i-simple-icons-github' }",
217        );
218        assert!(names.is_empty());
219    }
220}