typescript_tools/
link.rs

1use std::borrow::Borrow;
2use std::collections::HashMap;
3use std::ffi::OsString;
4use std::fmt::Display;
5use std::path::{Path, PathBuf};
6
7use pathdiff::diff_paths;
8
9use crate::configuration_file::{ConfigurationFile, WriteError};
10use crate::io::FromFileError;
11use crate::monorepo_manifest::{EnumeratePackageManifestsError, MonorepoManifest};
12use crate::out_of_date_project_references::{
13    AllOutOfDateTypescriptConfig, OutOfDatePackageProjectReferences,
14    OutOfDateParentProjectReferences, OutOfDateTypescriptConfig,
15};
16use crate::package_manifest::PackageManifest;
17use crate::types::{Directory, PackageName};
18use crate::typescript_config::{
19    TypescriptConfig, TypescriptParentProjectReference, TypescriptProjectReference,
20};
21
22#[derive(Debug)]
23#[non_exhaustive]
24pub struct LinkError {
25    pub kind: LinkErrorKind,
26}
27
28impl Display for LinkError {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        write!(f, "error linking TypeScript project references")
31    }
32}
33
34impl std::error::Error for LinkError {
35    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
36        match &self.kind {
37            LinkErrorKind::EnumeratePackageManifests(err) => Some(err),
38            LinkErrorKind::FromFile(err) => Some(err),
39            LinkErrorKind::Write(err) => Some(err),
40            LinkErrorKind::InvalidUtf8(err) => Some(err),
41        }
42    }
43}
44
45impl From<EnumeratePackageManifestsError> for LinkError {
46    fn from(err: EnumeratePackageManifestsError) -> Self {
47        Self {
48            kind: LinkErrorKind::EnumeratePackageManifests(err),
49        }
50    }
51}
52
53impl From<FromFileError> for LinkError {
54    fn from(err: FromFileError) -> Self {
55        Self {
56            kind: LinkErrorKind::FromFile(err),
57        }
58    }
59}
60
61impl From<WriteError> for LinkError {
62    fn from(err: WriteError) -> Self {
63        Self {
64            kind: LinkErrorKind::Write(err),
65        }
66    }
67}
68
69impl From<InvalidUtf8Error> for LinkError {
70    fn from(err: InvalidUtf8Error) -> Self {
71        Self {
72            kind: LinkErrorKind::InvalidUtf8(err),
73        }
74    }
75}
76
77#[derive(Debug)]
78pub enum LinkErrorKind {
79    #[non_exhaustive]
80    EnumeratePackageManifests(EnumeratePackageManifestsError),
81    #[non_exhaustive]
82    FromFile(FromFileError),
83    #[non_exhaustive]
84    InvalidUtf8(InvalidUtf8Error),
85    #[non_exhaustive]
86    Write(WriteError),
87}
88
89#[derive(Debug)]
90pub struct InvalidUtf8Error(OsString);
91
92impl Display for InvalidUtf8Error {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        write!(f, "path cannot be expressed as UTF-8: {:?}", self.0)
95    }
96}
97
98impl std::error::Error for InvalidUtf8Error {
99    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
100        None
101    }
102}
103
104fn key_children_by_parent<M>(
105    mut accumulator: HashMap<Directory, Vec<String>>,
106    package_manifest: M,
107) -> Result<HashMap<Directory, Vec<String>>, InvalidUtf8Error>
108where
109    M: Borrow<PackageManifest>,
110{
111    let mut path_so_far = PathBuf::new();
112    for component in package_manifest.borrow().directory().iter() {
113        let children = accumulator
114            .entry(Directory::unchecked_from_path(path_so_far.clone()))
115            .or_default();
116
117        let new_child = component
118            .to_str()
119            .map(ToOwned::to_owned)
120            .ok_or_else(|| InvalidUtf8Error(component.to_owned()))?;
121        // DISCUSS: when would this list already contain the child?
122        if !children.contains(&new_child) {
123            children.push(new_child);
124        }
125
126        path_so_far.push(component);
127    }
128    Ok(accumulator)
129}
130
131fn create_project_references(mut children: Vec<String>) -> Vec<TypescriptProjectReference> {
132    // Sort the TypeScript project references for deterministic file contents.
133    // This minimizes diffs since the tsconfig.json files are stored in version control.
134    children.sort_unstable();
135    children
136        .into_iter()
137        .map(|path| TypescriptProjectReference { path })
138        .collect()
139}
140
141// Create a tsconfig.json file in each parent directory to an internal package.
142// This permits us to compile the monorepo from the top down.
143fn link_children_packages(
144    root: &Directory,
145    package_manifests_by_package_name: &HashMap<PackageName, PackageManifest>,
146) -> Result<(), LinkError> {
147    out_of_date_parent_project_references(root, package_manifests_by_package_name)?.try_for_each(
148        |maybe_parent_project_references| -> Result<(), LinkError> {
149            let OutOfDateParentProjectReferences {
150                mut tsconfig,
151                desired_references,
152            } = maybe_parent_project_references?;
153            tsconfig.contents.references = desired_references;
154            Ok(TypescriptParentProjectReference::write(root, tsconfig)?)
155        },
156    )
157}
158
159fn link_package_dependencies(
160    root: &Directory,
161    package_manifests_by_package_name: &HashMap<PackageName, PackageManifest>,
162) -> Result<(), LinkError> {
163    out_of_date_package_project_references(root, package_manifests_by_package_name)?
164        .map(
165            |maybe_package_project_references| -> Result<Option<_>, FromFileError> {
166                let OutOfDatePackageProjectReferences {
167                    mut tsconfig,
168                    desired_references,
169                } = maybe_package_project_references?;
170                // Compare the current references against the desired references
171                let current_project_references = &tsconfig
172                    .contents
173                    .get("references")
174                    .map(|value| {
175                        serde_json::from_value::<Vec<TypescriptProjectReference>>(value.clone())
176                            .expect("value starting as JSON should be deserializable")
177                    })
178                    .unwrap_or_default();
179
180                let needs_update = !current_project_references.eq(&desired_references);
181                if !needs_update {
182                    return Ok(None);
183                }
184
185                // Update the current tsconfig with the desired references
186                tsconfig.contents.insert(
187                    String::from("references"),
188                    serde_json::to_value(desired_references).expect(
189                        "should be able to express desired TypeScript project references as JSON",
190                    ),
191                );
192
193                Ok(Some(tsconfig))
194            },
195        )
196        .filter_map(Result::transpose)
197        .map(|maybe_tsconfig| -> Result<(), LinkError> {
198            let tsconfig = maybe_tsconfig?;
199            Ok(TypescriptConfig::write(root, tsconfig)?)
200        })
201        .collect::<Result<Vec<_>, _>>()?;
202    Ok(())
203}
204
205pub fn modify<P>(root: P) -> Result<(), LinkError>
206where
207    P: AsRef<Path>,
208{
209    fn inner(root: &Path) -> Result<(), LinkError> {
210        let lerna_manifest = MonorepoManifest::from_directory(root)?;
211        let package_manifests_by_package_name =
212            lerna_manifest.package_manifests_by_package_name()?;
213        link_children_packages(&lerna_manifest.root, &package_manifests_by_package_name)?;
214        link_package_dependencies(&lerna_manifest.root, &package_manifests_by_package_name)?;
215        // TODO(7): create `tsconfig.settings.json` files
216        Ok(())
217    }
218    inner(root.as_ref())
219}
220
221#[derive(Debug)]
222#[non_exhaustive]
223pub struct LinkLintError {
224    pub kind: LinkLintErrorKind,
225}
226
227impl Display for LinkLintError {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        match &self.kind {
230            LinkLintErrorKind::ProjectReferencesOutOfDate(out_of_date_references) => {
231                writeln!(f, "TypeScript project references are not up-to-date")?;
232                writeln!(f, "{}", out_of_date_references)
233            }
234            _ => write!(f, "error linking TypeScript project references"),
235        }
236    }
237}
238
239impl std::error::Error for LinkLintError {
240    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
241        match &self.kind {
242            LinkLintErrorKind::EnumeratePackageManifests(err) => Some(err),
243            LinkLintErrorKind::FromFile(err) => Some(err),
244            LinkLintErrorKind::ProjectReferencesOutOfDate(_) => None,
245            LinkLintErrorKind::InvalidUtf8(err) => Some(err),
246        }
247    }
248}
249
250impl From<EnumeratePackageManifestsError> for LinkLintError {
251    fn from(err: EnumeratePackageManifestsError) -> Self {
252        Self {
253            kind: LinkLintErrorKind::EnumeratePackageManifests(err),
254        }
255    }
256}
257
258impl From<FromFileError> for LinkLintError {
259    fn from(err: FromFileError) -> Self {
260        Self {
261            kind: LinkLintErrorKind::FromFile(err),
262        }
263    }
264}
265
266impl From<InvalidUtf8Error> for LinkLintError {
267    fn from(err: InvalidUtf8Error) -> Self {
268        Self {
269            kind: LinkLintErrorKind::InvalidUtf8(err),
270        }
271    }
272}
273
274impl From<AllOutOfDateTypescriptConfig> for LinkLintError {
275    fn from(err: AllOutOfDateTypescriptConfig) -> Self {
276        Self {
277            kind: LinkLintErrorKind::ProjectReferencesOutOfDate(err),
278        }
279    }
280}
281
282#[derive(Debug)]
283pub enum LinkLintErrorKind {
284    #[non_exhaustive]
285    EnumeratePackageManifests(EnumeratePackageManifestsError),
286    #[non_exhaustive]
287    FromFile(FromFileError),
288    #[non_exhaustive]
289    InvalidUtf8(InvalidUtf8Error),
290    // TODO: augment this error with information for a useful error message
291    #[non_exhaustive]
292    ProjectReferencesOutOfDate(AllOutOfDateTypescriptConfig),
293}
294
295fn out_of_date_parent_project_references<'a>(
296    root: &'a Directory,
297    package_manifests_by_package_name: &'a HashMap<PackageName, PackageManifest>,
298) -> Result<
299    impl Iterator<Item = Result<OutOfDateParentProjectReferences, FromFileError>> + 'a,
300    InvalidUtf8Error,
301> {
302    let iter = package_manifests_by_package_name
303        .values()
304        .try_fold(HashMap::default(), key_children_by_parent)?
305        .into_iter()
306        .map(move |(directory, children)| {
307            let desired_references = create_project_references(children);
308            let tsconfig = TypescriptParentProjectReference::from_directory(&root, directory)?;
309            let current_project_references = &tsconfig.contents.references;
310            let needs_update = !current_project_references.eq(&desired_references);
311            Ok(match needs_update {
312                true => Some(OutOfDateParentProjectReferences {
313                    tsconfig,
314                    desired_references,
315                }),
316                false => None,
317            })
318        })
319        .filter_map(Result::transpose);
320    Ok(iter)
321}
322
323fn out_of_date_package_project_references<'a>(
324    root: &'a Directory,
325    package_manifests_by_package_name: &'a HashMap<PackageName, PackageManifest>,
326) -> Result<
327    impl Iterator<Item = Result<OutOfDatePackageProjectReferences, FromFileError>> + 'a,
328    InvalidUtf8Error,
329> {
330    let iter = package_manifests_by_package_name
331        .values()
332        .map(move |package_manifest| {
333            let package_directory = package_manifest.directory();
334            let tsconfig = TypescriptConfig::from_directory(&root, package_directory.to_owned())?;
335            let internal_dependencies =
336                package_manifest.internal_dependencies_iter(&package_manifests_by_package_name);
337
338            let desired_references: Vec<TypescriptProjectReference> = {
339                let mut typescript_project_references: Vec<String> = internal_dependencies
340                    .into_iter()
341                    .map(|dependency| {
342                        diff_paths(dependency.directory(), package_manifest.directory())
343                            .expect(
344                                "Unable to calculate a relative path to dependency from package",
345                            )
346                            .to_str()
347                            .expect("Path not valid UTF-8 encoded")
348                            .to_string()
349                    })
350                    .collect::<Vec<_>>();
351                // REFACTOR: can drop a `collect` if we implement Ord on TypescriptProjectReference
352                typescript_project_references.sort_unstable();
353
354                typescript_project_references
355                    .into_iter()
356                    .map(|path| TypescriptProjectReference { path })
357                    .collect()
358            };
359
360            // Compare the current references against the desired references
361            let current_project_references = &tsconfig
362                .contents
363                .get("references")
364                .map(|value| {
365                    serde_json::from_value::<Vec<TypescriptProjectReference>>(value.clone())
366                        .expect("value starting as JSON should be serializable")
367                })
368                .unwrap_or_default();
369
370            let needs_update = !current_project_references.eq(&desired_references);
371            Ok(match needs_update {
372                true => Some(OutOfDatePackageProjectReferences {
373                    tsconfig,
374                    desired_references,
375                }),
376                false => None,
377            })
378        })
379        .filter_map(Result::transpose);
380
381    Ok(iter)
382}
383
384pub fn lint<P>(root: P) -> Result<(), LinkLintError>
385where
386    P: AsRef<Path>,
387{
388    fn inner(root: &Path) -> Result<(), LinkLintError> {
389        let monorepo_manifest = MonorepoManifest::from_directory(root)?;
390        let package_manifests_by_package_name =
391            monorepo_manifest.package_manifests_by_package_name()?;
392
393        let is_children_link_success = out_of_date_parent_project_references(
394            &monorepo_manifest.root,
395            &package_manifests_by_package_name,
396        )?
397        .map(
398            |result| -> Result<OutOfDateTypescriptConfig, FromFileError> { result.map(Into::into) },
399        );
400
401        let is_dependencies_link_success = out_of_date_package_project_references(
402            &monorepo_manifest.root,
403            &package_manifests_by_package_name,
404        )?
405        .map(
406            |result| -> Result<OutOfDateTypescriptConfig, FromFileError> { result.map(Into::into) },
407        );
408
409        let lint_issues: AllOutOfDateTypescriptConfig = is_children_link_success
410            .chain(is_dependencies_link_success)
411            .collect::<Result<_, _>>()?;
412
413        match lint_issues.is_empty() {
414            true => Ok(()),
415            false => Err(lint_issues)?,
416        }
417    }
418    inner(root.as_ref())
419}