Skip to main content

chromasync_renderers/
registry.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    fs,
4    path::{Path, PathBuf},
5    str::FromStr,
6    sync::Mutex,
7};
8
9use chromasync_types::{
10    ChromaStrategy, GeneratedArtifact, GenerationContext, SemanticTokenName, SemanticTokens,
11    ThemePack,
12};
13use directories::ProjectDirs;
14use serde::Deserialize;
15
16use crate::{
17    ArtifactGenerator, BUILTIN_DECLARATIVE_TARGETS, BUILTIN_TARGETS, RendererError,
18    alacritty::AlacrittyRenderer, hyprland_rgba, normalized_hex_without_hash, rgb_components,
19};
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum TargetSource {
23    BuiltIn(&'static str),
24    Filesystem(PathBuf),
25    UserConfig(PathBuf),
26    Pack { pack: String, path: PathBuf },
27}
28
29impl TargetSource {
30    pub fn label(&self) -> &'static str {
31        match self {
32            Self::BuiltIn(_) => "built-in",
33            Self::Filesystem(_) => "filesystem",
34            Self::UserConfig(_) => "user-config",
35            Self::Pack { .. } => "pack",
36        }
37    }
38
39    pub fn location(&self) -> String {
40        match self {
41            Self::BuiltIn(name) => (*name).to_owned(),
42            Self::Filesystem(path) | Self::UserConfig(path) | Self::Pack { path, .. } => {
43                path.display().to_string()
44            }
45        }
46    }
47
48    pub fn pack_name(&self) -> Option<&str> {
49        match self {
50            Self::Pack { pack, .. } => Some(pack.as_str()),
51            _ => None,
52        }
53    }
54
55    pub(crate) const fn precedence(&self) -> u8 {
56        match self {
57            Self::BuiltIn(_) => 0,
58            Self::Pack { .. } => 1,
59            Self::UserConfig(_) => 2,
60            Self::Filesystem(_) => 3,
61        }
62    }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct ListedTarget {
67    pub name: String,
68    pub preferred_template: Option<String>,
69    pub chroma: Option<ChromaStrategy>,
70    pub source: TargetSource,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct ArtifactSpec {
75    pub file_name: String,
76    pub template: String,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct TargetSpec {
81    pub name: String,
82    pub description: Option<String>,
83    pub extends: Option<String>,
84    pub preferred_template: Option<String>,
85    pub chroma: Option<ChromaStrategy>,
86    pub artifacts: Vec<ArtifactSpec>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct CompiledTarget {
91    pub name: String,
92    pub preferred_template: Option<String>,
93    pub chroma: Option<ChromaStrategy>,
94    pub artifacts: Vec<CompiledArtifactSpec>,
95    pub source: TargetSource,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct CompiledArtifactSpec {
100    pub file_name: String,
101    template: CompiledTemplate,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
105struct CompiledTemplate {
106    segments: Vec<CompiledSegment>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
110enum CompiledSegment {
111    Literal(String),
112    Placeholder(CompiledPlaceholder),
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
116struct CompiledPlaceholder {
117    value: PlaceholderValue,
118    transforms: Vec<PlaceholderTransform>,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
122enum PlaceholderValue {
123    Token(SemanticTokenName),
124    Context(ContextField),
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
128enum PlaceholderTransform {
129    HexNoHash,
130    Rgb,
131    Rgba { alpha: u8 },
132    ModeMap { dark: String, light: String },
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136enum ContextField {
137    Mode,
138    TemplateName,
139    Chroma,
140    OutputDir,
141    Seed,
142}
143
144#[derive(Debug, Clone)]
145struct LoadedTarget {
146    spec: TargetSpec,
147    source: TargetSource,
148}
149
150#[derive(Debug, Deserialize)]
151#[serde(deny_unknown_fields)]
152struct RawTargetSpec {
153    name: String,
154    description: Option<String>,
155    extends: Option<String>,
156    preferred_template: Option<String>,
157    chroma: Option<ChromaStrategy>,
158    #[serde(default)]
159    artifacts: Vec<RawArtifactSpec>,
160}
161
162#[derive(Debug, Deserialize)]
163#[serde(deny_unknown_fields)]
164struct RawArtifactSpec {
165    file_name: String,
166    template: String,
167}
168
169pub struct RendererRegistry {
170    renderers: BTreeMap<String, Box<dyn ArtifactGenerator>>,
171}
172
173impl RendererRegistry {
174    pub fn new() -> Self {
175        let mut registry = Self {
176            renderers: BTreeMap::new(),
177        };
178
179        registry.register(AlacrittyRenderer);
180
181        for (name, file_name, content) in BUILTIN_DECLARATIVE_TARGETS {
182            registry.register(built_in_declarative_target(name, file_name, content));
183        }
184
185        registry
186    }
187
188    pub fn register<G>(&mut self, generator: G)
189    where
190        G: ArtifactGenerator + 'static,
191    {
192        let name = generator.name().to_owned();
193        self.renderers.insert(name, Box::new(generator));
194    }
195
196    pub fn get(&self, name: &str) -> Option<&dyn ArtifactGenerator> {
197        self.renderers.get(name).map(|generator| generator.as_ref())
198    }
199
200    pub fn contains(&self, name: &str) -> bool {
201        self.renderers.contains_key(name)
202    }
203
204    pub fn built_in_names(&self) -> Vec<String> {
205        BUILTIN_TARGETS
206            .iter()
207            .map(|target| target.as_str().to_owned())
208            .collect()
209    }
210
211    pub fn built_in_name_set(&self) -> BTreeSet<String> {
212        self.built_in_names().into_iter().collect()
213    }
214
215    pub fn list_targets(&self) -> Vec<ListedTarget> {
216        BUILTIN_TARGETS
217            .iter()
218            .filter_map(|target| {
219                self.renderers
220                    .get(target.as_str())
221                    .map(|generator| ListedTarget {
222                        name: target.as_str().to_owned(),
223                        preferred_template: generator.preferred_template().map(str::to_owned),
224                        chroma: generator.chroma_strategy(),
225                        source: TargetSource::BuiltIn(target.as_str()),
226                    })
227            })
228            .collect()
229    }
230
231    pub fn resolve_preferred_template(&self, target: &str) -> Option<String> {
232        self.renderers
233            .get(target)
234            .and_then(|generator| generator.preferred_template().map(str::to_owned))
235    }
236
237    pub fn resolve_chroma_strategy(&self, target: &str) -> Option<ChromaStrategy> {
238        self.renderers
239            .get(target)
240            .and_then(|generator| generator.chroma_strategy())
241    }
242}
243
244impl Default for RendererRegistry {
245    fn default() -> Self {
246        Self::new()
247    }
248}
249
250#[derive(Debug)]
251pub struct TargetRegistry {
252    targets: BTreeMap<String, CompiledTarget>,
253    listed: Vec<ListedTarget>,
254}
255
256impl TargetRegistry {
257    pub fn discover(built_in_names: &BTreeSet<String>) -> Result<Self, RendererError> {
258        Self::discover_with_packs(&[], built_in_names)
259    }
260
261    pub fn discover_with_packs(
262        packs: &[ThemePack],
263        built_in_names: &BTreeSet<String>,
264    ) -> Result<Self, RendererError> {
265        let mut loaded = Vec::new();
266
267        if let Some(path) = user_targets_dir() {
268            loaded.extend(targets_from_dir(&path, true)?);
269        }
270
271        loaded.extend(loaded_targets_from_packs(packs)?);
272
273        Self::from_loaded_targets(loaded, built_in_names)
274    }
275
276    pub fn from_dir(
277        path: &Path,
278        user_config: bool,
279        built_in_names: &BTreeSet<String>,
280    ) -> Result<Self, RendererError> {
281        let loaded = targets_from_dir(path, user_config)?;
282        Self::from_loaded_targets(loaded, built_in_names)
283    }
284
285    pub fn from_theme_packs(
286        packs: &[ThemePack],
287        built_in_names: &BTreeSet<String>,
288    ) -> Result<Self, RendererError> {
289        let loaded = loaded_targets_from_packs(packs)?;
290
291        Self::from_loaded_targets(loaded, built_in_names)
292    }
293
294    pub fn get(&self, name: &str) -> Option<&CompiledTarget> {
295        self.targets.get(name)
296    }
297
298    pub fn list_targets(&self) -> &[ListedTarget] {
299        &self.listed
300    }
301
302    fn empty() -> Self {
303        Self {
304            targets: BTreeMap::new(),
305            listed: Vec::new(),
306        }
307    }
308
309    fn from_loaded_targets(
310        loaded: Vec<LoadedTarget>,
311        built_in_names: &BTreeSet<String>,
312    ) -> Result<Self, RendererError> {
313        let targets = compile_registry_targets(loaded, built_in_names)?;
314        let mut listed = targets
315            .values()
316            .map(|target| ListedTarget {
317                name: target.name.clone(),
318                preferred_template: target.preferred_template.clone(),
319                chroma: target.chroma,
320                source: target.source.clone(),
321            })
322            .collect::<Vec<_>>();
323        listed.sort_by(|left, right| {
324            left.name
325                .cmp(&right.name)
326                .then(left.source.precedence().cmp(&right.source.precedence()))
327                .then(left.source.location().cmp(&right.source.location()))
328        });
329
330        Ok(Self { targets, listed })
331    }
332}
333
334impl Default for TargetRegistry {
335    fn default() -> Self {
336        Self::empty()
337    }
338}
339
340pub struct OutputRegistry {
341    built_in: RendererRegistry,
342    user_defined: TargetRegistry,
343    path_cache: Mutex<BTreeMap<PathBuf, CompiledTarget>>,
344}
345
346impl OutputRegistry {
347    pub fn discover() -> Result<Self, RendererError> {
348        Self::discover_with_packs(&[])
349    }
350
351    pub fn discover_with_packs(packs: &[ThemePack]) -> Result<Self, RendererError> {
352        let built_in = RendererRegistry::new();
353        let user_defined =
354            TargetRegistry::discover_with_packs(packs, &built_in.built_in_name_set())?;
355
356        Ok(Self {
357            built_in,
358            user_defined,
359            path_cache: Mutex::new(BTreeMap::new()),
360        })
361    }
362
363    pub fn resolve(&self, name: &str) -> Option<&dyn ArtifactGenerator> {
364        if let Some(target) = self.user_defined.get(name) {
365            return Some(target);
366        }
367
368        self.built_in.get(name)
369    }
370
371    pub fn list_targets(&self) -> Vec<ListedTarget> {
372        let mut targets = self.built_in.list_targets();
373        targets.extend(self.user_defined.list_targets().iter().cloned());
374        targets.sort_by(|left, right| {
375            left.name
376                .cmp(&right.name)
377                .then(left.source.precedence().cmp(&right.source.precedence()))
378                .then(left.source.location().cmp(&right.source.location()))
379        });
380        targets
381    }
382
383    pub fn resolve_preferred_template(&self, target: &str) -> Option<String> {
384        if let Some(compiled) = self.user_defined.get(target) {
385            return compiled.preferred_template.clone();
386        }
387
388        if looks_like_path(target)
389            && let Ok(compiled) = self.load_path_target(Path::new(target))
390        {
391            return compiled.preferred_template;
392        }
393
394        self.built_in.resolve_preferred_template(target)
395    }
396
397    pub fn resolve_chroma_strategy(&self, target: &str) -> Option<ChromaStrategy> {
398        if let Some(compiled) = self.user_defined.get(target) {
399            return compiled.chroma;
400        }
401
402        if looks_like_path(target)
403            && let Ok(compiled) = self.load_path_target(Path::new(target))
404        {
405            return compiled.chroma;
406        }
407
408        self.built_in.resolve_chroma_strategy(target)
409    }
410
411    pub fn generate(
412        &self,
413        requested: &[String],
414        tokens: &SemanticTokens,
415        ctx: &GenerationContext,
416    ) -> Result<Vec<GeneratedArtifact>, RendererError> {
417        let requested = normalized_target_requests(requested, &self.built_in);
418        let mut artifacts = Vec::new();
419
420        for name in requested {
421            if let Some(generator) = self.resolve(&name) {
422                artifacts.extend(generator.generate(tokens, ctx)?);
423                continue;
424            }
425
426            let compiled = self.load_path_target(Path::new(&name))?;
427            artifacts.extend(compiled.generate(tokens, ctx)?);
428        }
429
430        Ok(artifacts)
431    }
432
433    pub fn validate_path_target(&self, path: &Path) -> Result<(), RendererError> {
434        self.load_path_target(path).map(|_| ())
435    }
436
437    fn load_path_target(&self, path: &Path) -> Result<CompiledTarget, RendererError> {
438        if !looks_like_path(path.to_string_lossy().as_ref()) && !path.exists() {
439            return Err(RendererError::UnknownTarget {
440                requested: path.display().to_string(),
441            });
442        }
443
444        let cache_key = path.to_path_buf();
445
446        if let Some(cached) = self
447            .path_cache
448            .lock()
449            .expect("path target cache should not be poisoned")
450            .get(&cache_key)
451            .cloned()
452        {
453            return Ok(cached);
454        }
455
456        let loaded = target_from_file(path, false)?;
457
458        if self.built_in.contains(&loaded.spec.name) {
459            return Err(RendererError::TargetNameCollidesWithBuiltIn {
460                name: loaded.spec.name,
461            });
462        }
463
464        let base = match loaded.spec.extends.as_deref() {
465            Some(base) if self.built_in.contains(base) => {
466                return Err(RendererError::BuiltInTargetInheritance {
467                    target: loaded.spec.name,
468                    base: base.to_owned(),
469                });
470            }
471            Some(base) => self.user_defined.get(base).cloned().ok_or_else(|| {
472                RendererError::UnknownBaseTarget {
473                    target: loaded.spec.name.clone(),
474                    base: base.to_owned(),
475                }
476            })?,
477            None => CompiledTarget {
478                name: loaded.spec.name.clone(),
479                preferred_template: None,
480                chroma: None,
481                artifacts: Vec::new(),
482                source: loaded.source.clone(),
483            },
484        };
485
486        let compiled = compile_loaded_target(&loaded, &base)?;
487        self.path_cache
488            .lock()
489            .expect("path target cache should not be poisoned")
490            .insert(cache_key, compiled.clone());
491
492        Ok(compiled)
493    }
494}
495
496impl Default for OutputRegistry {
497    fn default() -> Self {
498        Self {
499            built_in: RendererRegistry::new(),
500            user_defined: TargetRegistry::default(),
501            path_cache: Mutex::new(BTreeMap::new()),
502        }
503    }
504}
505
506impl ArtifactGenerator for CompiledTarget {
507    fn name(&self) -> &str {
508        &self.name
509    }
510
511    fn preferred_template(&self) -> Option<&str> {
512        self.preferred_template.as_deref()
513    }
514
515    fn chroma_strategy(&self) -> Option<ChromaStrategy> {
516        self.chroma
517    }
518
519    fn generate(
520        &self,
521        tokens: &SemanticTokens,
522        ctx: &GenerationContext,
523    ) -> Result<Vec<GeneratedArtifact>, RendererError> {
524        self.artifacts
525            .iter()
526            .map(|artifact| {
527                Ok(GeneratedArtifact {
528                    target: self.name.clone(),
529                    file_name: artifact.file_name.clone(),
530                    content: artifact.template.render(tokens, ctx),
531                })
532            })
533            .collect()
534    }
535}
536
537pub fn user_targets_dir() -> Option<PathBuf> {
538    ProjectDirs::from("io", "chromasync", "chromasync")
539        .map(|dirs| dirs.config_dir().join("targets"))
540}
541
542fn targets_from_dir(path: &Path, user_config: bool) -> Result<Vec<LoadedTarget>, RendererError> {
543    let source = if user_config {
544        TargetSource::UserConfig(path.to_path_buf())
545    } else {
546        TargetSource::Filesystem(path.to_path_buf())
547    };
548
549    targets_from_dir_with_source(path, &source)
550}
551
552fn targets_from_dir_with_source(
553    path: &Path,
554    source: &TargetSource,
555) -> Result<Vec<LoadedTarget>, RendererError> {
556    if !path.exists() {
557        return Ok(Vec::new());
558    }
559
560    let mut loaded = Vec::new();
561
562    for entry in fs::read_dir(path).map_err(|source| RendererError::ReadTargetsDir {
563        path: path.to_path_buf(),
564        source,
565    })? {
566        let entry = entry.map_err(|source| RendererError::ReadTargetsDir {
567            path: path.to_path_buf(),
568            source,
569        })?;
570        let entry_path = entry.path();
571
572        if entry_path
573            .extension()
574            .and_then(|extension| extension.to_str())
575            != Some("toml")
576        {
577            continue;
578        }
579
580        let source = match source {
581            TargetSource::Pack { pack, .. } => TargetSource::Pack {
582                pack: pack.clone(),
583                path: entry_path.clone(),
584            },
585            TargetSource::UserConfig(_) => TargetSource::UserConfig(entry_path.clone()),
586            TargetSource::Filesystem(_) => TargetSource::Filesystem(entry_path.clone()),
587            TargetSource::BuiltIn(_) => TargetSource::Filesystem(entry_path.clone()),
588        };
589        loaded.push(target_from_file_with_source(&entry_path, source)?);
590    }
591
592    loaded.sort_by(|left, right| left.spec.name.cmp(&right.spec.name));
593
594    Ok(loaded)
595}
596
597fn target_from_file(path: &Path, user_config: bool) -> Result<LoadedTarget, RendererError> {
598    let source = if user_config {
599        TargetSource::UserConfig(path.to_path_buf())
600    } else {
601        TargetSource::Filesystem(path.to_path_buf())
602    };
603
604    target_from_file_with_source(path, source)
605}
606
607/// Parse and validate a standalone target TOML file, returning its spec.
608///
609/// Runs the same validation that target discovery applies (name rules, artifact
610/// file names, placeholder compilation, inheritance target names) without
611/// resolving `extends` against a registry. Used by `chromasync target install`
612/// to reject malformed targets before copying them into the user config.
613pub fn parse_target_file(path: &Path) -> Result<TargetSpec, RendererError> {
614    Ok(target_from_file(path, false)?.spec)
615}
616
617fn target_from_file_with_source(
618    path: &Path,
619    source: TargetSource,
620) -> Result<LoadedTarget, RendererError> {
621    let content = fs::read_to_string(path).map_err(|source| RendererError::ReadTargetFile {
622        path: path.to_path_buf(),
623        source,
624    })?;
625    let spec = parse_target(
626        path.file_name()
627            .and_then(|name| name.to_str())
628            .unwrap_or("target.toml"),
629        &content,
630        path.display().to_string(),
631    )?;
632
633    Ok(LoadedTarget { spec, source })
634}
635
636fn built_in_declarative_target(
637    name: &'static str,
638    file_name: &str,
639    content: &str,
640) -> CompiledTarget {
641    let spec = parse_target(
642        file_name,
643        content,
644        format!("built-in declarative target {file_name}"),
645    )
646    .expect("built-in declarative target should parse");
647    assert_eq!(
648        spec.name, name,
649        "built-in declarative target name should match its registry entry"
650    );
651    let source = TargetSource::BuiltIn(name);
652    let loaded = LoadedTarget { spec, source };
653    let base = CompiledTarget {
654        name: loaded.spec.name.clone(),
655        preferred_template: None,
656        chroma: None,
657        artifacts: Vec::new(),
658        source: loaded.source.clone(),
659    };
660
661    compile_loaded_target(&loaded, &base).expect("built-in declarative target should compile")
662}
663
664fn loaded_targets_from_packs(packs: &[ThemePack]) -> Result<Vec<LoadedTarget>, RendererError> {
665    let mut loaded = Vec::new();
666
667    for pack in packs {
668        for path in &pack.target_dirs {
669            loaded.extend(targets_from_dir_with_source(
670                path,
671                &TargetSource::Pack {
672                    pack: pack.name.clone(),
673                    path: path.clone(),
674                },
675            )?);
676        }
677    }
678
679    Ok(loaded)
680}
681
682fn parse_target(name: &str, content: &str, source: String) -> Result<TargetSpec, RendererError> {
683    let raw: RawTargetSpec =
684        toml::from_str(content).map_err(|error| RendererError::ParseTarget {
685            name: name.to_owned(),
686            source,
687            error: Box::new(error),
688        })?;
689
690    validate_target(raw)
691}
692
693fn validate_target(raw: RawTargetSpec) -> Result<TargetSpec, RendererError> {
694    validate_target_name(&raw.name)?;
695
696    if let Some(base) = &raw.extends {
697        validate_target_name(base)?;
698    }
699
700    if raw.artifacts.is_empty() && raw.extends.is_none() {
701        return Err(RendererError::TargetHasNoArtifacts {
702            target: raw.name.clone(),
703        });
704    }
705
706    let mut artifacts = Vec::with_capacity(raw.artifacts.len());
707
708    for artifact in raw.artifacts {
709        validate_artifact_file_name(&raw.name, &artifact.file_name)?;
710        compile_template(&raw.name, &artifact.file_name, &artifact.template)?;
711        artifacts.push(ArtifactSpec {
712            file_name: artifact.file_name,
713            template: artifact.template,
714        });
715    }
716
717    Ok(TargetSpec {
718        name: raw.name,
719        description: raw.description,
720        extends: raw.extends,
721        preferred_template: raw.preferred_template,
722        chroma: raw.chroma,
723        artifacts,
724    })
725}
726
727fn validate_target_name(name: &str) -> Result<(), RendererError> {
728    if name.is_empty() {
729        return Err(RendererError::InvalidTargetName {
730            name: name.to_owned(),
731            reason: "expected a non-empty lowercase identifier".to_owned(),
732        });
733    }
734
735    if name
736        .chars()
737        .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
738    {
739        Ok(())
740    } else {
741        Err(RendererError::InvalidTargetName {
742            name: name.to_owned(),
743            reason: "expected only lowercase ASCII letters, digits, '-' or '_'".to_owned(),
744        })
745    }
746}
747
748fn validate_artifact_file_name(target: &str, file_name: &str) -> Result<(), RendererError> {
749    if file_name.is_empty() {
750        return Err(RendererError::InvalidArtifactFileName {
751            target: target.to_owned(),
752            file_name: file_name.to_owned(),
753            reason: "expected a non-empty file name".to_owned(),
754        });
755    }
756
757    if file_name.contains('/') || file_name.contains('\\') {
758        return Err(RendererError::InvalidArtifactFileName {
759            target: target.to_owned(),
760            file_name: file_name.to_owned(),
761            reason: "expected a single file name without path separators".to_owned(),
762        });
763    }
764
765    if file_name == "." || file_name == ".." {
766        return Err(RendererError::InvalidArtifactFileName {
767            target: target.to_owned(),
768            file_name: file_name.to_owned(),
769            reason: "expected a normal relative file name".to_owned(),
770        });
771    }
772
773    Ok(())
774}
775
776fn compile_registry_targets(
777    loaded: Vec<LoadedTarget>,
778    built_in_names: &BTreeSet<String>,
779) -> Result<BTreeMap<String, CompiledTarget>, RendererError> {
780    let mut pending = BTreeMap::new();
781
782    for target in loaded {
783        if built_in_names.contains(&target.spec.name) {
784            if matches!(target.source, TargetSource::UserConfig(_)) {
785                continue;
786            }
787
788            return Err(RendererError::TargetNameCollidesWithBuiltIn {
789                name: target.spec.name,
790            });
791        }
792
793        if let Some(previous) = pending.insert(target.spec.name.clone(), target.clone()) {
794            return Err(RendererError::DuplicateTargetName {
795                name: target.spec.name,
796                first_source: previous.source.location(),
797                second_source: target.source.location(),
798            });
799        }
800    }
801
802    let mut compiler = TargetCompiler {
803        pending: &pending,
804        built_in_names,
805        compiled: BTreeMap::new(),
806        stack: Vec::new(),
807    };
808
809    for name in pending.keys() {
810        compiler.compile(name)?;
811    }
812
813    Ok(compiler.compiled)
814}
815
816struct TargetCompiler<'a> {
817    pending: &'a BTreeMap<String, LoadedTarget>,
818    built_in_names: &'a BTreeSet<String>,
819    compiled: BTreeMap<String, CompiledTarget>,
820    stack: Vec<String>,
821}
822
823impl<'a> TargetCompiler<'a> {
824    fn compile(&mut self, name: &str) -> Result<CompiledTarget, RendererError> {
825        if let Some(compiled) = self.compiled.get(name) {
826            return Ok(compiled.clone());
827        }
828
829        if let Some(position) = self.stack.iter().position(|active| active == name) {
830            let mut cycle = self.stack[position..].to_vec();
831            cycle.push(name.to_owned());
832            return Err(RendererError::TargetInheritanceCycle {
833                cycle: cycle.join(" -> "),
834            });
835        }
836
837        let loaded =
838            self.pending
839                .get(name)
840                .cloned()
841                .ok_or_else(|| RendererError::UnknownBaseTarget {
842                    target: self
843                        .stack
844                        .last()
845                        .cloned()
846                        .unwrap_or_else(|| name.to_owned()),
847                    base: name.to_owned(),
848                })?;
849
850        self.stack.push(name.to_owned());
851
852        let base = match loaded.spec.extends.as_deref() {
853            Some(base) if self.built_in_names.contains(base) => {
854                return Err(RendererError::BuiltInTargetInheritance {
855                    target: loaded.spec.name,
856                    base: base.to_owned(),
857                });
858            }
859            Some(base) => self.compile(base)?,
860            None => CompiledTarget {
861                name: loaded.spec.name.clone(),
862                preferred_template: None,
863                chroma: None,
864                artifacts: Vec::new(),
865                source: loaded.source.clone(),
866            },
867        };
868
869        let compiled = compile_loaded_target(&loaded, &base)?;
870        self.stack.pop();
871        self.compiled.insert(name.to_owned(), compiled.clone());
872
873        Ok(compiled)
874    }
875}
876
877fn compile_loaded_target(
878    loaded: &LoadedTarget,
879    base: &CompiledTarget,
880) -> Result<CompiledTarget, RendererError> {
881    let mut artifacts = base.artifacts.clone();
882
883    for artifact in &loaded.spec.artifacts {
884        let compiled = CompiledArtifactSpec {
885            file_name: artifact.file_name.clone(),
886            template: compile_template(&loaded.spec.name, &artifact.file_name, &artifact.template)?,
887        };
888
889        if let Some(existing) = artifacts
890            .iter_mut()
891            .find(|existing| existing.file_name == compiled.file_name)
892        {
893            *existing = compiled;
894        } else {
895            artifacts.push(compiled);
896        }
897    }
898
899    if artifacts.is_empty() {
900        return Err(RendererError::TargetHasNoArtifacts {
901            target: loaded.spec.name.clone(),
902        });
903    }
904
905    let preferred_template = loaded
906        .spec
907        .preferred_template
908        .clone()
909        .or_else(|| base.preferred_template.clone());
910
911    let chroma = loaded.spec.chroma.or(base.chroma);
912
913    Ok(CompiledTarget {
914        name: loaded.spec.name.clone(),
915        preferred_template,
916        chroma,
917        artifacts,
918        source: loaded.source.clone(),
919    })
920}
921
922fn compile_template(
923    target: &str,
924    file_name: &str,
925    template: &str,
926) -> Result<CompiledTemplate, RendererError> {
927    let mut segments = Vec::new();
928    let mut rest = template;
929
930    while let Some(start) = rest.find("{{") {
931        if start > 0 {
932            segments.push(CompiledSegment::Literal(rest[..start].to_owned()));
933        }
934
935        let after_start = &rest[start + 2..];
936        let Some(end) = after_start.find("}}") else {
937            return Err(RendererError::UnterminatedPlaceholder {
938                target: target.to_owned(),
939                file_name: file_name.to_owned(),
940            });
941        };
942
943        let raw_placeholder = after_start[..end].trim();
944        let placeholder = compile_placeholder(target, file_name, raw_placeholder)?;
945        segments.push(CompiledSegment::Placeholder(placeholder));
946        rest = &after_start[end + 2..];
947    }
948
949    if !rest.is_empty() {
950        segments.push(CompiledSegment::Literal(rest.to_owned()));
951    }
952
953    Ok(CompiledTemplate { segments })
954}
955
956fn compile_placeholder(
957    target: &str,
958    file_name: &str,
959    placeholder: &str,
960) -> Result<CompiledPlaceholder, RendererError> {
961    if placeholder.is_empty() {
962        return Err(RendererError::InvalidPlaceholder {
963            target: target.to_owned(),
964            file_name: file_name.to_owned(),
965            placeholder: placeholder.to_owned(),
966        });
967    }
968
969    let mut parts = placeholder.split('|').map(str::trim);
970    let Some(value) = parts.next() else {
971        return Err(RendererError::InvalidPlaceholder {
972            target: target.to_owned(),
973            file_name: file_name.to_owned(),
974            placeholder: placeholder.to_owned(),
975        });
976    };
977
978    let value = if let Some(token_name) = value.strip_prefix("tokens.") {
979        let token = SemanticTokenName::from_str(token_name).map_err(|_| {
980            RendererError::InvalidPlaceholder {
981                target: target.to_owned(),
982                file_name: file_name.to_owned(),
983                placeholder: placeholder.to_owned(),
984            }
985        })?;
986
987        PlaceholderValue::Token(token)
988    } else if let Some(field) = value.strip_prefix("ctx.") {
989        let field = match field {
990            "mode" => ContextField::Mode,
991            "template_name" => ContextField::TemplateName,
992            "chroma" => ContextField::Chroma,
993            "output_dir" => ContextField::OutputDir,
994            "seed" => ContextField::Seed,
995            _ => {
996                return Err(RendererError::InvalidPlaceholder {
997                    target: target.to_owned(),
998                    file_name: file_name.to_owned(),
999                    placeholder: placeholder.to_owned(),
1000                });
1001            }
1002        };
1003
1004        PlaceholderValue::Context(field)
1005    } else {
1006        return Err(RendererError::InvalidPlaceholder {
1007            target: target.to_owned(),
1008            file_name: file_name.to_owned(),
1009            placeholder: placeholder.to_owned(),
1010        });
1011    };
1012
1013    let mut transforms = Vec::new();
1014
1015    for raw_transform in parts {
1016        transforms.push(compile_placeholder_transform(
1017            target,
1018            file_name,
1019            placeholder,
1020            raw_transform,
1021        )?);
1022    }
1023
1024    let transforms_are_valid = match value {
1025        PlaceholderValue::Token(_) => {
1026            transforms.len() <= 1
1027                && transforms
1028                    .iter()
1029                    .all(|transform| !matches!(transform, PlaceholderTransform::ModeMap { .. }))
1030        }
1031        PlaceholderValue::Context(ContextField::Mode) => {
1032            transforms.len() <= 1
1033                && transforms
1034                    .iter()
1035                    .all(|transform| matches!(transform, PlaceholderTransform::ModeMap { .. }))
1036        }
1037        PlaceholderValue::Context(_) => transforms.is_empty(),
1038    };
1039
1040    if !transforms_are_valid {
1041        return Err(RendererError::InvalidPlaceholder {
1042            target: target.to_owned(),
1043            file_name: file_name.to_owned(),
1044            placeholder: placeholder.to_owned(),
1045        });
1046    }
1047
1048    Ok(CompiledPlaceholder { value, transforms })
1049}
1050
1051fn compile_placeholder_transform(
1052    target: &str,
1053    file_name: &str,
1054    placeholder: &str,
1055    raw_transform: &str,
1056) -> Result<PlaceholderTransform, RendererError> {
1057    if raw_transform == "hex_no_hash" {
1058        return Ok(PlaceholderTransform::HexNoHash);
1059    }
1060
1061    if raw_transform == "rgb" {
1062        return Ok(PlaceholderTransform::Rgb);
1063    }
1064
1065    if let Some(arguments) = raw_transform
1066        .strip_prefix("mode(")
1067        .and_then(|value| value.strip_suffix(')'))
1068    {
1069        let mut dark = None;
1070        let mut light = None;
1071
1072        for argument in arguments.split(',') {
1073            match argument.trim().split_once('=') {
1074                Some(("dark", value)) if !value.is_empty() => dark = Some(value.to_owned()),
1075                Some(("light", value)) if !value.is_empty() => light = Some(value.to_owned()),
1076                _ => {
1077                    return Err(RendererError::InvalidPlaceholder {
1078                        target: target.to_owned(),
1079                        file_name: file_name.to_owned(),
1080                        placeholder: placeholder.to_owned(),
1081                    });
1082                }
1083            }
1084        }
1085
1086        if let (Some(dark), Some(light)) = (dark, light) {
1087            return Ok(PlaceholderTransform::ModeMap { dark, light });
1088        }
1089    }
1090
1091    if let Some(alpha) = raw_transform
1092        .strip_prefix("rgba(")
1093        .and_then(|value| value.strip_suffix(')'))
1094    {
1095        if alpha.len() != 2 {
1096            return Err(RendererError::InvalidPlaceholder {
1097                target: target.to_owned(),
1098                file_name: file_name.to_owned(),
1099                placeholder: placeholder.to_owned(),
1100            });
1101        }
1102
1103        let alpha =
1104            u8::from_str_radix(alpha, 16).map_err(|_| RendererError::InvalidPlaceholder {
1105                target: target.to_owned(),
1106                file_name: file_name.to_owned(),
1107                placeholder: placeholder.to_owned(),
1108            })?;
1109
1110        return Ok(PlaceholderTransform::Rgba { alpha });
1111    }
1112
1113    Err(RendererError::InvalidPlaceholder {
1114        target: target.to_owned(),
1115        file_name: file_name.to_owned(),
1116        placeholder: placeholder.to_owned(),
1117    })
1118}
1119
1120impl CompiledTemplate {
1121    fn render(&self, tokens: &SemanticTokens, ctx: &GenerationContext) -> String {
1122        let mut rendered = String::new();
1123
1124        for segment in &self.segments {
1125            match segment {
1126                CompiledSegment::Literal(literal) => rendered.push_str(literal),
1127                CompiledSegment::Placeholder(placeholder) => {
1128                    rendered.push_str(&resolve_placeholder(placeholder, tokens, ctx));
1129                }
1130            }
1131        }
1132
1133        rendered
1134    }
1135}
1136
1137fn resolve_placeholder(
1138    placeholder: &CompiledPlaceholder,
1139    tokens: &SemanticTokens,
1140    ctx: &GenerationContext,
1141) -> String {
1142    let mut value = match &placeholder.value {
1143        PlaceholderValue::Token(token) => match token {
1144            SemanticTokenName::Bg => tokens.bg.clone(),
1145            SemanticTokenName::BgSecondary => tokens.bg_secondary.clone(),
1146            SemanticTokenName::Surface => tokens.surface.clone(),
1147            SemanticTokenName::SurfaceElevated => tokens.surface_elevated.clone(),
1148            SemanticTokenName::Text => tokens.text.clone(),
1149            SemanticTokenName::TextMuted => tokens.text_muted.clone(),
1150            SemanticTokenName::Border => tokens.border.clone(),
1151            SemanticTokenName::BorderStrong => tokens.border_strong.clone(),
1152            SemanticTokenName::Accent => tokens.accent.clone(),
1153            SemanticTokenName::AccentHover => tokens.accent_hover.clone(),
1154            SemanticTokenName::AccentActive => tokens.accent_active.clone(),
1155            SemanticTokenName::AccentFg => tokens.accent_fg.clone(),
1156            SemanticTokenName::Selection => tokens.selection.clone(),
1157            SemanticTokenName::Link => tokens.link.clone(),
1158            SemanticTokenName::Success => tokens.success.clone(),
1159            SemanticTokenName::Warning => tokens.warning.clone(),
1160            SemanticTokenName::Error => tokens.error.clone(),
1161        },
1162        PlaceholderValue::Context(field) => match field {
1163            ContextField::Mode => ctx.mode.to_string(),
1164            ContextField::TemplateName => ctx.template_name.clone(),
1165            ContextField::Chroma => ctx.chroma.to_string(),
1166            ContextField::OutputDir => ctx.output_dir.display().to_string(),
1167            ContextField::Seed => ctx.seed.clone().unwrap_or_default(),
1168        },
1169    };
1170
1171    for transform in &placeholder.transforms {
1172        value = apply_placeholder_transform(&value, transform);
1173    }
1174
1175    value
1176}
1177
1178fn apply_placeholder_transform(value: &str, transform: &PlaceholderTransform) -> String {
1179    match transform {
1180        PlaceholderTransform::HexNoHash => normalized_hex_without_hash(value)
1181            .expect("color transforms should resolve from valid semantic tokens"),
1182        PlaceholderTransform::Rgb => rgb_components(value)
1183            .expect("color transforms should resolve from valid semantic tokens"),
1184        PlaceholderTransform::Rgba { alpha } => hyprland_rgba(value, *alpha)
1185            .expect("color transforms should resolve from valid semantic tokens"),
1186        PlaceholderTransform::ModeMap { dark, light } => match value {
1187            "dark" => dark.clone(),
1188            "light" => light.clone(),
1189            _ => value.to_owned(),
1190        },
1191    }
1192}
1193
1194fn normalized_target_requests(requested: &[String], built_in: &RendererRegistry) -> Vec<String> {
1195    if requested.is_empty() {
1196        return built_in.built_in_names();
1197    }
1198
1199    let mut seen = BTreeSet::new();
1200    let mut normalized = Vec::with_capacity(requested.len());
1201
1202    for target in requested {
1203        if seen.insert(target.clone()) {
1204            normalized.push(target.clone());
1205        }
1206    }
1207
1208    normalized
1209}
1210
1211fn looks_like_path(value: &str) -> bool {
1212    let path = Path::new(value);
1213
1214    path.is_absolute()
1215        || value.contains(std::path::MAIN_SEPARATOR)
1216        || path.extension().and_then(|extension| extension.to_str()) == Some("toml")
1217}