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