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
606pub 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}