1use std::{
2 borrow::Cow,
3 collections::{BTreeMap, BTreeSet, HashMap, HashSet},
4 fmt,
5 ops::Deref,
6 panic::Location,
7 path::{Path, PathBuf},
8 sync::Arc,
9};
10
11use specta::{
12 ResolvedTypes, Types,
13 datatype::{DataType, NamedDataType, Reference},
14};
15
16use crate::{Branded, Error, primitives, references};
17
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
20pub enum Layout {
21 Namespaces,
23 Files,
25 ModulePrefixedName,
27 #[default]
30 FlatFile,
31}
32
33impl fmt::Display for Layout {
34 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35 write!(f, "{self:?}")
36 }
37}
38
39#[derive(Clone)]
40#[allow(clippy::type_complexity)]
41struct RuntimeFn(Arc<dyn Fn(FrameworkExporter) -> Result<Cow<'static, str>, Error> + Send + Sync>);
42
43impl fmt::Debug for RuntimeFn {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 write!(f, "RuntimeFn({:p})", self.0)
46 }
47}
48
49#[derive(Clone)]
50#[allow(clippy::type_complexity)]
51pub struct BrandedTypeImpl(
52 pub(crate) Arc<
53 dyn for<'a> Fn(BrandedTypeExporter<'a>, &Branded) -> Result<Cow<'static, str>, Error>
54 + Send
55 + Sync,
56 >,
57);
58
59impl fmt::Debug for BrandedTypeImpl {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 write!(f, "BrandedTypeImpl({:p})", self.0)
62 }
63}
64
65#[derive(Debug, Clone)]
67#[non_exhaustive]
68pub struct Exporter {
69 pub header: Cow<'static, str>,
71 framework_runtime: Option<RuntimeFn>,
72 pub(crate) branded_type_impl: Option<BrandedTypeImpl>,
73 framework_prelude: Cow<'static, str>,
74 pub layout: Layout,
76 pub(crate) jsdoc: bool,
77}
78
79impl Exporter {
80 pub(crate) fn default() -> Exporter {
82 Exporter {
83 header: Cow::Borrowed(""),
84 framework_runtime: None,
85 branded_type_impl: None,
86 framework_prelude: Cow::Borrowed(
87 "// This file has been generated by Specta. Do not edit this file manually.",
88 ),
89 layout: Default::default(),
90 jsdoc: false,
91 }
92 }
93
94 pub fn framework_prelude(mut self, prelude: impl Into<Cow<'static, str>>) -> Self {
96 self.framework_prelude = prelude.into();
97 self
98 }
99
100 pub fn framework_runtime(
106 mut self,
107 builder: impl Fn(FrameworkExporter) -> Result<Cow<'static, str>, Error> + Send + Sync + 'static,
108 ) -> Self {
109 self.framework_runtime = Some(RuntimeFn(Arc::new(builder)));
110 self
111 }
112
113 pub fn branded_type_impl(
153 mut self,
154 builder: impl for<'a> Fn(BrandedTypeExporter<'a>, &Branded) -> Result<Cow<'static, str>, Error>
155 + Send
156 + Sync
157 + 'static,
158 ) -> Self {
159 self.branded_type_impl = Some(BrandedTypeImpl(Arc::new(builder)));
160 self
161 }
162
163 pub fn header(mut self, header: impl Into<Cow<'static, str>>) -> Self {
167 self.header = header.into();
168 self
169 }
170
171 pub fn layout(mut self, layout: Layout) -> Self {
173 self.layout = layout;
174 self
175 }
176
177 pub fn export(&self, resolved_types: &ResolvedTypes) -> Result<String, Error> {
181 let types = resolved_types.as_types();
182
183 if let Layout::Files = self.layout {
184 return Err(Error::unable_to_export(self.layout));
185 }
186 if let Layout::Namespaces = self.layout
187 && self.jsdoc
188 {
189 return Err(Error::unable_to_export(self.layout));
190 }
191
192 let mut out = render_file_header(self)?;
193
194 let mut has_manually_exported_user_types = false;
195 let mut runtime = Ok(Cow::default());
196 if let Some(framework_runtime) = &self.framework_runtime {
197 runtime = (framework_runtime.0)(FrameworkExporter {
198 exporter: self,
199 has_manually_exported_user_types: &mut has_manually_exported_user_types,
200 files_root_types: "",
201 types: resolved_types,
202 });
203 }
204 let runtime = runtime?;
205
206 if !runtime.is_empty() {
208 out += "\n";
209 }
210 out += &runtime;
211 if !runtime.is_empty() {
212 out += "\n";
213 }
214
215 if !has_manually_exported_user_types {
217 render_types(&mut out, self, types, "")?;
218 }
219
220 Ok(out)
221 }
222
223 pub fn export_to(
229 &self,
230 path: impl AsRef<Path>,
231 resolved_types: &ResolvedTypes,
232 ) -> Result<(), Error> {
233 let types = resolved_types.as_types();
234 let path = path.as_ref();
235
236 if self.layout != Layout::Files {
237 let result = self.export(resolved_types)?;
238 if let Some(parent) = path.parent() {
239 std::fs::create_dir_all(parent)?;
240 };
241 std::fs::write(path, result)?;
242 return Ok(());
243 }
244
245 fn export(
246 exporter: &Exporter,
247 types: &Types,
248 module: &mut Module,
249 s: &mut String,
250 path: &Path,
251 files: &mut HashMap<PathBuf, String>,
252 ) -> Result<bool, Error> {
253 module.types.sort_by(|a, b| {
254 a.name()
255 .cmp(b.name())
256 .then(a.module_path().cmp(b.module_path()))
257 .then(a.location().cmp(&b.location()))
258 });
259 let (rendered_types_result, referenced_types) =
260 references::with_module_path(module.module_path.as_ref(), || {
261 references::collect_references(|| {
262 let mut rendered = String::new();
263 let exports = render_flat_types(
264 &mut rendered,
265 exporter,
266 types,
267 module.types.iter().copied(),
268 "",
269 )?;
270 Ok::<_, Error>((rendered, exports))
271 })
272 });
273 let (rendered_types, exports) = rendered_types_result?;
274
275 let import_paths = referenced_types
276 .into_iter()
277 .filter_map(|r| {
278 r.get(types)
279 .map(|ndt| ndt.module_path().as_ref().to_string())
280 })
281 .filter(|module_path| module_path != module.module_path.as_ref())
282 .collect::<BTreeSet<_>>();
283 if !import_paths.is_empty() {
284 s.push('\n');
285 s.push_str(&module_import_block(
286 exporter,
287 module.module_path.as_ref(),
288 &import_paths,
289 ));
290 }
291
292 if !import_paths.is_empty() && !rendered_types.is_empty() {
293 s.push('\n');
294 }
295
296 s.push_str(&rendered_types);
297
298 for (name, module) in &mut module.children {
299 if module.types.is_empty() && module.children.is_empty() {
302 continue;
303 }
304
305 let mut path = path.join(name);
306 let mut out = render_file_header(exporter)?;
307
308 let has_types = export(exporter, types, module, &mut out, &path, files)?;
309 if has_types {
310 path.set_extension(if exporter.jsdoc { "js" } else { "ts" });
311 files.insert(path, out);
312 }
313 }
314
315 Ok(!exports.is_empty())
316 }
317
318 let mut files = HashMap::new();
319 let mut runtime_path = path.join("index");
320 runtime_path.set_extension(if self.jsdoc { "js" } else { "ts" });
321
322 let mut root_types = String::new();
323 export(
324 self,
325 types,
326 &mut build_module_graph(types),
327 &mut root_types,
328 path,
329 &mut files,
330 )?;
331
332 {
333 let mut has_manually_exported_user_types = false;
334 let mut runtime = Cow::default();
335 let mut runtime_references = HashSet::new();
336 if let Some(framework_runtime) = &self.framework_runtime {
337 let (runtime_result, referenced_types) = references::with_module_path("", || {
338 references::collect_references(|| {
339 (framework_runtime.0)(FrameworkExporter {
340 exporter: self,
341 has_manually_exported_user_types: &mut has_manually_exported_user_types,
342 files_root_types: &root_types,
343 types: resolved_types,
344 })
345 })
346 });
347 runtime = runtime_result?;
348 runtime_references = referenced_types;
349 }
350
351 let should_export_user_types =
352 !has_manually_exported_user_types && !root_types.is_empty();
353
354 if !runtime.is_empty() || should_export_user_types {
355 files.insert(runtime_path, {
356 let mut out = render_file_header(self)?;
357 let mut body = String::new();
358
359 if !runtime.is_empty() {
361 body.push_str(&runtime);
362 }
363
364 if should_export_user_types {
366 if !body.is_empty() {
367 body.push('\n');
368 }
369
370 body.push_str(&root_types);
371 }
372
373 let import_paths = runtime_references
374 .into_iter()
375 .filter_map(|r| {
376 r.get(types)
377 .map(|ndt| ndt.module_path().as_ref().to_string())
378 })
379 .filter(|module_path| !module_path.is_empty())
380 .collect::<BTreeSet<_>>();
381
382 let import_paths = import_paths
383 .into_iter()
384 .filter(|module_path| {
385 !body.contains(&module_import_statement(self, "", module_path))
386 })
387 .collect::<BTreeSet<_>>();
388
389 if !import_paths.is_empty() {
390 out.push('\n');
391 out.push_str(&module_import_block(self, "", &import_paths));
392 }
393
394 if !body.is_empty() {
395 out.push('\n');
396 if !import_paths.is_empty() {
397 out.push('\n');
398 }
399 out.push_str(&body);
400 }
401
402 out
403 });
404 }
405 }
406
407 match path.metadata() {
408 Ok(meta) if !meta.is_dir() => std::fs::remove_file(path).or_else(|source| {
409 if source.kind() == std::io::ErrorKind::NotFound {
410 Ok(())
411 } else {
412 Err(Error::remove_file(path.to_path_buf(), source))
413 }
414 })?,
415 Ok(_) => {}
416 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
417 Err(source) => {
418 return Err(Error::metadata(path.to_path_buf(), source));
419 }
420 }
421
422 for (path, content) in &files {
423 path.parent().map(std::fs::create_dir_all).transpose()?;
424 std::fs::write(path, content)?;
425 }
426
427 cleanup_stale_files(path, &files)?;
428
429 Ok(())
430 }
431}
432
433impl AsRef<Exporter> for Exporter {
434 fn as_ref(&self) -> &Exporter {
435 self
436 }
437}
438
439impl AsMut<Exporter> for Exporter {
440 fn as_mut(&mut self) -> &mut Exporter {
441 self
442 }
443}
444
445pub struct BrandedTypeExporter<'a> {
447 pub(crate) exporter: &'a Exporter,
448 pub types: &'a ResolvedTypes,
450}
451
452impl fmt::Debug for BrandedTypeExporter<'_> {
453 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
454 self.exporter.fmt(f)
455 }
456}
457
458impl AsRef<Exporter> for BrandedTypeExporter<'_> {
459 fn as_ref(&self) -> &Exporter {
460 self
461 }
462}
463
464impl Deref for BrandedTypeExporter<'_> {
465 type Target = Exporter;
466
467 fn deref(&self) -> &Self::Target {
468 self.exporter
469 }
470}
471
472impl BrandedTypeExporter<'_> {
473 pub fn inline(&self, dt: &DataType) -> Result<String, Error> {
475 primitives::inline(self, self.types, dt)
476 }
477
478 pub fn reference(&self, r: &Reference) -> Result<String, Error> {
480 primitives::reference(self, self.types, r)
481 }
482}
483
484pub struct FrameworkExporter<'a> {
486 exporter: &'a Exporter,
487 has_manually_exported_user_types: &'a mut bool,
488 files_root_types: &'a str,
490 pub types: &'a ResolvedTypes,
492}
493
494impl fmt::Debug for FrameworkExporter<'_> {
495 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
496 self.exporter.fmt(f)
497 }
498}
499
500impl AsRef<Exporter> for FrameworkExporter<'_> {
501 fn as_ref(&self) -> &Exporter {
502 self
503 }
504}
505
506impl Deref for FrameworkExporter<'_> {
507 type Target = Exporter;
508
509 fn deref(&self) -> &Self::Target {
510 self.exporter
511 }
512}
513
514impl FrameworkExporter<'_> {
515 pub fn render_types(&mut self) -> Result<Cow<'static, str>, Error> {
520 let mut s = String::new();
521 render_types(
522 &mut s,
523 self.exporter,
524 self.types.as_types(),
525 self.files_root_types,
526 )?;
527 *self.has_manually_exported_user_types = true;
528 Ok(Cow::Owned(s))
529 }
530
531 pub fn inline(&self, dt: &DataType) -> Result<String, Error> {
533 primitives::inline(self, self.types, dt)
534 }
535
536 pub fn reference(&self, r: &Reference) -> Result<String, Error> {
538 primitives::reference(self, self.types, r)
539 }
540
541 pub fn export<'a>(
543 &self,
544 ndts: impl Iterator<Item = &'a NamedDataType>,
545 indent: &'a str,
546 ) -> Result<String, Error> {
547 primitives::export(self, self.types, ndts, indent)
548 }
549}
550
551struct Module<'a> {
552 types: Vec<&'a NamedDataType>,
553 children: BTreeMap<&'a str, Module<'a>>,
554 module_path: Cow<'static, str>,
555}
556
557fn build_module_graph(types: &Types) -> Module<'_> {
558 types.into_unsorted_iter().fold(
559 Module {
560 types: Default::default(),
561 children: Default::default(),
562 module_path: Default::default(),
563 },
564 |mut ns, ndt| {
565 let path = ndt.module_path();
566
567 if path.is_empty() {
568 ns.types.push(ndt);
569 } else {
570 let mut current = &mut ns;
571 let mut current_path = String::new();
572 for segment in path.split("::") {
573 if !current_path.is_empty() {
574 current_path.push_str("::");
575 }
576 current_path.push_str(segment);
577
578 current = current.children.entry(segment).or_insert_with(|| Module {
579 types: Default::default(),
580 children: Default::default(),
581 module_path: current_path.clone().into(),
582 });
583 }
584
585 current.types.push(ndt);
586 }
587
588 ns
589 },
590 )
591}
592
593fn render_file_header(exporter: &Exporter) -> Result<String, Error> {
594 let mut out = exporter.header.to_string();
595 if !exporter.header.is_empty() {
596 out += "\n";
597 }
598
599 out += &exporter.framework_prelude;
600 if !exporter.framework_prelude.is_empty() {
601 out += "\n";
602 }
603
604 Ok(out)
605}
606
607fn render_types(
608 s: &mut String,
609 exporter: &Exporter,
610 types: &Types,
611 files_user_types: &str,
612) -> Result<(), Error> {
613 match exporter.layout {
614 Layout::Namespaces => {
615 fn has_renderable_content(module: &Module<'_>, types: &Types) -> bool {
616 module.types.iter().any(|ndt| ndt.requires_reference(types))
617 || module
618 .children
619 .values()
620 .any(|child| has_renderable_content(child, types))
621 }
622
623 fn export<'a>(
624 exporter: &Exporter,
625 types: &Types,
626 s: &mut String,
627 module: impl ExactSizeIterator<Item = (&'a &'a str, &'a mut Module<'a>)>,
628 depth: usize,
629 ) -> Result<(), Error> {
630 let namespace_indent = "\t".repeat(depth);
631 let content_indent = "\t".repeat(depth + 1);
632
633 for (name, module) in module {
634 if !has_renderable_content(module, types) {
635 continue;
636 }
637
638 s.push('\n');
639 s.push_str(&namespace_indent);
640 if depth != 0 && *name != "$specta$" {
641 s.push_str("export ");
642 }
643 s.push_str("namespace ");
644 s.push_str(name);
645 s.push_str(" {\n");
646
647 module.types.sort_by(|a, b| {
649 a.name()
650 .cmp(b.name())
651 .then(a.module_path().cmp(b.module_path()))
652 .then(a.location().cmp(&b.location()))
653 });
654 render_flat_types(
655 s,
656 exporter,
657 types,
658 module.types.iter().copied(),
659 &content_indent,
660 )?;
661
662 export(exporter, types, s, module.children.iter_mut(), depth + 1)?;
664
665 s.push_str(&namespace_indent);
666 s.push_str("}\n");
667 }
668
669 Ok(())
670 }
671
672 let mut module = build_module_graph(types);
673
674 let reexports = {
675 let mut reexports = String::new();
676 for name in module
677 .children
678 .iter()
679 .filter_map(|(name, module)| {
680 has_renderable_content(module, types).then_some(*name)
681 })
682 .chain(
683 module
684 .types
685 .iter()
686 .filter(|ndt| ndt.requires_reference(types))
687 .map(|ndt| ndt.name().as_ref()),
688 )
689 {
690 reexports.push_str("export import ");
691 reexports.push_str(name);
692 reexports.push_str(" = $s$.");
693 reexports.push_str(name);
694 reexports.push_str(";\n");
695 }
696 reexports
697 };
698
699 export(exporter, types, s, [(&"$s$", &mut module)].into_iter(), 0)?;
700 s.push_str(&reexports);
701 }
702 Layout::ModulePrefixedName | Layout::FlatFile => {
703 render_flat_types(s, exporter, types, types.into_sorted_iter(), "")?;
704 }
705 Layout::Files => {
708 if !files_user_types.is_empty() {
709 s.push_str(files_user_types);
710 }
711 }
712 }
713
714 Ok(())
715}
716
717fn render_flat_types<'a>(
720 s: &mut String,
721 exporter: &Exporter,
722 types: &Types,
723 ndts: impl ExactSizeIterator<Item = &'a NamedDataType>,
724 indent: &str,
725) -> Result<HashMap<String, Location<'static>>, Error> {
726 let mut exports = HashMap::with_capacity(ndts.len());
727
728 let ndts = ndts
729 .filter(|ndt| ndt.requires_reference(types))
730 .map(|ndt| {
731 let export_name = exported_type_name(exporter, ndt);
732 if let Some(other) = exports.insert(export_name.to_string(), ndt.location()) {
733 return Err(Error::duplicate_type_name(
734 export_name,
735 ndt.location(),
736 other,
737 ));
738 }
739
740 Ok(ndt)
741 })
742 .collect::<Result<Vec<_>, _>>()?;
743
744 primitives::export_internal(s, exporter, types, ndts.into_iter(), indent)?;
745
746 Ok(exports)
747}
748
749fn collect_existing_files(root: &Path) -> Result<HashSet<PathBuf>, Error> {
751 if !root.exists() {
752 return Ok(HashSet::new());
753 }
754
755 let mut files = HashSet::new();
756 let entries =
757 std::fs::read_dir(root).map_err(|source| Error::read_dir(root.to_path_buf(), source))?;
758 for entry in entries {
759 let entry = entry.map_err(|source| Error::read_dir(root.to_path_buf(), source))?;
760 let path = entry.path();
761 let file_type = entry
762 .file_type()
763 .map_err(|source| Error::metadata(path.clone(), source))?;
764
765 if file_type.is_symlink() {
766 continue;
767 }
768
769 if file_type.is_dir() {
770 files.extend(collect_existing_files(&path)?);
771 } else if matches!(path.extension().and_then(|e| e.to_str()), Some("ts" | "js")) {
772 files.insert(path);
773 }
774 }
775
776 Ok(files)
777}
778
779fn is_generated_specta_file(path: &Path) -> Result<bool, Error> {
780 match std::fs::read_to_string(path) {
781 Ok(contents) => Ok(contents.contains("generated by Specta")),
782 Err(err) if err.kind() == std::io::ErrorKind::InvalidData => Ok(false),
783 Err(source) => Err(Error::from(source)),
784 }
785}
786
787fn remove_empty_dirs(path: &Path, root: &Path) -> Result<(), Error> {
789 let entries =
790 std::fs::read_dir(path).map_err(|source| Error::read_dir(path.to_path_buf(), source))?;
791 for entry in entries {
792 let entry = entry.map_err(|source| Error::read_dir(path.to_path_buf(), source))?;
793 let entry_path = entry.path();
794 let file_type = entry
795 .file_type()
796 .map_err(|source| Error::metadata(entry_path.clone(), source))?;
797 if file_type.is_symlink() {
798 continue;
799 }
800 if file_type.is_dir() {
801 remove_empty_dirs(&entry_path, root)?;
802 }
803 }
804
805 let is_empty = path
806 .read_dir()
807 .map_err(|source| Error::read_dir(path.to_path_buf(), source))?
808 .next()
809 .is_none();
810
811 if path != root && is_empty {
812 match std::fs::remove_dir(path) {
813 Ok(()) => {}
814 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
815 Err(source) => {
816 return Err(Error::remove_dir(path.to_path_buf(), source));
817 }
818 }
819 }
820 Ok(())
821}
822
823fn cleanup_stale_files(root: &Path, current_files: &HashMap<PathBuf, String>) -> Result<(), Error> {
825 for path in collect_existing_files(root)? {
826 if current_files.contains_key(&path) || !is_generated_specta_file(&path)? {
827 continue;
828 }
829
830 std::fs::remove_file(&path).or_else(|source| {
831 if source.kind() == std::io::ErrorKind::NotFound {
832 Ok(())
833 } else {
834 Err(Error::remove_file(path.clone(), source))
835 }
836 })?;
837 }
838
839 remove_empty_dirs(root, root)?;
840
841 Ok(())
842}
843
844fn exported_type_name(exporter: &Exporter, ndt: &NamedDataType) -> Cow<'static, str> {
845 match exporter.layout {
846 Layout::ModulePrefixedName => {
847 let mut s = ndt.module_path().split("::").collect::<Vec<_>>().join("_");
848 s.push('_');
849 s.push_str(ndt.name());
850 Cow::Owned(s)
851 }
852 _ => ndt.name().clone(),
853 }
854}
855
856pub(crate) fn module_alias(module_path: &str) -> String {
857 if module_path.is_empty() {
858 "$root".to_string()
859 } else {
860 module_path.split("::").collect::<Vec<_>>().join("$")
861 }
862}
863
864fn module_import_statement(
865 exporter: &Exporter,
866 from_module_path: &str,
867 to_module_path: &str,
868) -> String {
869 let import_keyword = if exporter.jsdoc {
870 "import"
871 } else {
872 "import type"
873 };
874
875 format!(
876 "{} * as {} from \"{}\";",
877 import_keyword,
878 module_alias(to_module_path),
879 module_import_path(from_module_path, to_module_path)
880 )
881}
882
883fn module_import_block(
884 exporter: &Exporter,
885 from_module_path: &str,
886 import_paths: &BTreeSet<String>,
887) -> String {
888 if exporter.jsdoc {
889 let mut out = String::from("/**\n");
890
891 for module_path in import_paths {
892 out.push_str(" * @typedef {import(\"");
893 out.push_str(&module_import_path(from_module_path, module_path));
894 out.push_str("\")} ");
895 out.push_str(&module_alias(module_path));
896 out.push('\n');
897 }
898
899 out.push_str(" */");
900 out
901 } else {
902 import_paths
903 .iter()
904 .map(|module_path| module_import_statement(exporter, from_module_path, module_path))
905 .collect::<Vec<_>>()
906 .join("\n")
907 }
908}
909
910fn module_import_path(from_module_path: &str, to_module_path: &str) -> String {
911 fn module_file_segments(module_path: &str) -> Vec<&str> {
912 if module_path.is_empty() {
913 vec!["index"]
914 } else {
915 module_path.split("::").collect()
916 }
917 }
918
919 let from_file_segments = module_file_segments(from_module_path);
920 let from_dir_segments = &from_file_segments[..from_file_segments.len() - 1];
921 let to_file_segments = module_file_segments(to_module_path);
922
923 let shared = from_dir_segments
924 .iter()
925 .zip(to_file_segments.iter())
926 .take_while(|(a, b)| a == b)
927 .count();
928
929 let mut relative_parts = Vec::new();
930 relative_parts.extend(std::iter::repeat_n(
931 "..",
932 from_dir_segments.len().saturating_sub(shared),
933 ));
934 relative_parts.extend(to_file_segments.iter().skip(shared).copied());
935
936 if relative_parts
937 .first()
938 .is_none_or(|v| *v != "." && *v != "..")
939 {
940 relative_parts.insert(0, ".");
941 }
942
943 relative_parts.join("/")
944}