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 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 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 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 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 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 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 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 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 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 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 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 parser.collect_regular_class_groups(&content, &mut groups)?;
1237
1238 for g in groups {
1239 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if let Some(name) = Self::extract_css_variable_write(raw_spell) {
2268 writes.push(name);
2269 }
2270
2271 let bytes = raw_spell.as_bytes();
2273 let mut i = 0;
2274 while i + 6 < bytes.len() {
2275 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 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 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 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 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 = ⌖
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(®ular, 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(®ular).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}