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