omena_bridge/utility_intelligence/
mod.rs1use 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;