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