Skip to main content

omena_bridge/utility_intelligence/
mod.rs

1use std::{
2    collections::VecDeque,
3    fs,
4    path::{Path, PathBuf},
5};
6
7use omena_abstract_value::FactPrecision;
8use omena_parser::ParserByteSpanV0;
9use serde::Serialize;
10
11use crate::{
12    SourceClassValueUniverseEntryV0, SourceClassValueUnresolvedV0,
13    SourceDomainClassReferenceFactV0, SourceSyntaxIndexV0,
14};
15
16mod config;
17
18const UTILITY_PROVIDER_ID: &str = "tailwind-uno-utility-domain";
19const UTILITY_OWNER_NAME: &str = "workspace";
20const UTILITY_REFERENCE_DOMAIN: &str = "utility-classes";
21const DISCOVERY_DIR_LIMIT: usize = 256;
22const DISCOVERY_MAX_DEPTH: usize = 3;
23const CONFIG_NAMES: &[&str] = &[
24    "tailwind.config.js",
25    "tailwind.config.ts",
26    "tailwind.config.cjs",
27    "tailwind.config.mjs",
28    "tailwind.config.cts",
29    "tailwind.config.mts",
30    "uno.config.js",
31    "uno.config.ts",
32    "uno.config.cjs",
33    "uno.config.mjs",
34    "uno.config.cts",
35    "uno.config.mts",
36];
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
39#[serde(rename_all = "camelCase")]
40pub enum UtilityConfigKindV0 {
41    Tailwind,
42    UnoCss,
43}
44
45impl UtilityConfigKindV0 {
46    pub(crate) const fn domain(self) -> &'static str {
47        match self {
48            Self::Tailwind => "tailwind-utilities",
49            Self::UnoCss => "unocss-utilities",
50        }
51    }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55#[serde(rename_all = "camelCase")]
56pub struct UtilityClassIntelligenceReportV0 {
57    pub schema_version: &'static str,
58    pub product: &'static str,
59    pub config_paths: Vec<String>,
60    pub class_value_universes: Vec<SourceClassValueUniverseEntryV0>,
61    pub precision: FactPrecision,
62}
63
64impl Default for UtilityClassIntelligenceReportV0 {
65    fn default() -> Self {
66        Self {
67            schema_version: "0",
68            product: "omena-bridge.utility-class-intelligence",
69            config_paths: Vec::new(),
70            class_value_universes: Vec::new(),
71            precision: FactPrecision::Unknown,
72        }
73    }
74}
75
76impl UtilityClassIntelligenceReportV0 {
77    pub fn enumerated_class_count(&self) -> usize {
78        self.class_value_universes
79            .iter()
80            .map(|entry| entry.class_names.len())
81            .sum()
82    }
83
84    pub fn pattern_count(&self) -> usize {
85        self.class_value_universes
86            .iter()
87            .map(|entry| entry.patterns.len())
88            .sum()
89    }
90
91    pub fn unresolved_count(&self) -> usize {
92        self.class_value_universes
93            .iter()
94            .map(|entry| entry.unresolved.len())
95            .sum()
96    }
97
98    pub fn unresolved(&self) -> impl Iterator<Item = &SourceClassValueUnresolvedV0> {
99        self.class_value_universes
100            .iter()
101            .flat_map(|entry| entry.unresolved.iter())
102    }
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
106#[serde(rename_all = "camelCase")]
107pub enum UtilityClassMembershipKindV0 {
108    Enumerated,
109    Pattern,
110    Outside,
111    Indeterminate,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
115#[serde(rename_all = "camelCase")]
116pub struct UtilityClassLintSignalV0 {
117    pub class_name: String,
118    pub membership: UtilityClassMembershipKindV0,
119    pub undefined_class_signal: bool,
120    pub matched_pattern: Option<String>,
121    pub unresolved_count: usize,
122}
123
124pub fn summarize_omena_bridge_utility_class_intelligence_for_config(
125    config_path: &Path,
126    config_source: &str,
127) -> UtilityClassIntelligenceReportV0 {
128    let kind = config_kind_from_path(config_path);
129    let universe = config::summarize_config(config_path, config_source, kind);
130    UtilityClassIntelligenceReportV0 {
131        config_paths: vec![config_path.to_string_lossy().to_string()],
132        class_value_universes: vec![universe],
133        ..UtilityClassIntelligenceReportV0::default()
134    }
135}
136
137pub fn load_omena_bridge_workspace_utility_class_intelligence(
138    workspace_root: &Path,
139    explicit_config_path: Option<&Path>,
140) -> UtilityClassIntelligenceReportV0 {
141    let declared_settings = declared_utility_config_settings(workspace_root);
142    if explicit_config_path.is_none() && declared_settings.enabled == Some(false) {
143        return UtilityClassIntelligenceReportV0::default();
144    }
145    let declared_config = explicit_config_path
146        .map(Path::to_path_buf)
147        .or(declared_settings.config_path);
148    let config_paths = declared_config.map_or_else(
149        || discover_utility_config_paths(workspace_root),
150        |path| {
151            let path = if path.is_absolute() {
152                path
153            } else {
154                workspace_root.join(path)
155            };
156            vec![path]
157        },
158    );
159
160    let mut report = UtilityClassIntelligenceReportV0::default();
161    for config_path in config_paths {
162        let config_path_text = config_path.to_string_lossy().to_string();
163        report.config_paths.push(config_path_text.clone());
164        match fs::read_to_string(config_path.as_path()) {
165            Ok(source) => report.class_value_universes.push(config::summarize_config(
166                config_path.as_path(),
167                source.as_str(),
168                config_kind_from_path(config_path.as_path()),
169            )),
170            Err(error) => report
171                .class_value_universes
172                .push(unreadable_config_universe(
173                    config_path_text,
174                    error.to_string(),
175                )),
176        }
177    }
178    report.config_paths.sort();
179    report.config_paths.dedup();
180    report
181}
182
183pub fn append_omena_bridge_utility_class_intelligence(
184    index: &mut SourceSyntaxIndexV0,
185    source: &str,
186    report: &UtilityClassIntelligenceReportV0,
187) {
188    index
189        .class_value_universes
190        .retain(|entry| entry.plugin_id != UTILITY_PROVIDER_ID);
191    index
192        .domain_class_references
193        .retain(|reference| reference.plugin_id != UTILITY_PROVIDER_ID);
194    index
195        .class_value_universes
196        .extend(report.class_value_universes.iter().cloned());
197
198    if report.class_value_universes.is_empty() {
199        return;
200    }
201    for literal_span in index.class_string_literals.clone() {
202        let Some(literal) = source.get(literal_span.start..literal_span.end) else {
203            continue;
204        };
205        for (relative_start, relative_end, class_name) in class_tokens(literal) {
206            index
207                .domain_class_references
208                .push(SourceDomainClassReferenceFactV0 {
209                    byte_span: ParserByteSpanV0 {
210                        start: literal_span.start + relative_start,
211                        end: literal_span.start + relative_end,
212                    },
213                    plugin_id: UTILITY_PROVIDER_ID,
214                    domain: UTILITY_REFERENCE_DOMAIN,
215                    owner_name: UTILITY_OWNER_NAME.to_string(),
216                    axis_name: "class".to_string(),
217                    option_name: Some(class_name.to_string()),
218                    prefix: None,
219                });
220        }
221    }
222    index.domain_class_references.sort_by_key(|reference| {
223        (
224            reference.byte_span.start,
225            reference.byte_span.end,
226            reference.plugin_id,
227            reference.option_name.clone(),
228        )
229    });
230    index.domain_class_references.dedup();
231}
232
233pub fn classify_omena_bridge_utility_class(
234    report: &UtilityClassIntelligenceReportV0,
235    class_name: &str,
236) -> UtilityClassLintSignalV0 {
237    let exact = report.class_value_universes.iter().any(|entry| {
238        entry
239            .class_names
240            .iter()
241            .any(|candidate| candidate == class_name)
242    });
243    if exact {
244        return utility_signal(
245            class_name,
246            UtilityClassMembershipKindV0::Enumerated,
247            None,
248            report,
249        );
250    }
251
252    let mut has_unresolved_pattern = false;
253    for pattern in report
254        .class_value_universes
255        .iter()
256        .flat_map(|entry| entry.patterns.iter())
257    {
258        match pattern.matches(class_name) {
259            Some(true) => {
260                return utility_signal(
261                    class_name,
262                    UtilityClassMembershipKindV0::Pattern,
263                    Some(pattern.source.clone()),
264                    report,
265                );
266            }
267            Some(false) => {}
268            None => has_unresolved_pattern = true,
269        }
270    }
271
272    let unresolved_count = report.unresolved_count();
273    let membership = if unresolved_count > 0 || has_unresolved_pattern {
274        UtilityClassMembershipKindV0::Indeterminate
275    } else {
276        UtilityClassMembershipKindV0::Outside
277    };
278    utility_signal(class_name, membership, None, report)
279}
280
281fn utility_signal(
282    class_name: &str,
283    membership: UtilityClassMembershipKindV0,
284    matched_pattern: Option<String>,
285    report: &UtilityClassIntelligenceReportV0,
286) -> UtilityClassLintSignalV0 {
287    UtilityClassLintSignalV0 {
288        class_name: class_name.to_string(),
289        membership,
290        undefined_class_signal: membership == UtilityClassMembershipKindV0::Outside,
291        matched_pattern,
292        unresolved_count: report.unresolved_count(),
293    }
294}
295
296fn unreadable_config_universe(
297    config_path: String,
298    detail: String,
299) -> SourceClassValueUniverseEntryV0 {
300    SourceClassValueUniverseEntryV0 {
301        plugin_id: UTILITY_PROVIDER_ID,
302        domain: config_kind_from_path(Path::new(config_path.as_str())).domain(),
303        owner_name: UTILITY_OWNER_NAME.to_string(),
304        class_names: Vec::new(),
305        axes: Vec::new(),
306        patterns: Vec::new(),
307        unresolved: vec![SourceClassValueUnresolvedV0 {
308            path: config_path,
309            reason: "config-read-failed".to_string(),
310            detail,
311        }],
312        byte_span: ParserByteSpanV0 { start: 0, end: 0 },
313    }
314}
315
316fn config_kind_from_path(path: &Path) -> UtilityConfigKindV0 {
317    let name = path
318        .file_name()
319        .and_then(|name| name.to_str())
320        .unwrap_or_default();
321    if name.starts_with("uno.") || name.starts_with("unocss.") {
322        UtilityConfigKindV0::UnoCss
323    } else {
324        UtilityConfigKindV0::Tailwind
325    }
326}
327
328fn discover_utility_config_paths(workspace_root: &Path) -> Vec<PathBuf> {
329    let mut paths = Vec::new();
330    let mut queue = VecDeque::from([(workspace_root.to_path_buf(), 0usize)]);
331    let mut visited = 0usize;
332    while let Some((directory, depth)) = queue.pop_front() {
333        if visited >= DISCOVERY_DIR_LIMIT {
334            break;
335        }
336        visited += 1;
337        for name in CONFIG_NAMES {
338            let candidate = directory.join(name);
339            if candidate.is_file() {
340                paths.push(candidate);
341            }
342        }
343        if depth >= DISCOVERY_MAX_DEPTH {
344            continue;
345        }
346        let Ok(entries) = fs::read_dir(directory) else {
347            continue;
348        };
349        for entry in entries.flatten() {
350            let path = entry.path();
351            let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
352                continue;
353            };
354            if path.is_dir() && !should_skip_discovery_directory(name) {
355                queue.push_back((path, depth + 1));
356            }
357        }
358    }
359    paths.sort();
360    paths.dedup();
361    paths
362}
363
364fn should_skip_discovery_directory(name: &str) -> bool {
365    name.starts_with('.')
366        || matches!(
367            name,
368            "coverage" | "dist" | "node_modules" | "target" | "vendor"
369        )
370}
371
372#[derive(Default)]
373struct DeclaredUtilityConfigSettings {
374    enabled: Option<bool>,
375    config_path: Option<PathBuf>,
376}
377
378fn declared_utility_config_settings(workspace_root: &Path) -> DeclaredUtilityConfigSettings {
379    for name in ["omena.toml", "omena.config.toml", "omena.config.json"] {
380        let path = workspace_root.join(name);
381        let Ok(source) = fs::read_to_string(path.as_path()) else {
382            continue;
383        };
384        let value = if name.ends_with(".json") {
385            serde_json::from_str::<serde_json::Value>(source.as_str()).ok()
386        } else {
387            toml::from_str::<toml::Value>(source.as_str())
388                .ok()
389                .and_then(|value| serde_json::to_value(value).ok())
390        };
391        let Some(value) = value else {
392            continue;
393        };
394        let tailwind = value.pointer("/intelligence/tailwind");
395        return DeclaredUtilityConfigSettings {
396            enabled: tailwind
397                .and_then(|value| value.get("enabled"))
398                .and_then(serde_json::Value::as_bool),
399            config_path: tailwind
400                .and_then(|value| value.get("configPath"))
401                .and_then(serde_json::Value::as_str)
402                .map(PathBuf::from),
403        };
404    }
405    DeclaredUtilityConfigSettings::default()
406}
407
408fn class_tokens(source: &str) -> Vec<(usize, usize, &str)> {
409    let mut tokens = Vec::new();
410    let mut start = None;
411    for (offset, character) in source.char_indices() {
412        if character.is_whitespace() {
413            if let Some(token_start) = start.take() {
414                tokens.push((token_start, offset, &source[token_start..offset]));
415            }
416        } else if start.is_none() {
417            start = Some(offset);
418        }
419    }
420    if let Some(token_start) = start {
421        tokens.push((token_start, source.len(), &source[token_start..]));
422    }
423    tokens
424}
425
426#[cfg(test)]
427mod tests;