Skip to main content

specta_typescript/
exporter.rs

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/// Allows configuring the format of the final types file
19#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
20pub enum Layout {
21    /// Produce a Typescript namespace for each Rust module
22    Namespaces,
23    /// Produce a dedicated file for each Rust module
24    Files,
25    /// Include the full module path in the types name but keep a flat structure.
26    ModulePrefixedName,
27    /// Flatten all of the types into a single file of types.
28    /// This mode doesn't support having multiple types with the same name.
29    #[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/// Typescript language exporter.
66#[derive(Debug, Clone)]
67#[non_exhaustive]
68pub struct Exporter {
69    /// Custom header prepended to exported files.
70    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    /// Output layout mode for generated TypeScript.
75    pub layout: Layout,
76    pub(crate) jsdoc: bool,
77}
78
79impl Exporter {
80    // You should get this from either a [Typescript] or [JSDoc], not construct it directly.
81    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    /// Provide a prelude which is added to the start of all exported files.
95    pub fn framework_prelude(mut self, prelude: impl Into<Cow<'static, str>>) -> Self {
96        self.framework_prelude = prelude.into();
97        self
98    }
99
100    /// Add some custom Typescript or Javascript code that is exported as part of the bindings.
101    /// It's appending to the types file for single-file layouts or put in a root `index.{ts/js}` for multi-file.
102    ///
103    /// The closure is wrapped in [`specta::collect()`] to capture any referenced types.
104    /// Ensure you call `T::reference()` within the closure if you want an import to be created.
105    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    /// Configure how `specta_typescript::branded!` types are rendered with exporter context.
114    ///
115    /// This callback receives both the branded payload and a [`BrandedTypeExporter`], allowing
116    /// you to call [`BrandedTypeExporter::inline`] / [`BrandedTypeExporter::reference`] while
117    /// preserving the current export configuration.
118    ///
119    /// # Examples
120    ///
121    /// `ts-brand` style:
122    /// ```rust
123    /// # use std::borrow::Cow;
124    /// # use specta_typescript::{Branded, Error, Typescript};
125    /// let exporter = Typescript::default().branded_type_impl(|ctx, branded| {
126    ///     let datatype = ctx.inline(branded.ty())?;
127    ///
128    ///     Ok(Cow::Owned(format!(
129    ///         "import(\"ts-brand\").Brand<{}, \"{}\">",
130    ///         datatype,
131    ///         branded.brand()
132    ///     )))
133    /// });
134    /// # let _ = exporter;
135    /// ```
136    ///
137    /// Effect style:
138    /// ```rust
139    /// # use std::borrow::Cow;
140    /// # use specta_typescript::{Branded, Error, Typescript};
141    /// let exporter = Typescript::default().branded_type_impl(|ctx, branded| {
142    ///     let datatype = ctx.inline(branded.ty())?;
143    ///
144    ///     Ok(Cow::Owned(format!(
145    ///         "{} & import(\"effect\").Brand.Brand<\"{}\">",
146    ///         datatype,
147    ///         branded.brand()
148    ///     )))
149    /// });
150    /// # let _ = exporter;
151    /// ```
152    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    /// Configure a header for the file.
164    ///
165    /// This is perfect for configuring lint ignore rules or other file-level comments.
166    pub fn header(mut self, header: impl Into<Cow<'static, str>>) -> Self {
167        self.header = header.into();
168        self
169    }
170
171    /// Configure the bindings layout
172    pub fn layout(mut self, layout: Layout) -> Self {
173        self.layout = layout;
174        self
175    }
176
177    /// Export the files into a single string.
178    ///
179    /// Note: This returns an error if the format is `Format::Files`.
180    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        // Framework runtime
207        if !runtime.is_empty() {
208            out += "\n";
209        }
210        out += &runtime;
211        if !runtime.is_empty() {
212            out += "\n";
213        }
214
215        // User types (if not included in framework runtime)
216        if !has_manually_exported_user_types {
217            render_types(&mut out, self, types, "")?;
218        }
219
220        Ok(out)
221    }
222
223    /// Export the types to a specific file/folder.
224    ///
225    /// When configured when `format` is `Format::Files`, you must provide a directory path.
226    /// Otherwise, you must provide the path of a single file.
227    ///
228    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                // This doesn't account for `NamedDataType::requires_reference`
300                // but we keep it for performance.
301                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                    // Framework runtime
360                    if !runtime.is_empty() {
361                        body.push_str(&runtime);
362                    }
363
364                    // User types (if not included in framework runtime)
365                    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
445/// Reference to Typescript language exporter for branded type callbacks.
446pub struct BrandedTypeExporter<'a> {
447    pub(crate) exporter: &'a Exporter,
448    /// Collected types currently being exported.
449    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    /// [primitives::inline]
474    pub fn inline(&self, dt: &DataType) -> Result<String, Error> {
475        primitives::inline(self, self.types, dt)
476    }
477
478    /// [primitives::reference]
479    pub fn reference(&self, r: &Reference) -> Result<String, Error> {
480        primitives::reference(self, self.types, r)
481    }
482}
483
484/// Reference to Typescript language exporter for framework
485pub struct FrameworkExporter<'a> {
486    exporter: &'a Exporter,
487    has_manually_exported_user_types: &'a mut bool,
488    // For `Layout::Files` we need to inject the value
489    files_root_types: &'a str,
490    /// Collected types currently being exported.
491    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    /// Render the types within the [`ResolvedTypes`](specta::ResolvedTypes).
516    ///
517    /// This will only work if used within [`Exporter::framework_runtime`].
518    /// It allows frameworks to intersperse their user types into their runtime code.
519    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    /// [primitives::inline]
532    pub fn inline(&self, dt: &DataType) -> Result<String, Error> {
533        primitives::inline(self, self.types, dt)
534    }
535
536    /// [primitives::reference]
537    pub fn reference(&self, r: &Reference) -> Result<String, Error> {
538        primitives::reference(self, self.types, r)
539    }
540
541    /// [primitives::export]
542    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                    // Types
648                    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                    // Namespaces
663                    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        // The types will get their own files
706        // So we keep the user types empty for easy downstream detection.
707        Layout::Files => {
708            if !files_user_types.is_empty() {
709                s.push_str(files_user_types);
710            }
711        }
712    }
713
714    Ok(())
715}
716
717// Implementation of `Layout::ModulePrefixedName | Layout::FlatFile`,
718// but is used by `Layout::Namespace` and `Layout::Files`
719fn 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
749/// Collect all TypeScript/JavaScript files in a directory recursively
750fn 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
787/// Remove empty directories recursively, stopping at the root
788fn 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
823/// Delete stale files and clean up empty directories
824fn 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}