Skip to main content

grimoire_css_lib/analyzer/
mod.rs

1#[cfg(feature = "mcp")]
2use crate::transmutator::{Transmutation, TransmuteOptions, transmute_css, transmute_paths};
3use crate::{
4    GrimoireCssError, Spell,
5    config::{ConfigFs, external_config_files},
6    core::{Filesystem, css_builder::CssBuilder, parser::Parser},
7    infrastructure::LightningCssOptimizer,
8};
9use serde::Serialize;
10use serde_json::{Value, json};
11#[cfg(feature = "mcp")]
12use std::io::Write;
13use std::{
14    collections::{HashMap, HashSet},
15    fs,
16    path::{Path, PathBuf},
17};
18
19use glob::glob;
20
21#[derive(Debug, Clone, Serialize)]
22pub struct ExplainClassTokenResult {
23    pub class_token: String,
24    pub expanded_spells: Vec<String>,
25    pub css: String,
26}
27
28pub struct Analyzer;
29
30#[derive(Debug, Clone, Serialize)]
31pub struct DryOccurrence {
32    pub file: String,
33    pub line: usize,
34    pub column: usize,
35    pub tokens: Vec<String>,
36}
37
38#[derive(Debug, Clone, Serialize)]
39pub struct DryCandidate {
40    pub tokens: Vec<String>,
41    pub support: usize,
42    pub occurrences: Vec<DryOccurrence>,
43}
44
45#[derive(Debug, Clone, Serialize)]
46pub struct DryCandidatesResult {
47    pub files_scanned: usize,
48    pub class_occurrences: usize,
49    pub candidates: Vec<DryCandidate>,
50}
51
52#[derive(Debug, Clone, Serialize)]
53pub struct TokenOccurrence {
54    pub token: String,
55    pub file: String,
56    pub byte_offset: usize,
57    pub byte_len: usize,
58    pub line: usize,
59    pub column: usize,
60}
61
62#[derive(Debug, Clone, Serialize)]
63pub struct ScrollReference {
64    pub scroll: String,
65    pub arity: usize,
66    pub occurrence: TokenOccurrence,
67}
68
69#[derive(Debug, Clone, Serialize)]
70pub struct SpellReference {
71    pub spell: String,
72    pub occurrence: TokenOccurrence,
73}
74
75#[derive(Debug, Clone, Serialize)]
76pub struct SpellFrequency {
77    pub spell: String,
78    pub count: u64,
79}
80
81#[derive(Debug, Clone, Serialize)]
82pub struct IndexError {
83    pub file: String,
84    pub byte_offset: usize,
85    pub byte_len: usize,
86    pub message: String,
87}
88
89#[derive(Debug, Clone, Serialize)]
90pub struct IndexResult {
91    pub files_scanned: usize,
92    pub token_occurrences: usize,
93    pub scroll_references: Vec<ScrollReference>,
94    pub top_expanded_spells: Vec<SpellFrequency>,
95    pub css_variables_read: Vec<String>,
96    pub css_variables_written: Vec<String>,
97    pub errors: Vec<IndexError>,
98}
99
100#[derive(Debug, Clone, Serialize)]
101pub struct VariableReference {
102    pub variable: String,
103    pub kind: String,
104    pub spell: String,
105    pub occurrence: TokenOccurrence,
106}
107
108#[derive(Debug, Clone, Serialize)]
109pub struct GrimoireVariableDefinition {
110    pub name: String,
111    pub value: String,
112}
113
114#[derive(Debug, Clone, Serialize)]
115pub struct GrimoireVariableReference {
116    pub variable: String,
117    pub spell: String,
118    pub occurrence: TokenOccurrence,
119}
120
121#[derive(Debug, Clone, Serialize)]
122pub struct LintMessage {
123    pub level: String,
124    pub code: String,
125    pub message: String,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub occurrence: Option<TokenOccurrence>,
128}
129
130#[derive(Debug, Clone, Serialize)]
131pub struct LintResult {
132    pub errors: Vec<LintMessage>,
133    pub warnings: Vec<LintMessage>,
134    pub notes: Vec<LintMessage>,
135}
136
137#[derive(Debug, Clone, Serialize)]
138pub struct ConfigProjectSummary {
139    pub name: String,
140    pub input_paths: Vec<String>,
141    pub output_dir_path: Option<String>,
142    pub single_output_file_name: Option<String>,
143}
144
145#[derive(Debug, Clone, Serialize)]
146pub struct ConfigSummary {
147    pub config_path: String,
148    pub projects: Vec<ConfigProjectSummary>,
149    pub scrolls: Vec<String>,
150    pub variables: Vec<GrimoireVariableDefinition>,
151    pub shared_spells: Vec<String>,
152    pub custom_animations: Vec<String>,
153    pub css_custom_properties: Vec<String>,
154    pub external_scroll_files: Vec<String>,
155    pub external_variable_files: Vec<String>,
156}
157
158#[cfg(feature = "mcp")]
159#[derive(Debug, Clone, Serialize)]
160pub struct ValidationIssue {
161    pub stage: String,
162    pub path: String,
163    pub message: String,
164}
165
166#[cfg(feature = "mcp")]
167#[derive(Debug, Clone, Serialize)]
168pub struct ConfigValidationResult {
169    pub valid: bool,
170    pub config_path: String,
171    pub schema_valid: bool,
172    pub engine_load_valid: bool,
173    pub issues: Vec<ValidationIssue>,
174}
175
176#[cfg(feature = "mcp")]
177#[derive(Debug, Clone, Serialize)]
178pub struct SpellValidationItem {
179    pub token: String,
180    pub valid: bool,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub expanded_spells: Option<Vec<String>>,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub css: Option<String>,
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub error: Option<String>,
187}
188
189#[cfg(feature = "mcp")]
190#[derive(Debug, Clone, Serialize)]
191pub struct SpellsValidationResult {
192    pub valid: bool,
193    pub checked: usize,
194    pub items: Vec<SpellValidationItem>,
195}
196
197#[cfg(feature = "mcp")]
198#[derive(Debug, Clone, Serialize)]
199pub struct BuildCheckResult {
200    pub attempted: bool,
201    pub successful: bool,
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub error: Option<String>,
204}
205
206#[cfg(feature = "mcp")]
207#[derive(Debug, Clone, Serialize)]
208pub struct ProjectCheckResult {
209    pub valid: bool,
210    pub config: ConfigValidationResult,
211    pub spells_valid: bool,
212    pub spell_errors: Vec<IndexError>,
213    pub lint_clean: bool,
214    pub lint: LintResult,
215    pub build: BuildCheckResult,
216}
217
218#[cfg(feature = "mcp")]
219#[derive(Debug, Clone, Serialize)]
220pub struct TransmutationValidationResult {
221    pub valid: bool,
222    pub transmutation: Transmutation,
223    pub validation: SpellsValidationResult,
224}
225
226#[cfg(feature = "mcp")]
227#[derive(Debug, Clone, Serialize)]
228pub struct CssImportResult {
229    pub valid: bool,
230    pub import_path: String,
231    pub transmutation: Transmutation,
232    pub validation: SpellsValidationResult,
233    pub project_check: Option<ProjectCheckResult>,
234    pub rolled_back: bool,
235    pub rollback_error: Option<String>,
236    pub error: Option<String>,
237    pub build_outputs_transactional: bool,
238    pub warning: String,
239}
240
241#[cfg(feature = "mcp")]
242fn is_valid_import_name(value: &str) -> bool {
243    !value.is_empty()
244        && value
245            .bytes()
246            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
247}
248
249#[cfg(feature = "mcp")]
250fn collect_existing_scroll_names(
251    config_path: &Path,
252    config_dir: &Path,
253    excluded: &Path,
254) -> Result<HashSet<String>, GrimoireCssError> {
255    let mut names = scroll_names_from_file(config_path)?;
256    for path in external_config_files(config_dir, ".scrolls.json")? {
257        if path != excluded {
258            names.extend(scroll_names_from_file(&path)?);
259        }
260    }
261    Ok(names)
262}
263
264#[cfg(feature = "mcp")]
265fn scroll_names_from_file(path: &Path) -> Result<HashSet<String>, GrimoireCssError> {
266    let value: Value = serde_json::from_slice(&fs::read(path)?)?;
267    Ok(value
268        .get("scrolls")
269        .and_then(Value::as_array)
270        .into_iter()
271        .flatten()
272        .filter_map(|scroll| scroll.get("name").and_then(Value::as_str))
273        .map(str::to_string)
274        .collect())
275}
276
277#[cfg(feature = "mcp")]
278fn validate_external_config_files(
279    current_dir: &Path,
280    validator: &jsonschema::Validator,
281) -> Vec<ValidationIssue> {
282    let config_dir = current_dir.join("grimoire/config");
283    let mut issues = Vec::new();
284
285    for (suffix, property) in [
286        (".scrolls.json", "scrolls"),
287        (".variables.json", "variables"),
288    ] {
289        let paths = match external_config_files(&config_dir, suffix) {
290            Ok(paths) => paths,
291            Err(error) => {
292                issues.push(ValidationIssue {
293                    stage: "external_discovery".to_string(),
294                    path: Analyzer::to_rel(current_dir, &config_dir),
295                    message: error.to_string(),
296                });
297                continue;
298            }
299        };
300
301        for path in paths {
302            let display_path = Analyzer::to_rel(current_dir, &path);
303            let content = match fs::read_to_string(&path) {
304                Ok(content) => content,
305                Err(error) => {
306                    issues.push(ValidationIssue {
307                        stage: "external_read".to_string(),
308                        path: display_path,
309                        message: error.to_string(),
310                    });
311                    continue;
312                }
313            };
314            let external: Value = match serde_json::from_str(&content) {
315                Ok(external) => external,
316                Err(error) => {
317                    issues.push(ValidationIssue {
318                        stage: "external_json".to_string(),
319                        path: display_path,
320                        message: error.to_string(),
321                    });
322                    continue;
323                }
324            };
325            let Some(object) = external.as_object() else {
326                issues.push(ValidationIssue {
327                    stage: "external_schema".to_string(),
328                    path: display_path,
329                    message: "external config must be a JSON object".to_string(),
330                });
331                continue;
332            };
333            if object.len() != 1 || !object.contains_key(property) {
334                issues.push(ValidationIssue {
335                    stage: "external_schema".to_string(),
336                    path: display_path,
337                    message: format!("external config must contain only '{property}'"),
338                });
339                continue;
340            }
341
342            let mut synthetic = json!({"projects":[]});
343            synthetic[property] = object[property].clone();
344            issues.extend(
345                validator
346                    .iter_errors(&synthetic)
347                    .map(|error| ValidationIssue {
348                        stage: "external_schema".to_string(),
349                        path: display_path.clone(),
350                        message: error.to_string(),
351                    }),
352            );
353        }
354    }
355
356    issues
357}
358
359#[cfg(feature = "mcp")]
360fn atomic_write(path: &Path, bytes: &[u8], replace: bool) -> Result<(), GrimoireCssError> {
361    let parent = path.parent().ok_or_else(|| {
362        GrimoireCssError::InvalidPath(format!("Path has no parent: {}", path.display()))
363    })?;
364    fs::create_dir_all(parent)?;
365    let mut builder = tempfile::Builder::new();
366    builder.prefix(".grimoire-import-");
367    #[cfg(unix)]
368    {
369        use std::os::unix::fs::PermissionsExt;
370        builder.permissions(fs::Permissions::from_mode(0o666));
371    }
372    let mut file = builder.tempfile_in(parent)?;
373    file.write_all(bytes)?;
374    file.as_file().sync_all()?;
375    if replace {
376        file.persist(path)
377    } else {
378        file.persist_noclobber(path)
379    }
380    .map_err(|error| GrimoireCssError::Io(error.error))?;
381    Ok(())
382}
383
384#[cfg(feature = "mcp")]
385fn restore_import(
386    path: &Path,
387    previous: Option<&[u8]>,
388    installed: &[u8],
389) -> Result<(), GrimoireCssError> {
390    match fs::read(path) {
391        Ok(current) if current == installed => {}
392        Ok(_) => {
393            return Err(GrimoireCssError::RuntimeError(format!(
394                "Import target changed concurrently; refusing to roll it back: {}",
395                path.display()
396            )));
397        }
398        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
399            return Err(GrimoireCssError::RuntimeError(format!(
400                "Import target was removed concurrently; refusing to recreate it: {}",
401                path.display()
402            )));
403        }
404        Err(error) => return Err(GrimoireCssError::Io(error)),
405    }
406
407    if let Some(bytes) = previous {
408        atomic_write(path, bytes, true)
409    } else {
410        match fs::remove_file(path) {
411            Ok(()) => Ok(()),
412            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
413            Err(error) => Err(GrimoireCssError::Io(error)),
414        }
415    }
416}
417
418impl Analyzer {
419    pub fn load_config(current_dir: &Path) -> Result<ConfigFs, GrimoireCssError> {
420        ConfigFs::load_read_only(current_dir)
421    }
422
423    #[cfg(feature = "mcp")]
424    /// Validates main and external configs against the schema, then loads them.
425    pub fn validate_config(current_dir: &Path) -> Result<ConfigValidationResult, GrimoireCssError> {
426        let config_path = current_dir.join("grimoire/config/grimoire.config.json");
427        let display_path = Self::to_rel(current_dir, &config_path);
428        let content = match fs::read_to_string(&config_path) {
429            Ok(content) => content,
430            Err(error) => {
431                return Ok(ConfigValidationResult {
432                    valid: false,
433                    config_path: display_path.clone(),
434                    schema_valid: false,
435                    engine_load_valid: false,
436                    issues: vec![ValidationIssue {
437                        stage: "read".to_string(),
438                        path: display_path,
439                        message: error.to_string(),
440                    }],
441                });
442            }
443        };
444
445        let instance: Value = match serde_json::from_str(&content) {
446            Ok(instance) => instance,
447            Err(error) => {
448                return Ok(ConfigValidationResult {
449                    valid: false,
450                    config_path: display_path.clone(),
451                    schema_valid: false,
452                    engine_load_valid: false,
453                    issues: vec![ValidationIssue {
454                        stage: "json".to_string(),
455                        path: display_path,
456                        message: error.to_string(),
457                    }],
458                });
459            }
460        };
461
462        let schema: Value =
463            serde_json::from_str(include_str!("../core/config/config-schema.json"))?;
464        let validator = jsonschema::validator_for(&schema).map_err(|error| {
465            GrimoireCssError::RuntimeError(format!("Invalid embedded config schema: {error}"))
466        })?;
467        let mut issues = validator
468            .iter_errors(&instance)
469            .map(|error| ValidationIssue {
470                stage: "schema".to_string(),
471                path: error.instance_path.to_string(),
472                message: error.to_string(),
473            })
474            .collect::<Vec<_>>();
475        issues.extend(validate_external_config_files(current_dir, &validator));
476        let schema_valid = issues.is_empty();
477
478        let engine_load_valid = match Self::load_config(current_dir) {
479            Ok(_) => true,
480            Err(error) => {
481                issues.push(ValidationIssue {
482                    stage: "engine_load".to_string(),
483                    path: display_path.clone(),
484                    message: error.to_string(),
485                });
486                false
487            }
488        };
489
490        Ok(ConfigValidationResult {
491            valid: schema_valid && engine_load_valid,
492            config_path: display_path,
493            schema_valid,
494            engine_load_valid,
495            issues,
496        })
497    }
498
499    #[cfg(feature = "mcp")]
500    /// Validates each Spell or Scroll invocation by parsing and compiling it.
501    pub fn validate_spells(
502        current_dir: &Path,
503        tokens: &[String],
504    ) -> Result<SpellsValidationResult, GrimoireCssError> {
505        Ok(Self::validate_spells_using(tokens, |token| {
506            Self::explain_class_token(current_dir, token)
507        }))
508    }
509
510    #[cfg(feature = "mcp")]
511    fn validate_spells_using(
512        tokens: &[String],
513        explain: impl Fn(&str) -> Result<ExplainClassTokenResult, GrimoireCssError>,
514    ) -> SpellsValidationResult {
515        let items = tokens
516            .iter()
517            .map(|token| match explain(token) {
518                Ok(result) => SpellValidationItem {
519                    token: token.clone(),
520                    valid: true,
521                    expanded_spells: Some(result.expanded_spells),
522                    css: Some(result.css),
523                    error: None,
524                },
525                Err(error) => SpellValidationItem {
526                    token: token.clone(),
527                    valid: false,
528                    expanded_spells: None,
529                    css: None,
530                    error: Some(error.to_string()),
531                },
532            })
533            .collect::<Vec<_>>();
534        SpellsValidationResult {
535            valid: !items.is_empty() && items.iter().all(|item| item.valid),
536            checked: items.len(),
537            items,
538        }
539    }
540
541    #[cfg(feature = "mcp")]
542    fn validate_transmuted_spells(
543        config: &ConfigFs,
544        transmutation: &Transmutation,
545    ) -> Result<SpellsValidationResult, GrimoireCssError> {
546        let names = config
547            .scrolls
548            .as_ref()
549            .map(|scrolls| scrolls.keys().cloned().collect())
550            .unwrap_or_default();
551        transmutation.validate_component_scroll_conflicts(&names)?;
552        let tokens = transmutation
553            .scrolls
554            .iter()
555            .flat_map(|scroll| scroll.spells.iter().cloned())
556            .collect::<Vec<_>>();
557        Ok(Self::validate_spells_using(&tokens, |token| {
558            Self::explain_class_token_with_config(config, token)
559        }))
560    }
561
562    #[cfg(feature = "mcp")]
563    pub fn transmute_and_validate(
564        current_dir: &Path,
565        css: &str,
566        options: TransmuteOptions,
567    ) -> Result<TransmutationValidationResult, GrimoireCssError> {
568        let transmutation = transmute_css(css, options)?;
569        let config = Self::load_config(current_dir)?;
570        let validation = Self::validate_transmuted_spells(&config, &transmutation)?;
571
572        Ok(TransmutationValidationResult {
573            valid: validation.valid,
574            transmutation,
575            validation,
576        })
577    }
578
579    #[cfg(feature = "mcp")]
580    pub fn import_css(
581        current_dir: &Path,
582        content: Option<&str>,
583        paths: Option<&[String]>,
584        import_name: &str,
585        options: TransmuteOptions,
586        replace: bool,
587    ) -> Result<CssImportResult, GrimoireCssError> {
588        let config_path = current_dir.join("grimoire/config/grimoire.config.json");
589        if !config_path.is_file() {
590            return Err(GrimoireCssError::InvalidInput(
591                "Grimoire CSS is not initialized; call grimoire_init before importing CSS".into(),
592            ));
593        }
594        if !is_valid_import_name(import_name) {
595            return Err(GrimoireCssError::InvalidInput(
596                "import_name must contain only ASCII letters, digits, '-' or '_'".into(),
597            ));
598        }
599        if content.is_some() == paths.is_some() {
600            return Err(GrimoireCssError::InvalidInput(
601                "CSS import requires exactly one of content or paths".into(),
602            ));
603        }
604
605        let config_dir = current_dir.join("grimoire/config");
606        let target = config_dir.join(format!("grimoire.{import_name}.scrolls.json"));
607        let import_path = Self::to_rel(current_dir, &target);
608        let transaction_lock = fs::OpenOptions::new()
609            .read(true)
610            .write(true)
611            .create(true)
612            .truncate(false)
613            .open(config_dir.join(".grimoire-css-import.lock"))?;
614        transaction_lock.lock()?;
615
616        let initial_config = Self::validate_config(current_dir)?;
617        if !initial_config.valid {
618            return Err(GrimoireCssError::InvalidInput(
619                "The existing project configuration is invalid; fix it before importing CSS".into(),
620            ));
621        }
622
623        let transmutation = if let Some(css) = content {
624            transmute_css(css, options)?
625        } else {
626            let patterns = paths.unwrap_or_default();
627            transmute_paths(current_dir, patterns, options)?
628        };
629        let existing_names = collect_existing_scroll_names(&config_path, &config_dir, &target)?;
630        let mut config = Self::load_config(current_dir)?;
631        // Exclude definitions removed by this replacement from conflict checks.
632        if let Some(scrolls) = &mut config.scrolls {
633            scrolls.retain(|name, _| existing_names.contains(name));
634        }
635        let validation = Self::validate_transmuted_spells(&config, &transmutation)?;
636        let warning = "Build outputs follow the existing build semantics and are not part of the import transaction".to_string();
637
638        if !validation.valid {
639            return Ok(CssImportResult {
640                valid: false,
641                import_path,
642                transmutation,
643                validation,
644                project_check: None,
645                rolled_back: false,
646                rollback_error: None,
647                error: Some("Generated spells were rejected by the Grimoire CSS engine".into()),
648                build_outputs_transactional: false,
649                warning,
650            });
651        }
652
653        let previous = match fs::read(&target) {
654            Ok(bytes) => Some(bytes),
655            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
656            Err(error) => return Err(GrimoireCssError::Io(error)),
657        };
658        if previous.is_some() && !replace {
659            return Err(GrimoireCssError::InvalidInput(format!(
660                "Import file '{}' already exists; pass replace=true to replace it",
661                import_path
662            )));
663        }
664
665        let conflicts = transmutation
666            .scrolls
667            .iter()
668            .map(|scroll| scroll.name.as_str())
669            .filter(|name| existing_names.contains(*name))
670            .collect::<Vec<_>>();
671        if !conflicts.is_empty() {
672            return Err(GrimoireCssError::InvalidInput(format!(
673                "scroll name conflict: {}",
674                conflicts.join(", ")
675            )));
676        }
677
678        let external = json!({
679            "scrolls": transmutation.scrolls.iter().map(|scroll| json!({
680                "name": scroll.name,
681                "spells": scroll.spells,
682            })).collect::<Vec<_>>()
683        });
684        let encoded = serde_json::to_vec_pretty(&external)?;
685        atomic_write(&target, &encoded, previous.is_some())?;
686
687        match Self::check_project(current_dir) {
688            Ok(project_check) if project_check.valid => Ok(CssImportResult {
689                valid: true,
690                import_path,
691                transmutation,
692                validation,
693                project_check: Some(project_check),
694                rolled_back: false,
695                rollback_error: None,
696                error: None,
697                build_outputs_transactional: false,
698                warning,
699            }),
700            Ok(project_check) => {
701                let rollback = restore_import(&target, previous.as_deref(), &encoded);
702                Ok(CssImportResult {
703                    valid: false,
704                    import_path,
705                    transmutation,
706                    validation,
707                    project_check: Some(project_check),
708                    rolled_back: rollback.is_ok(),
709                    rollback_error: rollback.err().map(|error| error.to_string()),
710                    error: Some("Project verification failed after CSS import".into()),
711                    build_outputs_transactional: false,
712                    warning,
713                })
714            }
715            Err(error) => {
716                let message = error.to_string();
717                let rollback = restore_import(&target, previous.as_deref(), &encoded);
718                Ok(CssImportResult {
719                    valid: false,
720                    import_path,
721                    transmutation,
722                    validation,
723                    project_check: None,
724                    rolled_back: rollback.is_ok(),
725                    rollback_error: rollback.err().map(|error| error.to_string()),
726                    error: Some(message),
727                    build_outputs_transactional: false,
728                    warning,
729                })
730            }
731        }
732    }
733
734    #[cfg(feature = "mcp")]
735    /// Validates configuration, indexes and lints spells, then builds the project.
736    pub fn check_project(current_dir: &Path) -> Result<ProjectCheckResult, GrimoireCssError> {
737        let config = Self::validate_config(current_dir)?;
738        if !config.valid {
739            return Ok(ProjectCheckResult {
740                valid: false,
741                config,
742                spells_valid: false,
743                spell_errors: Vec::new(),
744                lint_clean: false,
745                lint: LintResult {
746                    errors: Vec::new(),
747                    warnings: Vec::new(),
748                    notes: Vec::new(),
749                },
750                build: BuildCheckResult {
751                    attempted: false,
752                    successful: false,
753                    error: Some("Build skipped because the configuration is invalid".to_string()),
754                },
755            });
756        }
757
758        let index = Self::index(current_dir, 0)?;
759        let spells_valid = index.errors.is_empty();
760        let spell_errors = index.errors;
761        let lint = Self::lint(current_dir)?;
762        let lint_clean = lint.errors.is_empty() && lint.warnings.is_empty();
763        let build = match crate::build_with_options(current_dir, false) {
764            Ok(()) => BuildCheckResult {
765                attempted: true,
766                successful: true,
767                error: None,
768            },
769            Err(error) => BuildCheckResult {
770                attempted: true,
771                successful: false,
772                error: Some(error.to_string()),
773            },
774        };
775        let valid = config.valid && spells_valid && lint_clean && build.successful;
776
777        Ok(ProjectCheckResult {
778            valid,
779            config,
780            spells_valid,
781            spell_errors,
782            lint_clean,
783            lint,
784            build,
785        })
786    }
787
788    /// Finds variable and Scroll references, with a fallback to Spell references.
789    pub fn refs(current_dir: &Path, target: &str) -> Result<Value, GrimoireCssError> {
790        let config = Self::load_config(current_dir)?;
791        let is_dollar = target.starts_with('$');
792        let variable = target.trim_start_matches('$');
793        let known_variable = config
794            .variables
795            .as_ref()
796            .is_some_and(|variables| variables.iter().any(|(name, _)| name == variable));
797        let known_scroll = config
798            .scrolls
799            .as_ref()
800            .is_some_and(|scrolls| scrolls.contains_key(target));
801        let mut results = Vec::new();
802
803        if is_dollar || known_variable {
804            let references = Self::refs_grimoire_variable(current_dir, variable)?;
805            if !references.is_empty() {
806                results.push(json!({"kind":"var","name":variable,"refs":references}));
807            }
808        }
809        if known_scroll {
810            let references = Self::refs_scroll(current_dir, target)?;
811            if !references.is_empty() {
812                results.push(json!({"kind":"scroll","name":target,"refs":references}));
813            }
814        }
815        if results.is_empty() && !is_dollar {
816            let references = Self::refs_spell(current_dir, target)?;
817            if !references.is_empty() {
818                results.push(json!({"kind":"spell","name":target,"refs":references}));
819            }
820        }
821
822        Ok(if results.is_empty() {
823            json!({
824                "query":target,
825                "results":[],
826                "note":"No references found. If you meant a variable, try prefixing with '$' (e.g. $spacing-unit)."
827            })
828        } else {
829            json!({"query":target,"results":results})
830        })
831    }
832
833    /// Returns project statistics, optionally filtered by group or token.
834    pub fn stats(
835        current_dir: &Path,
836        group: Option<&str>,
837        token: Option<&str>,
838        top: usize,
839    ) -> Result<Value, GrimoireCssError> {
840        let group = group.unwrap_or("all");
841        if !matches!(group, "all" | "spells" | "scrolls" | "vars") {
842            return Err(GrimoireCssError::InvalidInput(format!(
843                "Unknown stats group: {group}"
844            )));
845        }
846
847        let config = Self::load_config(current_dir)?;
848        let index = Self::index(current_dir, top)?;
849        let mut output = serde_json::Map::new();
850        output.insert("top".to_string(), json!(top));
851
852        if let Some(token) = token {
853            let variable = token.trim_start_matches('$');
854            let known_variable = token.starts_with('$')
855                || config
856                    .variables
857                    .as_ref()
858                    .is_some_and(|variables| variables.iter().any(|(name, _)| name == variable));
859            let known_scroll = config
860                .scrolls
861                .as_ref()
862                .is_some_and(|scrolls| scrolls.contains_key(token));
863
864            if known_variable {
865                let references = Self::refs_grimoire_variable(current_dir, variable)?;
866                output.insert(
867                    "token".to_string(),
868                    json!({"kind":"var","name":variable,"count":references.len()}),
869                );
870            } else if known_scroll {
871                let count = index
872                    .scroll_references
873                    .iter()
874                    .filter(|reference| reference.scroll == token)
875                    .count();
876                output.insert(
877                    "token".to_string(),
878                    json!({"kind":"scroll","name":token,"count":count}),
879                );
880            } else {
881                let count = Self::spell_count(current_dir, token)?;
882                output.insert(
883                    "token".to_string(),
884                    if count > 0 {
885                        json!({"kind":"spell","name":token,"count":count})
886                    } else {
887                        json!({
888                            "error":"Unknown token",
889                            "hint":"Provide a scroll name, $var name, or a spell"
890                        })
891                    },
892                );
893            }
894            return Ok(Value::Object(output));
895        }
896
897        if matches!(group, "all" | "spells") {
898            output.insert("spells".to_string(), json!(index.top_expanded_spells));
899        }
900        if matches!(group, "all" | "scrolls") {
901            let mut counts = HashMap::<String, u64>::new();
902            for reference in &index.scroll_references {
903                *counts.entry(reference.scroll.clone()).or_insert(0) += 1;
904            }
905            let mut items = counts
906                .into_iter()
907                .map(|(spell, count)| SpellFrequency { spell, count })
908                .collect::<Vec<_>>();
909            items.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.spell.cmp(&b.spell)));
910            items.truncate(top);
911            output.insert("scrolls".to_string(), json!(items));
912        }
913        if matches!(group, "all" | "vars") {
914            let mut variables = config
915                .variables
916                .as_ref()
917                .map(|variables| {
918                    variables
919                        .iter()
920                        .map(|(name, _)| name.clone())
921                        .collect::<Vec<_>>()
922                })
923                .unwrap_or_default();
924            variables.sort();
925            variables.dedup();
926            let mut items = Vec::new();
927            for variable in variables {
928                items.push(SpellFrequency {
929                    count: Self::refs_grimoire_variable(current_dir, &variable)?.len() as u64,
930                    spell: variable,
931                });
932            }
933            items.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.spell.cmp(&b.spell)));
934            items.truncate(top);
935            output.insert("vars".to_string(), json!(items));
936        }
937
938        Ok(Value::Object(output))
939    }
940
941    /// Explain a single class token (e.g. `md3-btn`, `box=10px_20px`, `hover:bg-c=red`).
942    ///
943    /// Returns:
944    /// - resolved spells (scroll expansion if applicable)
945    /// - compiled CSS for the exact class token
946    pub fn explain_class_token(
947        current_dir: &Path,
948        class_token: &str,
949    ) -> Result<ExplainClassTokenResult, GrimoireCssError> {
950        let config_fs = Self::load_config(current_dir)?;
951        Self::explain_class_token_with_config(&config_fs, class_token)
952    }
953
954    fn explain_class_token_with_config(
955        config_fs: &ConfigFs,
956        class_token: &str,
957    ) -> Result<ExplainClassTokenResult, GrimoireCssError> {
958        let shared_spells = config_fs.shared_spells.clone();
959        let spell = Spell::new(
960            class_token,
961            &shared_spells,
962            &config_fs.scrolls,
963            (0, 0),
964            None,
965        )?
966        .ok_or_else(|| {
967            GrimoireCssError::InvalidInput(format!(
968                "Could not parse '{class_token}' as a spell or scroll invocation"
969            ))
970        })?;
971
972        let expanded_spells: Vec<String> = if let Some(scroll_spells) = &spell.scroll_spells {
973            scroll_spells
974                .iter()
975                .map(|s| s.raw_spell.clone())
976                .collect::<Vec<String>>()
977        } else {
978            vec![spell.raw_spell.clone()]
979        };
980
981        let optimizer = LightningCssOptimizer::new_from_with_printer_minify("", false)?;
982        let builder = CssBuilder::new(
983            &optimizer,
984            &config_fs.variables,
985            &config_fs.custom_animations,
986        )?;
987        let css = builder.combine_spells_to_optimized_css_string(&[spell])?;
988
989        Ok(ExplainClassTokenResult {
990            class_token: class_token.to_string(),
991            expanded_spells,
992            css,
993        })
994    }
995
996    pub fn config_summary(current_dir: &Path) -> Result<ConfigSummary, GrimoireCssError> {
997        let config_fs = Self::load_config(current_dir)?;
998        let config_path = Filesystem::get_config_path(current_dir)?;
999        let config_dir = config_path.parent().unwrap_or(current_dir);
1000
1001        let projects = config_fs
1002            .projects
1003            .iter()
1004            .map(|p| ConfigProjectSummary {
1005                name: p.project_name.clone(),
1006                input_paths: p.input_paths.clone(),
1007                output_dir_path: p.output_dir_path.clone(),
1008                single_output_file_name: p.single_output_file_name.clone(),
1009            })
1010            .collect::<Vec<_>>();
1011
1012        let mut scrolls = config_fs
1013            .scrolls
1014            .as_ref()
1015            .map(|m| m.keys().cloned().collect::<Vec<_>>())
1016            .unwrap_or_default();
1017        scrolls.sort();
1018
1019        let variables = config_fs
1020            .variables
1021            .clone()
1022            .unwrap_or_default()
1023            .into_iter()
1024            .map(|(name, value)| GrimoireVariableDefinition { name, value })
1025            .collect::<Vec<_>>();
1026
1027        let shared_spells = Self::sorted_set(config_fs.shared_spells.clone());
1028
1029        let mut custom_animations = config_fs
1030            .custom_animations
1031            .keys()
1032            .cloned()
1033            .collect::<Vec<_>>();
1034        custom_animations.sort();
1035
1036        let css_custom_properties =
1037            Self::sorted_set(Self::defined_css_custom_properties(&config_fs));
1038
1039        let external_scroll_files = external_config_files(config_dir, ".scrolls.json")?
1040            .into_iter()
1041            .map(|p| Self::to_rel(current_dir, &p))
1042            .collect::<Vec<_>>();
1043        let external_variable_files = external_config_files(config_dir, ".variables.json")?
1044            .into_iter()
1045            .map(|p| Self::to_rel(current_dir, &p))
1046            .collect::<Vec<_>>();
1047
1048        Ok(ConfigSummary {
1049            config_path: Self::to_rel(current_dir, &config_path),
1050            projects,
1051            scrolls,
1052            variables,
1053            shared_spells,
1054            custom_animations,
1055            css_custom_properties,
1056            external_scroll_files,
1057            external_variable_files,
1058        })
1059    }
1060
1061    pub fn index(current_dir: &Path, top: usize) -> Result<IndexResult, GrimoireCssError> {
1062        let config_fs = Self::load_config(current_dir)?;
1063        let parser = Parser::new();
1064
1065        let mut files = HashSet::<PathBuf>::new();
1066        for project in &config_fs.projects {
1067            for pattern in &project.input_paths {
1068                for path in Self::expand_input_pattern(current_dir, pattern)? {
1069                    if path.is_file() {
1070                        files.insert(path);
1071                    }
1072                }
1073            }
1074        }
1075
1076        let mut scroll_references: Vec<ScrollReference> = Vec::new();
1077        let mut errors: Vec<IndexError> = Vec::new();
1078        let mut token_occurrences: usize = 0;
1079
1080        let mut expanded_spell_counts: HashMap<String, u64> = HashMap::new();
1081        let mut css_variables_read: HashSet<String> = HashSet::new();
1082        let mut css_variables_written: HashSet<String> = HashSet::new();
1083
1084        let mut file_list: Vec<PathBuf> = files.into_iter().collect();
1085        file_list.sort();
1086
1087        for file_path in &file_list {
1088            let content = match fs::read_to_string(file_path) {
1089                Ok(c) => c,
1090                Err(e) => {
1091                    errors.push(IndexError {
1092                        file: Self::to_rel(current_dir, file_path),
1093                        byte_offset: 0,
1094                        byte_len: 0,
1095                        message: format!("Failed to read file: {e}"),
1096                    });
1097                    continue;
1098                }
1099            };
1100
1101            let line_index = LineIndex::new(&content);
1102
1103            let mut candidates: Vec<(String, (usize, usize))> = Vec::new();
1104            // This method is available only when built with `--features analyzer`.
1105            parser.collect_candidates_all(&content, &mut candidates)?;
1106
1107            for (token, (byte_offset, byte_len)) in candidates {
1108                token_occurrences += 1;
1109
1110                let (line, column) = line_index.line_col(byte_offset);
1111                let occurrence = TokenOccurrence {
1112                    token: token.clone(),
1113                    file: Self::to_rel(current_dir, file_path),
1114                    byte_offset,
1115                    byte_len,
1116                    line,
1117                    column,
1118                };
1119
1120                let parsed = Spell::new(
1121                    &token,
1122                    &config_fs.shared_spells,
1123                    &config_fs.scrolls,
1124                    (byte_offset, byte_len),
1125                    None,
1126                );
1127
1128                let spell = match parsed {
1129                    Ok(Some(s)) => s,
1130                    Ok(None) => continue,
1131                    Err(e) => {
1132                        errors.push(IndexError {
1133                            file: occurrence.file.clone(),
1134                            byte_offset,
1135                            byte_len,
1136                            message: e.to_string(),
1137                        });
1138                        continue;
1139                    }
1140                };
1141
1142                if let Some(expanded_spells) = &spell.scroll_spells {
1143                    // `scroll_spells` is used for both scroll invocations AND template tokens.
1144                    // Only treat it as a scroll reference when the component (scroll name) exists.
1145                    let scroll_name = spell.component().to_string();
1146                    if !scroll_name.is_empty() {
1147                        let arity = if spell.component_target().is_empty() {
1148                            0
1149                        } else {
1150                            spell.component_target().split('_').count()
1151                        };
1152
1153                        scroll_references.push(ScrollReference {
1154                            scroll: scroll_name,
1155                            arity,
1156                            occurrence: occurrence.clone(),
1157                        });
1158                    }
1159
1160                    for inner in expanded_spells {
1161                        Self::collect_css_variable_usage(
1162                            &inner.raw_spell,
1163                            &mut css_variables_read,
1164                            &mut css_variables_written,
1165                        );
1166                        *expanded_spell_counts
1167                            .entry(inner.raw_spell.clone())
1168                            .or_default() += 1;
1169                    }
1170                } else {
1171                    Self::collect_css_variable_usage(
1172                        &spell.raw_spell,
1173                        &mut css_variables_read,
1174                        &mut css_variables_written,
1175                    );
1176                    *expanded_spell_counts
1177                        .entry(spell.raw_spell.clone())
1178                        .or_default() += 1;
1179                }
1180            }
1181        }
1182
1183        let mut top_expanded_spells = Self::top_counts(expanded_spell_counts, top);
1184        // Stable output for same counts.
1185        top_expanded_spells
1186            .sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.spell.cmp(&b.spell)));
1187
1188        Ok(IndexResult {
1189            files_scanned: file_list.len(),
1190            token_occurrences,
1191            scroll_references,
1192            top_expanded_spells,
1193            css_variables_read: Self::sorted_set(css_variables_read),
1194            css_variables_written: Self::sorted_set(css_variables_written),
1195            errors,
1196        })
1197    }
1198
1199    /// Find DRY candidates: sets of spells/scrolls that appear together in regular `class`/`className`
1200    /// attributes multiple times (order-insensitive).
1201    ///
1202    /// This is intended as IDE tooling: it helps you extract a shared scroll for repeated clusters.
1203    pub fn dry_candidates(
1204        current_dir: &Path,
1205        min_support: usize,
1206        min_items: usize,
1207    ) -> Result<DryCandidatesResult, GrimoireCssError> {
1208        let config_fs = Self::load_config(current_dir)?;
1209        let parser = Parser::new();
1210
1211        let mut files = HashSet::<PathBuf>::new();
1212        for project in &config_fs.projects {
1213            for pattern in &project.input_paths {
1214                for path in Self::expand_input_pattern(current_dir, pattern)? {
1215                    if path.is_file() {
1216                        files.insert(path);
1217                    }
1218                }
1219            }
1220        }
1221
1222        let mut file_list: Vec<PathBuf> = files.into_iter().collect();
1223        file_list.sort();
1224
1225        let mut occurrences: Vec<DryOccurrence> = Vec::new();
1226        for file_path in &file_list {
1227            let content = match fs::read_to_string(file_path) {
1228                Ok(c) => c,
1229                Err(_) => continue,
1230            };
1231
1232            let line_index = LineIndex::new(&content);
1233
1234            let mut groups: Vec<crate::core::parser::RegularClassGroup> = Vec::new();
1235            // Analyzer-only API.
1236            parser.collect_regular_class_groups(&content, &mut groups)?;
1237
1238            for g in groups {
1239                // Keep only tokens that parse as Grimoire spells/scroll invocations.
1240                let mut toks: Vec<(String, (usize, usize))> = Vec::new();
1241                for (t, span) in g.tokens {
1242                    if t.is_empty() {
1243                        continue;
1244                    }
1245
1246                    let parsed =
1247                        Spell::new(&t, &config_fs.shared_spells, &config_fs.scrolls, span, None)?;
1248
1249                    if parsed.is_some() {
1250                        toks.push((t, span));
1251                    }
1252                }
1253
1254                if toks.len() < min_items {
1255                    continue;
1256                }
1257
1258                // Normalize tokens for set comparisons (order-insensitive, de-dup).
1259                let mut norm: Vec<String> = toks.iter().map(|(t, _)| t.clone()).collect();
1260                norm.sort();
1261                norm.dedup();
1262                if norm.len() < min_items {
1263                    continue;
1264                }
1265
1266                let (line, column) = line_index.line_col(toks[0].1.0);
1267
1268                occurrences.push(DryOccurrence {
1269                    file: Self::to_rel(current_dir, file_path),
1270                    line,
1271                    column,
1272                    tokens: norm,
1273                });
1274            }
1275        }
1276
1277        // Mine frequent intersections via pairwise set intersections.
1278        let mut candidate_support: HashMap<String, (Vec<String>, HashSet<usize>)> = HashMap::new();
1279
1280        for i in 0..occurrences.len() {
1281            for j in (i + 1)..occurrences.len() {
1282                let inter = intersect_sorted(&occurrences[i].tokens, &occurrences[j].tokens);
1283                if inter.len() < min_items {
1284                    continue;
1285                }
1286
1287                let key = inter.join("\u{1f}");
1288                let entry = candidate_support
1289                    .entry(key)
1290                    .or_insert_with(|| (inter.clone(), HashSet::new()));
1291                entry.1.insert(i);
1292                entry.1.insert(j);
1293            }
1294        }
1295
1296        // Expand support: if an occurrence contains all tokens, include it.
1297        for (_, (tokens, support)) in candidate_support.iter_mut() {
1298            for (idx, occ) in occurrences.iter().enumerate() {
1299                if is_subset(tokens, &occ.tokens) {
1300                    support.insert(idx);
1301                }
1302            }
1303        }
1304
1305        let mut candidates: Vec<(Vec<String>, Vec<usize>)> = candidate_support
1306            .into_values()
1307            .filter_map(|(tokens, support)| {
1308                if support.len() >= min_support {
1309                    let mut v: Vec<usize> = support.into_iter().collect();
1310                    v.sort();
1311                    Some((tokens, v))
1312                } else {
1313                    None
1314                }
1315            })
1316            .collect();
1317
1318        // Prune redundant candidates: drop strict subsets with identical support.
1319        candidates.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
1320        let mut kept: Vec<(Vec<String>, Vec<usize>)> = Vec::new();
1321        'outer: for (toks, supp) in candidates {
1322            for (kt, ks) in &kept {
1323                if ks == &supp && is_subset(&toks, kt) {
1324                    continue 'outer;
1325                }
1326            }
1327            kept.push((toks, supp));
1328        }
1329
1330        let mut out: Vec<DryCandidate> = Vec::new();
1331        for (tokens, support) in kept {
1332            let occs = support
1333                .iter()
1334                .map(|&i| occurrences[i].clone())
1335                .collect::<Vec<_>>();
1336            out.push(DryCandidate {
1337                support: occs.len(),
1338                tokens,
1339                occurrences: occs,
1340            });
1341        }
1342
1343        // Prefer bigger clusters first.
1344        out.sort_by(|a, b| {
1345            b.tokens
1346                .len()
1347                .cmp(&a.tokens.len())
1348                .then_with(|| b.support.cmp(&a.support))
1349        });
1350
1351        Ok(DryCandidatesResult {
1352            files_scanned: file_list.len(),
1353            class_occurrences: occurrences.len(),
1354            candidates: out,
1355        })
1356    }
1357
1358    pub fn refs_scroll(
1359        current_dir: &Path,
1360        scroll_name: &str,
1361    ) -> Result<Vec<ScrollReference>, GrimoireCssError> {
1362        let mut index = Self::index(current_dir, 0)?;
1363        index.scroll_references.retain(|r| r.scroll == scroll_name);
1364        Ok(index.scroll_references)
1365    }
1366
1367    pub fn refs_spell(
1368        current_dir: &Path,
1369        raw_spell: &str,
1370    ) -> Result<Vec<SpellReference>, GrimoireCssError> {
1371        let config_fs = Self::load_config(current_dir)?;
1372        let parser = Parser::new();
1373
1374        let mut files = HashSet::<PathBuf>::new();
1375        for project in &config_fs.projects {
1376            for pattern in &project.input_paths {
1377                for path in Self::expand_input_pattern(current_dir, pattern)? {
1378                    if path.is_file() {
1379                        files.insert(path);
1380                    }
1381                }
1382            }
1383        }
1384
1385        let mut file_list: Vec<PathBuf> = files.into_iter().collect();
1386        file_list.sort();
1387
1388        let mut refs: Vec<SpellReference> = Vec::new();
1389
1390        for file_path in &file_list {
1391            let content = fs::read_to_string(file_path)?;
1392            let line_index = LineIndex::new(&content);
1393
1394            let mut candidates: Vec<(String, (usize, usize))> = Vec::new();
1395            parser.collect_candidates_all(&content, &mut candidates)?;
1396
1397            for (token, (byte_offset, byte_len)) in candidates {
1398                let parsed = Spell::new(
1399                    &token,
1400                    &config_fs.shared_spells,
1401                    &config_fs.scrolls,
1402                    (byte_offset, byte_len),
1403                    None,
1404                );
1405
1406                let spell = match parsed {
1407                    Ok(Some(s)) => s,
1408                    _ => continue,
1409                };
1410
1411                let (line, column) = line_index.line_col(byte_offset);
1412                let occurrence = TokenOccurrence {
1413                    token: token.clone(),
1414                    file: Self::to_rel(current_dir, file_path),
1415                    byte_offset,
1416                    byte_len,
1417                    line,
1418                    column,
1419                };
1420
1421                if let Some(scroll_spells) = &spell.scroll_spells {
1422                    for inner in scroll_spells {
1423                        if inner.raw_spell == raw_spell {
1424                            refs.push(SpellReference {
1425                                spell: raw_spell.to_string(),
1426                                occurrence: occurrence.clone(),
1427                            });
1428                        }
1429                    }
1430                } else if spell.raw_spell == raw_spell {
1431                    refs.push(SpellReference {
1432                        spell: raw_spell.to_string(),
1433                        occurrence,
1434                    });
1435                }
1436            }
1437        }
1438
1439        Ok(refs)
1440    }
1441
1442    pub fn spell_count(current_dir: &Path, raw_spell: &str) -> Result<u64, GrimoireCssError> {
1443        Ok(Self::refs_spell(current_dir, raw_spell)?.len() as u64)
1444    }
1445
1446    pub fn stats_spells(
1447        current_dir: &Path,
1448        top: usize,
1449    ) -> Result<Vec<SpellFrequency>, GrimoireCssError> {
1450        let index = Self::index(current_dir, top)?;
1451        Ok(index.top_expanded_spells)
1452    }
1453
1454    pub fn refs_variable(
1455        current_dir: &Path,
1456        variable: &str,
1457    ) -> Result<Vec<VariableReference>, GrimoireCssError> {
1458        let config_fs = Self::load_config(current_dir)?;
1459        let parser = Parser::new();
1460
1461        let variable = if variable.starts_with("--") {
1462            variable.to_string()
1463        } else {
1464            format!("--{variable}")
1465        };
1466
1467        let mut files = HashSet::<PathBuf>::new();
1468        for project in &config_fs.projects {
1469            for pattern in &project.input_paths {
1470                for path in Self::expand_input_pattern(current_dir, pattern)? {
1471                    if path.is_file() {
1472                        files.insert(path);
1473                    }
1474                }
1475            }
1476        }
1477
1478        let mut file_list: Vec<PathBuf> = files.into_iter().collect();
1479        file_list.sort();
1480
1481        let mut refs: Vec<VariableReference> = Vec::new();
1482
1483        // Project files: scan token candidates and expand scroll/template spells.
1484        for file_path in &file_list {
1485            let content = fs::read_to_string(file_path)?;
1486            let line_index = LineIndex::new(&content);
1487
1488            let mut candidates: Vec<(String, (usize, usize))> = Vec::new();
1489            parser.collect_candidates_all(&content, &mut candidates)?;
1490
1491            for (token, (byte_offset, byte_len)) in candidates {
1492                let parsed = Spell::new(
1493                    &token,
1494                    &config_fs.shared_spells,
1495                    &config_fs.scrolls,
1496                    (byte_offset, byte_len),
1497                    None,
1498                );
1499
1500                let spell = match parsed {
1501                    Ok(Some(s)) => s,
1502                    _ => continue,
1503                };
1504
1505                let (line, column) = line_index.line_col(byte_offset);
1506                let occurrence = TokenOccurrence {
1507                    token: token.clone(),
1508                    file: Self::to_rel(current_dir, file_path),
1509                    byte_offset,
1510                    byte_len,
1511                    line,
1512                    column,
1513                };
1514
1515                let expanded: Vec<&str> = if let Some(scroll_spells) = &spell.scroll_spells {
1516                    scroll_spells.iter().map(|s| s.raw_spell.as_str()).collect()
1517                } else {
1518                    vec![spell.raw_spell.as_str()]
1519                };
1520
1521                for raw_spell in expanded {
1522                    let mut reads = Vec::new();
1523                    let mut writes = Vec::new();
1524                    Self::extract_css_variable_usage(raw_spell, &mut reads, &mut writes);
1525
1526                    if reads.iter().any(|v| v == &variable) {
1527                        refs.push(VariableReference {
1528                            variable: variable.clone(),
1529                            kind: "read".to_string(),
1530                            spell: raw_spell.to_string(),
1531                            occurrence: occurrence.clone(),
1532                        });
1533                    }
1534                    if writes.iter().any(|v| v == &variable) {
1535                        refs.push(VariableReference {
1536                            variable: variable.clone(),
1537                            kind: "write".to_string(),
1538                            spell: raw_spell.to_string(),
1539                            occurrence: occurrence.clone(),
1540                        });
1541                    }
1542                }
1543            }
1544        }
1545
1546        // Scroll config files: scan raw scroll spell strings in JSON.
1547        for file_path in Self::scroll_config_files(current_dir) {
1548            let content = fs::read_to_string(&file_path)?;
1549            let line_index = LineIndex::new(&content);
1550            let json: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
1551                GrimoireCssError::InvalidInput(format!(
1552                    "Failed to parse JSON in {}: {e}",
1553                    Self::to_rel(current_dir, &file_path)
1554                ))
1555            })?;
1556
1557            let mut search_from: usize = 0;
1558
1559            let Some(scrolls) = json.get("scrolls").and_then(|v| v.as_array()) else {
1560                continue;
1561            };
1562
1563            for scroll in scrolls {
1564                // base spells
1565                if let Some(spells) = scroll.get("spells").and_then(|v| v.as_array()) {
1566                    for s in spells.iter().filter_map(|v| v.as_str()) {
1567                        Self::push_css_var_ref_if_match(
1568                            current_dir,
1569                            &file_path,
1570                            &content,
1571                            &line_index,
1572                            &variable,
1573                            s,
1574                            &mut search_from,
1575                            &mut refs,
1576                        )?;
1577                    }
1578                }
1579
1580                // overloads: spellsByArgs
1581                if let Some(obj) = scroll.get("spellsByArgs").and_then(|v| v.as_object()) {
1582                    for (_k, arr) in obj {
1583                        let Some(spells) = arr.as_array() else {
1584                            continue;
1585                        };
1586                        for s in spells.iter().filter_map(|v| v.as_str()) {
1587                            Self::push_css_var_ref_if_match(
1588                                current_dir,
1589                                &file_path,
1590                                &content,
1591                                &line_index,
1592                                &variable,
1593                                s,
1594                                &mut search_from,
1595                                &mut refs,
1596                            )?;
1597                        }
1598                    }
1599                }
1600            }
1601        }
1602
1603        Ok(refs)
1604    }
1605
1606    #[allow(clippy::too_many_arguments)]
1607    fn push_css_var_ref_if_match(
1608        current_dir: &Path,
1609        file_path: &Path,
1610        content: &str,
1611        line_index: &LineIndex,
1612        variable: &str,
1613        raw_spell: &str,
1614        search_from: &mut usize,
1615        out: &mut Vec<VariableReference>,
1616    ) -> Result<(), GrimoireCssError> {
1617        let mut reads = Vec::new();
1618        let mut writes = Vec::new();
1619        Self::extract_css_variable_usage(raw_spell, &mut reads, &mut writes);
1620
1621        let matched_reads = reads
1622            .into_iter()
1623            .filter(|v| v == variable)
1624            .collect::<Vec<_>>();
1625        let matched_writes = writes
1626            .into_iter()
1627            .filter(|v| v == variable)
1628            .collect::<Vec<_>>();
1629
1630        if matched_reads.is_empty() && matched_writes.is_empty() {
1631            return Ok(());
1632        }
1633
1634        // Find a stable byte range by locating the JSON string literal.
1635        let json_string = serde_json::to_string(raw_spell).map_err(|e| {
1636            GrimoireCssError::InvalidInput(format!(
1637                "Failed to encode JSON string for spell in {}: {e}",
1638                Self::to_rel(current_dir, file_path)
1639            ))
1640        })?;
1641
1642        let mut found = None;
1643        if *search_from < content.len()
1644            && let Some(rel) = content[*search_from..].find(&json_string)
1645        {
1646            found = Some(*search_from + rel);
1647        }
1648        if found.is_none() {
1649            found = content.find(&json_string);
1650        }
1651
1652        let Some(byte_offset) = found else {
1653            // Fallback: location unknown.
1654            for v in matched_reads {
1655                out.push(VariableReference {
1656                    variable: v,
1657                    kind: "read".to_string(),
1658                    spell: raw_spell.to_string(),
1659                    occurrence: TokenOccurrence {
1660                        token: raw_spell.to_string(),
1661                        file: Self::to_rel(current_dir, file_path),
1662                        byte_offset: 0,
1663                        byte_len: 0,
1664                        line: 1,
1665                        column: 1,
1666                    },
1667                });
1668            }
1669            for v in matched_writes {
1670                out.push(VariableReference {
1671                    variable: v,
1672                    kind: "write".to_string(),
1673                    spell: raw_spell.to_string(),
1674                    occurrence: TokenOccurrence {
1675                        token: raw_spell.to_string(),
1676                        file: Self::to_rel(current_dir, file_path),
1677                        byte_offset: 0,
1678                        byte_len: 0,
1679                        line: 1,
1680                        column: 1,
1681                    },
1682                });
1683            }
1684            return Ok(());
1685        };
1686
1687        *search_from = byte_offset + json_string.len();
1688        let byte_len = json_string.len();
1689        let (line, column) = line_index.line_col(byte_offset);
1690        let occurrence = TokenOccurrence {
1691            token: raw_spell.to_string(),
1692            file: Self::to_rel(current_dir, file_path),
1693            byte_offset,
1694            byte_len,
1695            line,
1696            column,
1697        };
1698
1699        for v in matched_reads {
1700            out.push(VariableReference {
1701                variable: v,
1702                kind: "read".to_string(),
1703                spell: raw_spell.to_string(),
1704                occurrence: occurrence.clone(),
1705            });
1706        }
1707        for v in matched_writes {
1708            out.push(VariableReference {
1709                variable: v,
1710                kind: "write".to_string(),
1711                spell: raw_spell.to_string(),
1712                occurrence: occurrence.clone(),
1713            });
1714        }
1715
1716        Ok(())
1717    }
1718
1719    pub fn list_grimoire_variables(
1720        current_dir: &Path,
1721    ) -> Result<Vec<GrimoireVariableDefinition>, GrimoireCssError> {
1722        let config_fs = Self::load_config(current_dir)?;
1723        let mut out = config_fs
1724            .variables
1725            .unwrap_or_default()
1726            .into_iter()
1727            .map(|(name, value)| GrimoireVariableDefinition { name, value })
1728            .collect::<Vec<_>>();
1729        out.sort_by(|a, b| a.name.cmp(&b.name));
1730        Ok(out)
1731    }
1732
1733    pub fn refs_grimoire_variable(
1734        current_dir: &Path,
1735        variable: &str,
1736    ) -> Result<Vec<GrimoireVariableReference>, GrimoireCssError> {
1737        let config_fs = Self::load_config(current_dir)?;
1738        let parser = Parser::new();
1739
1740        let mut files = HashSet::<PathBuf>::new();
1741        for project in &config_fs.projects {
1742            for pattern in &project.input_paths {
1743                for path in Self::expand_input_pattern(current_dir, pattern)? {
1744                    if path.is_file() {
1745                        files.insert(path);
1746                    }
1747                }
1748            }
1749        }
1750
1751        let mut file_list: Vec<PathBuf> = files.into_iter().collect();
1752        file_list.sort();
1753
1754        let needle = format!("${variable}");
1755        let mut refs: Vec<GrimoireVariableReference> = Vec::new();
1756
1757        for file_path in &file_list {
1758            let content = fs::read_to_string(file_path)?;
1759            let line_index = LineIndex::new(&content);
1760
1761            let mut candidates: Vec<(String, (usize, usize))> = Vec::new();
1762            parser.collect_candidates_all(&content, &mut candidates)?;
1763
1764            for (token, (byte_offset, byte_len)) in candidates {
1765                let parsed = Spell::new(
1766                    &token,
1767                    &config_fs.shared_spells,
1768                    &config_fs.scrolls,
1769                    (byte_offset, byte_len),
1770                    None,
1771                );
1772
1773                let spell = match parsed {
1774                    Ok(Some(s)) => s,
1775                    _ => continue,
1776                };
1777
1778                let (line, column) = line_index.line_col(byte_offset);
1779                let occurrence = TokenOccurrence {
1780                    token: token.clone(),
1781                    file: Self::to_rel(current_dir, file_path),
1782                    byte_offset,
1783                    byte_len,
1784                    line,
1785                    column,
1786                };
1787
1788                let expanded: Vec<&str> = if let Some(scroll_spells) = &spell.scroll_spells {
1789                    scroll_spells.iter().map(|s| s.raw_spell.as_str()).collect()
1790                } else {
1791                    vec![spell.raw_spell.as_str()]
1792                };
1793
1794                for raw_spell in expanded {
1795                    if raw_spell.contains(&needle) {
1796                        refs.push(GrimoireVariableReference {
1797                            variable: variable.to_string(),
1798                            spell: raw_spell.to_string(),
1799                            occurrence: occurrence.clone(),
1800                        });
1801                    }
1802                }
1803            }
1804        }
1805
1806        // Also scan raw scroll definitions (grimoire.config.json and grimoire.*.scrolls.json)
1807        // for occurrences inside scroll spell strings.
1808        for file_path in Self::scroll_config_files(current_dir) {
1809            let content = fs::read_to_string(&file_path)?;
1810            let line_index = LineIndex::new(&content);
1811            let json: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
1812                GrimoireCssError::InvalidInput(format!(
1813                    "Failed to parse JSON in {}: {e}",
1814                    Self::to_rel(current_dir, &file_path)
1815                ))
1816            })?;
1817
1818            let mut search_from: usize = 0;
1819
1820            let Some(scrolls) = json.get("scrolls").and_then(|v| v.as_array()) else {
1821                continue;
1822            };
1823
1824            for scroll in scrolls {
1825                // base spells
1826                if let Some(spells) = scroll.get("spells").and_then(|v| v.as_array()) {
1827                    for s in spells.iter().filter_map(|v| v.as_str()) {
1828                        Self::push_gvar_ref_if_match(
1829                            current_dir,
1830                            &file_path,
1831                            &content,
1832                            &line_index,
1833                            variable,
1834                            &needle,
1835                            s,
1836                            &mut search_from,
1837                            &mut refs,
1838                        )?;
1839                    }
1840                }
1841
1842                // overloads: spellsByArgs
1843                if let Some(obj) = scroll.get("spellsByArgs").and_then(|v| v.as_object()) {
1844                    for (_k, arr) in obj {
1845                        let Some(spells) = arr.as_array() else {
1846                            continue;
1847                        };
1848                        for s in spells.iter().filter_map(|v| v.as_str()) {
1849                            Self::push_gvar_ref_if_match(
1850                                current_dir,
1851                                &file_path,
1852                                &content,
1853                                &line_index,
1854                                variable,
1855                                &needle,
1856                                s,
1857                                &mut search_from,
1858                                &mut refs,
1859                            )?;
1860                        }
1861                    }
1862                }
1863            }
1864        }
1865
1866        Ok(refs)
1867    }
1868
1869    fn scroll_config_files(current_dir: &Path) -> Vec<PathBuf> {
1870        let config_dir = current_dir.join("grimoire").join("config");
1871        if !config_dir.exists() {
1872            return Vec::new();
1873        }
1874
1875        let mut out = Vec::new();
1876        let main = config_dir.join("grimoire.config.json");
1877        if main.is_file() {
1878            out.push(main);
1879        }
1880
1881        let pattern = config_dir
1882            .join("grimoire.*.scrolls.json")
1883            .to_string_lossy()
1884            .to_string();
1885        if let Ok(entries) = glob(&pattern) {
1886            for p in entries.flatten() {
1887                if p.is_file() {
1888                    out.push(p);
1889                }
1890            }
1891        }
1892
1893        out.sort();
1894        out.dedup();
1895        out
1896    }
1897
1898    #[allow(clippy::too_many_arguments)]
1899    fn push_gvar_ref_if_match(
1900        current_dir: &Path,
1901        file_path: &Path,
1902        content: &str,
1903        line_index: &LineIndex,
1904        variable: &str,
1905        needle: &str,
1906        raw_spell: &str,
1907        search_from: &mut usize,
1908        out: &mut Vec<GrimoireVariableReference>,
1909    ) -> Result<(), GrimoireCssError> {
1910        if !raw_spell.contains(needle) {
1911            return Ok(());
1912        }
1913
1914        // Find a stable byte range by locating the JSON string literal.
1915        let json_string = serde_json::to_string(raw_spell).map_err(|e| {
1916            GrimoireCssError::InvalidInput(format!(
1917                "Failed to encode JSON string for spell in {}: {e}",
1918                Self::to_rel(current_dir, file_path)
1919            ))
1920        })?;
1921
1922        let mut found = None;
1923        if *search_from < content.len()
1924            && let Some(rel) = content[*search_from..].find(&json_string)
1925        {
1926            found = Some(*search_from + rel);
1927        }
1928        if found.is_none() {
1929            found = content.find(&json_string);
1930        }
1931
1932        let Some(byte_offset) = found else {
1933            // If we can't locate it precisely, still return a reference without location.
1934            out.push(GrimoireVariableReference {
1935                variable: variable.to_string(),
1936                spell: raw_spell.to_string(),
1937                occurrence: TokenOccurrence {
1938                    token: raw_spell.to_string(),
1939                    file: Self::to_rel(current_dir, file_path),
1940                    byte_offset: 0,
1941                    byte_len: 0,
1942                    line: 1,
1943                    column: 1,
1944                },
1945            });
1946            return Ok(());
1947        };
1948
1949        *search_from = byte_offset + json_string.len();
1950
1951        let byte_len = json_string.len();
1952        let (line, column) = line_index.line_col(byte_offset);
1953
1954        out.push(GrimoireVariableReference {
1955            variable: variable.to_string(),
1956            spell: raw_spell.to_string(),
1957            occurrence: TokenOccurrence {
1958                token: raw_spell.to_string(),
1959                file: Self::to_rel(current_dir, file_path),
1960                byte_offset,
1961                byte_len,
1962                line,
1963                column,
1964            },
1965        });
1966
1967        Ok(())
1968    }
1969
1970    pub fn lint(current_dir: &Path) -> Result<LintResult, GrimoireCssError> {
1971        let config_fs = Self::load_config(current_dir)?;
1972        let index = Self::index(current_dir, 200)?;
1973        let _config_path = Filesystem::get_config_path(current_dir)?;
1974
1975        let mut errors: Vec<LintMessage> = Vec::new();
1976        let mut warnings: Vec<LintMessage> = Vec::new();
1977        let notes: Vec<LintMessage> = Vec::new();
1978
1979        if !index.errors.is_empty() {
1980            let occurrence = index.errors.first().and_then(|e| {
1981                // Best-effort: map byte offset to (line, column) for click-to-open.
1982                let abs = current_dir.join(&e.file);
1983                let content = fs::read_to_string(&abs).ok()?;
1984                let (line, column) = line_col_from_byte_offset(&content, e.byte_offset);
1985                Some(TokenOccurrence {
1986                    token: "parse_error".to_string(),
1987                    file: e.file.clone(),
1988                    byte_offset: e.byte_offset,
1989                    byte_len: e.byte_len,
1990                    line,
1991                    column,
1992                })
1993            });
1994
1995            errors.push(LintMessage {
1996                level: "error".to_string(),
1997                code: "parse_error".to_string(),
1998                message: format!(
1999                    "Encountered {} parse/compile errors while scanning project files",
2000                    index.errors.len()
2001                ),
2002                occurrence,
2003            });
2004        }
2005
2006        if let Some(scrolls) = &config_fs.scrolls {
2007            // Overload sanity for actual usages.
2008            for r in &index.scroll_references {
2009                if r.arity == 0 {
2010                    continue;
2011                }
2012                if let Some(def) = scrolls.get(&r.scroll)
2013                    && let Some(map) = &def.spells_by_args
2014                    && !map.is_empty()
2015                {
2016                    let key = r.arity.to_string();
2017                    if !map.contains_key(&key) {
2018                        errors.push(LintMessage {
2019                            level: "error".to_string(),
2020                            code: "missing_overload".to_string(),
2021                            message: format!(
2022                                "Scroll '{}' is used with arity {}, but spellsByArgs['{}'] is not defined",
2023                                r.scroll, r.arity, key
2024                            ),
2025                            occurrence: Some(r.occurrence.clone()),
2026                        });
2027                    }
2028                }
2029            }
2030        }
2031
2032        // Shared styles lint: warn about styles declared in `shared.styles` that are never used
2033        // in scanned project inputs (these would bloat output CSS).
2034        if let Some(shared) = &config_fs.shared {
2035            let parser = Parser::new();
2036            let mut files = HashSet::<PathBuf>::new();
2037            for project in &config_fs.projects {
2038                for pattern in &project.input_paths {
2039                    for path in Self::expand_input_pattern(current_dir, pattern)? {
2040                        if path.is_file() {
2041                            files.insert(path);
2042                        }
2043                    }
2044                }
2045            }
2046
2047            let mut file_list: Vec<PathBuf> = files.into_iter().collect();
2048            file_list.sort();
2049
2050            let mut used_tokens: HashSet<String> = HashSet::new();
2051            for file_path in &file_list {
2052                let Ok(content) = fs::read_to_string(file_path) else {
2053                    continue;
2054                };
2055
2056                let mut candidates: Vec<(String, (usize, usize))> = Vec::new();
2057                parser.collect_candidates_all(&content, &mut candidates)?;
2058                for (token, _span) in candidates {
2059                    if token.is_empty() {
2060                        continue;
2061                    }
2062                    used_tokens.insert(token);
2063                }
2064            }
2065
2066            let mut unused_shared: Vec<String> = Vec::new();
2067            for s in shared {
2068                let Some(styles) = &s.styles else {
2069                    continue;
2070                };
2071                for t in styles {
2072                    if t.is_empty() {
2073                        continue;
2074                    }
2075
2076                    // Only lint Grimoire tokens/spells.
2077                    let parsed = Spell::new(
2078                        t,
2079                        &config_fs.shared_spells,
2080                        &config_fs.scrolls,
2081                        (0, 0),
2082                        None,
2083                    );
2084                    let Ok(Some(_)) = parsed else {
2085                        continue;
2086                    };
2087
2088                    if !used_tokens.contains(t) {
2089                        unused_shared.push(t.clone());
2090                    }
2091                }
2092            }
2093
2094            unused_shared.sort();
2095            unused_shared.dedup();
2096
2097            if !unused_shared.is_empty() {
2098                warnings.push(LintMessage {
2099                    level: "warning".to_string(),
2100                    code: "unused_shared_style".to_string(),
2101                    message: format!(
2102                        "{} shared style(s) are configured but never used in scanned project inputs: {}",
2103                        unused_shared.len(),
2104                        unused_shared.join(", ")
2105                    ),
2106                    occurrence: None,
2107                });
2108            }
2109        }
2110
2111        // Token lint: variables defined in cssCustomProperties but never referenced.
2112        let defined_tokens = Self::defined_css_custom_properties(&config_fs);
2113        if !defined_tokens.is_empty() {
2114            let used_tokens: HashSet<String> = index.css_variables_read.iter().cloned().collect();
2115
2116            let mut unused_tokens: Vec<String> = defined_tokens
2117                .iter()
2118                .filter(|t| !used_tokens.contains(*t))
2119                .cloned()
2120                .collect();
2121            unused_tokens.sort();
2122
2123            if !unused_tokens.is_empty() {
2124                warnings.push(LintMessage {
2125                    level: "warning".to_string(),
2126                    code: "unused_token".to_string(),
2127                    message: format!(
2128                        "{} token(s) are defined in cssCustomProperties but never read via var(--token): {}",
2129                        unused_tokens.len(),
2130                        unused_tokens.join(", ")
2131                    ),
2132                    occurrence: None,
2133                });
2134            }
2135        }
2136
2137        Ok(LintResult {
2138            errors,
2139            warnings,
2140            notes,
2141        })
2142    }
2143
2144    fn expand_input_pattern(
2145        current_dir: &Path,
2146        pattern: &str,
2147    ) -> Result<Vec<PathBuf>, GrimoireCssError> {
2148        let abs = current_dir.join(pattern);
2149
2150        if abs.exists() && abs.is_dir() {
2151            let mut dir_pattern = glob::Pattern::escape(&abs.to_string_lossy());
2152            if !dir_pattern.ends_with('/') {
2153                dir_pattern.push('/');
2154            }
2155            dir_pattern.push_str("**/*");
2156            return Self::glob_paths(&dir_pattern);
2157        }
2158
2159        // ConfigFs already expanded globs; resolved filenames must remain literal.
2160        Ok(vec![abs])
2161    }
2162
2163    fn glob_paths(pattern: &str) -> Result<Vec<PathBuf>, GrimoireCssError> {
2164        let mut out = Vec::new();
2165        let entries = glob(pattern).map_err(|e| {
2166            GrimoireCssError::InvalidInput(format!("Invalid glob pattern '{pattern}': {e}"))
2167        })?;
2168        for entry in entries {
2169            match entry {
2170                Ok(path) => out.push(path),
2171                Err(e) => {
2172                    return Err(GrimoireCssError::InvalidInput(format!(
2173                        "Failed to expand glob '{pattern}': {e}"
2174                    )));
2175                }
2176            }
2177        }
2178        Ok(out)
2179    }
2180
2181    fn to_rel(current_dir: &Path, p: &Path) -> String {
2182        p.strip_prefix(current_dir)
2183            .unwrap_or(p)
2184            .to_string_lossy()
2185            .replace(std::path::MAIN_SEPARATOR, "/")
2186    }
2187
2188    fn sorted_set(set: HashSet<String>) -> Vec<String> {
2189        let mut out: Vec<String> = set.into_iter().collect();
2190        out.sort();
2191        out
2192    }
2193
2194    fn top_counts(map: HashMap<String, u64>, top: usize) -> Vec<SpellFrequency> {
2195        if top == 0 {
2196            return Vec::new();
2197        }
2198
2199        let mut items: Vec<(String, u64)> = map.into_iter().collect();
2200        items.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
2201        items
2202            .into_iter()
2203            .take(top)
2204            .map(|(spell, count)| SpellFrequency { spell, count })
2205            .collect()
2206    }
2207
2208    fn defined_css_custom_properties(config_fs: &ConfigFs) -> HashSet<String> {
2209        let mut set = HashSet::new();
2210
2211        if let Some(shared) = &config_fs.shared {
2212            for s in shared {
2213                if let Some(props) = &s.css_custom_properties {
2214                    for p in props {
2215                        for (k, _) in &p.css_variables {
2216                            if k.starts_with("--") {
2217                                set.insert(k.clone());
2218                            } else {
2219                                set.insert(format!("--{k}"));
2220                            }
2221                        }
2222                    }
2223                }
2224            }
2225        }
2226        if let Some(critical) = &config_fs.critical {
2227            for c in critical {
2228                if let Some(props) = &c.css_custom_properties {
2229                    for p in props {
2230                        for (k, _) in &p.css_variables {
2231                            if k.starts_with("--") {
2232                                set.insert(k.clone());
2233                            } else {
2234                                set.insert(format!("--{k}"));
2235                            }
2236                        }
2237                    }
2238                }
2239            }
2240        }
2241
2242        set
2243    }
2244
2245    fn collect_css_variable_usage(
2246        raw_spell: &str,
2247        reads: &mut HashSet<String>,
2248        writes: &mut HashSet<String>,
2249    ) {
2250        let mut r = Vec::new();
2251        let mut w = Vec::new();
2252        Self::extract_css_variable_usage(raw_spell, &mut r, &mut w);
2253        for v in r {
2254            reads.insert(v);
2255        }
2256        for v in w {
2257            writes.insert(v);
2258        }
2259    }
2260
2261    fn extract_css_variable_usage(
2262        raw_spell: &str,
2263        reads: &mut Vec<String>,
2264        writes: &mut Vec<String>,
2265    ) {
2266        // Writes: spell of the form "--token=value".
2267        if let Some(name) = Self::extract_css_variable_write(raw_spell) {
2268            writes.push(name);
2269        }
2270
2271        // Reads: occurrences of "var(--token".
2272        let bytes = raw_spell.as_bytes();
2273        let mut i = 0;
2274        while i + 6 < bytes.len() {
2275            // "var(--" is 6 bytes.
2276            if bytes[i] == b'v'
2277                && bytes[i + 1] == b'a'
2278                && bytes[i + 2] == b'r'
2279                && bytes[i + 3] == b'('
2280                && bytes[i + 4] == b'-'
2281                && bytes[i + 5] == b'-'
2282            {
2283                let start = i + 4;
2284                let mut j = start;
2285                while j < bytes.len() {
2286                    let c = bytes[j];
2287                    let ok = c.is_ascii_lowercase()
2288                        || c.is_ascii_uppercase()
2289                        || c.is_ascii_digit()
2290                        || c == b'-'
2291                        || c == b'_';
2292                    if !ok {
2293                        break;
2294                    }
2295                    j += 1;
2296                }
2297                if j > start {
2298                    reads.push(String::from_utf8_lossy(&bytes[start..j]).to_string());
2299                }
2300                i = j;
2301                continue;
2302            }
2303            i += 1;
2304        }
2305    }
2306
2307    fn extract_css_variable_write(raw_spell: &str) -> Option<String> {
2308        // Fast path: starts with "--" and contains '='.
2309        if !raw_spell.starts_with("--") {
2310            return None;
2311        }
2312        let eq = raw_spell.find('=')?;
2313        if eq <= 2 {
2314            return None;
2315        }
2316        let name = &raw_spell[..eq];
2317        if name.as_bytes().iter().all(|c| {
2318            (*c >= b'a' && *c <= b'z')
2319                || (*c >= b'A' && *c <= b'Z')
2320                || (*c >= b'0' && *c <= b'9')
2321                || *c == b'-'
2322                || *c == b'_'
2323        }) {
2324            Some(name.to_string())
2325        } else {
2326            None
2327        }
2328    }
2329}
2330
2331fn line_col_from_byte_offset(content: &str, byte_offset: usize) -> (usize, usize) {
2332    let mut i = byte_offset.min(content.len());
2333    while i > 0 && !content.is_char_boundary(i) {
2334        i -= 1;
2335    }
2336
2337    let prefix = &content[..i];
2338    let line = prefix.bytes().filter(|b| *b == b'\n').count();
2339
2340    let last_nl = prefix.rfind('\n').map(|p| p + 1).unwrap_or(0);
2341    let col = prefix[last_nl..].chars().count();
2342
2343    (line, col)
2344}
2345
2346fn intersect_sorted(a: &[String], b: &[String]) -> Vec<String> {
2347    let mut out: Vec<String> = Vec::new();
2348    let mut i = 0usize;
2349    let mut j = 0usize;
2350    while i < a.len() && j < b.len() {
2351        match a[i].cmp(&b[j]) {
2352            std::cmp::Ordering::Less => i += 1,
2353            std::cmp::Ordering::Greater => j += 1,
2354            std::cmp::Ordering::Equal => {
2355                out.push(a[i].clone());
2356                i += 1;
2357                j += 1;
2358            }
2359        }
2360    }
2361    out
2362}
2363
2364fn is_subset(needles: &[String], haystack_sorted: &[String]) -> bool {
2365    // both sorted
2366    let mut i = 0usize;
2367    let mut j = 0usize;
2368    while i < needles.len() && j < haystack_sorted.len() {
2369        match needles[i].cmp(&haystack_sorted[j]) {
2370            std::cmp::Ordering::Less => return false,
2371            std::cmp::Ordering::Greater => j += 1,
2372            std::cmp::Ordering::Equal => {
2373                i += 1;
2374                j += 1;
2375            }
2376        }
2377    }
2378    i == needles.len()
2379}
2380
2381struct LineIndex {
2382    // Byte indices of '\n' characters.
2383    newlines: Vec<usize>,
2384}
2385
2386impl LineIndex {
2387    fn new(content: &str) -> Self {
2388        let mut newlines = Vec::new();
2389        for (i, b) in content.as_bytes().iter().enumerate() {
2390            if *b == b'\n' {
2391                newlines.push(i);
2392            }
2393        }
2394        Self { newlines }
2395    }
2396
2397    fn line_col(&self, byte_offset: usize) -> (usize, usize) {
2398        // line: 1-based, column: 1-based
2399        let line_idx = match self.newlines.binary_search(&byte_offset) {
2400            Ok(i) => i + 1,
2401            Err(i) => i,
2402        };
2403
2404        let line = line_idx + 1;
2405        let last_nl = if line_idx == 0 {
2406            None
2407        } else {
2408            self.newlines.get(line_idx - 1).copied()
2409        };
2410
2411        let col0 = match last_nl {
2412            Some(nl) => byte_offset.saturating_sub(nl + 1),
2413            None => byte_offset,
2414        };
2415        (line, col0 + 1)
2416    }
2417}
2418
2419#[cfg(all(test, feature = "mcp"))]
2420mod mcp_import_transaction_tests {
2421    use super::*;
2422    use tempfile::tempdir;
2423
2424    #[test]
2425    fn no_replace_publication_cannot_clobber_an_existing_target() {
2426        let dir = tempdir().unwrap();
2427        let target = dir.path().join("grimoire.concurrent.scrolls.json");
2428        atomic_write(&target, b"first writer", false).unwrap();
2429
2430        assert!(atomic_write(&target, b"second writer", false).is_err());
2431        assert_eq!(fs::read(&target).unwrap(), b"first writer");
2432        assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
2433    }
2434
2435    #[test]
2436    fn explicit_replace_publishes_complete_contents_without_temp_files() {
2437        let dir = tempdir().unwrap();
2438        let target = dir.path().join("grimoire.import.scrolls.json");
2439        atomic_write(&target, b"original contents", false).unwrap();
2440
2441        atomic_write(&target, b"replacement", true).unwrap();
2442
2443        assert_eq!(fs::read(&target).unwrap(), b"replacement");
2444        assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
2445    }
2446
2447    #[test]
2448    fn concurrent_no_replace_writers_publish_exactly_one_complete_file() {
2449        let dir = tempdir().unwrap();
2450        let target = dir.path().join("grimoire.concurrent.scrolls.json");
2451        let barrier = std::sync::Barrier::new(8);
2452        let results = std::thread::scope(|scope| {
2453            let handles = (0..8u8)
2454                .map(|writer| {
2455                    let target = &target;
2456                    let barrier = &barrier;
2457                    scope.spawn(move || {
2458                        let bytes = vec![writer; 4096];
2459                        barrier.wait();
2460                        (bytes.clone(), atomic_write(target, &bytes, false).is_ok())
2461                    })
2462                })
2463                .collect::<Vec<_>>();
2464            handles
2465                .into_iter()
2466                .map(|handle| handle.join().unwrap())
2467                .collect::<Vec<_>>()
2468        });
2469
2470        let winners = results
2471            .iter()
2472            .filter(|(_, success)| *success)
2473            .collect::<Vec<_>>();
2474        assert_eq!(winners.len(), 1);
2475        assert_eq!(fs::read(&target).unwrap(), winners[0].0);
2476        assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
2477    }
2478
2479    #[test]
2480    fn failed_publication_preserves_destination_and_cleans_temp_files() {
2481        for replace in [false, true] {
2482            let dir = tempdir().unwrap();
2483            let target = dir.path().join("grimoire.import.scrolls.json");
2484            fs::create_dir(&target).unwrap();
2485            fs::write(target.join("sentinel"), b"keep").unwrap();
2486
2487            assert!(atomic_write(&target, b"replacement", replace).is_err());
2488            assert_eq!(fs::read(target.join("sentinel")).unwrap(), b"keep");
2489            assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
2490        }
2491    }
2492
2493    #[cfg(unix)]
2494    #[test]
2495    fn import_permissions_match_regular_file_creation() {
2496        use std::os::unix::fs::PermissionsExt;
2497
2498        let dir = tempdir().unwrap();
2499        let regular = dir.path().join("regular");
2500        let target = dir.path().join("grimoire.import.scrolls.json");
2501        fs::write(&regular, b"regular").unwrap();
2502
2503        for replace in [false, true] {
2504            atomic_write(&target, b"import", replace).unwrap();
2505            assert_eq!(
2506                fs::metadata(&target).unwrap().permissions().mode(),
2507                fs::metadata(&regular).unwrap().permissions().mode()
2508            );
2509        }
2510    }
2511
2512    #[test]
2513    fn rollback_refuses_to_clobber_a_concurrently_changed_target() {
2514        let dir = tempdir().unwrap();
2515        let target = dir.path().join("grimoire.concurrent.scrolls.json");
2516        fs::write(&target, b"other writer").unwrap();
2517
2518        assert!(restore_import(&target, Some(b"original"), b"our import").is_err());
2519        assert_eq!(fs::read(&target).unwrap(), b"other writer");
2520    }
2521}