tsconfig_includes/
estimate.rs

1use std::{
2    collections::{HashMap, HashSet},
3    fmt::Display,
4    iter,
5    path::{Path, PathBuf},
6};
7
8use globwalk::{FileType, GlobWalkerBuilder};
9use log::{debug, trace};
10use rayon::prelude::*;
11use serde::Deserialize;
12use typescript_tools::{configuration_file::ConfigurationFile, monorepo_manifest};
13
14use crate::{
15    io::read_json_from_file,
16    path::{self, *},
17    typescript_package::{
18        FromTypescriptConfigFileError, PackageInMonorepoRootError, PackageManifest,
19        PackageManifestFile, TypescriptConfigFile, TypescriptPackage,
20    },
21};
22
23#[derive(Debug, Default, Deserialize)]
24#[serde(rename_all = "camelCase")]
25struct CompilerOptions {
26    #[serde(default)]
27    allow_js: bool,
28    #[serde(default)]
29    resolve_json_module: bool,
30}
31
32#[derive(Debug, Deserialize)]
33#[serde(rename_all = "camelCase")]
34struct TypescriptConfig {
35    #[serde(default)]
36    compiler_options: CompilerOptions,
37    // DISCUSS: how should we behave if `include` is not present?
38    include: Vec<String>,
39}
40
41impl TypescriptConfig {
42    /// LIMITATION: The TypeScript compiler docs state:
43    ///
44    /// > If a glob pattern doesn’t include a file extension, then only files
45    /// > with supported extensions are included (e.g. .ts, .tsx, and .d.ts by
46    /// > default, with .js and .jsx if allowJs is set to true).
47    ///
48    /// This implementation does not examine if globs contain extensions.
49    fn whitelisted_file_extensions(&self) -> HashSet<String> {
50        let mut whitelist: Vec<String> = vec![
51            String::from(".ts"),
52            String::from(".tsx"),
53            String::from(".d.ts"),
54        ];
55        if self.compiler_options.allow_js {
56            whitelist.append(&mut vec![String::from(".js"), String::from(".jsx")]);
57        }
58
59        // add extensions from any glob that specifies one
60        let mut glob_extensions: Vec<String> = self
61            .include
62            .iter()
63            .filter(|pattern| is_glob(pattern))
64            .filter_map(|glob| glob_file_extension(glob))
65            .collect();
66
67        // FIXME: glob extensions apply to a specific glob, not every glob
68        whitelist.append(&mut glob_extensions);
69        whitelist
70            .into_iter()
71            .filter(|extension| {
72                if !extension.ends_with(".json") {
73                    return true;
74                }
75                // For JSON modules, the presence of a "src/**/*.json" include glob
76                // is not enough, JSON imports are still gated by this compiler option.
77                self.compiler_options.resolve_json_module
78            })
79            .collect()
80    }
81}
82
83#[derive(Debug)]
84#[non_exhaustive]
85pub struct BuildWalkerError {
86    kind: BuildWalkerErrorKind,
87}
88
89impl Display for BuildWalkerError {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        match &self.kind {
92            BuildWalkerErrorKind::PackageInMonorepoRoot(path) => {
93                write!(f, "unexpected package in monorepo root: {:?}", path)
94            }
95            BuildWalkerErrorKind::IO(_) => write!(f, "unable to estimate tsconfig includes"),
96        }
97    }
98}
99
100impl std::error::Error for BuildWalkerError {
101    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
102        match &self.kind {
103            BuildWalkerErrorKind::IO(err) => Some(err),
104            BuildWalkerErrorKind::PackageInMonorepoRoot(_) => None,
105        }
106    }
107}
108
109#[derive(Debug)]
110pub enum BuildWalkerErrorKind {
111    #[non_exhaustive]
112    IO(crate::io::FromFileError),
113    #[non_exhaustive]
114    PackageInMonorepoRoot(PathBuf),
115}
116
117impl From<crate::io::FromFileError> for BuildWalkerErrorKind {
118    fn from(err: crate::io::FromFileError) -> Self {
119        Self::IO(err)
120    }
121}
122
123#[derive(Debug)]
124#[non_exhaustive]
125pub struct WalkError {
126    kind: WalkErrorKind,
127}
128
129impl Display for WalkError {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        match &self.kind {
132            WalkErrorKind::WalkError(_) => write!(f, "unable to walk directory tree"),
133            // DISCUSS: is this something we can fully test for at compile time?
134            // If so, we can use `expect` instead of exposing this possibility to the user.
135            WalkErrorKind::Path(_) => write!(f, "unable to strip path prefix"),
136        }
137    }
138}
139
140impl std::error::Error for WalkError {
141    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
142        match &self.kind {
143            WalkErrorKind::Path(err) => Some(err),
144            WalkErrorKind::WalkError(err) => Some(err),
145        }
146    }
147}
148
149impl From<globwalk::WalkError> for WalkError {
150    fn from(err: globwalk::WalkError) -> Self {
151        Self {
152            kind: WalkErrorKind::WalkError(err),
153        }
154    }
155}
156
157#[derive(Debug)]
158pub enum WalkErrorKind {
159    #[non_exhaustive]
160    Path(path::StripPrefixError),
161    #[non_exhaustive]
162    WalkError(globwalk::WalkError),
163}
164
165/// Use the `tsconfig_file`'s `include` configuration to enumerate the list of files
166/// matching include globs.
167fn tsconfig_includes_estimate<'a, 'b>(
168    monorepo_root: &'a Path,
169    tsconfig_file: &'b TypescriptConfigFile,
170) -> Result<impl Iterator<Item = Result<PathBuf, WalkError>>, BuildWalkerError> {
171    let monorepo_root = monorepo_root.to_owned();
172    let package_directory = tsconfig_file
173        .package_directory(&monorepo_root)
174        .map_err(|kind| BuildWalkerError {
175            kind: BuildWalkerErrorKind::PackageInMonorepoRoot(kind.0),
176        })?;
177    let tsconfig: TypescriptConfig =
178        read_json_from_file(monorepo_root.join(tsconfig_file.as_path())).map_err(|err| {
179            BuildWalkerError {
180                kind: BuildWalkerErrorKind::IO(err),
181            }
182        })?;
183
184    let whitelisted_file_extensions = tsconfig.whitelisted_file_extensions();
185
186    let is_whitelisted_file_extension = move |path: &Path| -> bool {
187        // Can't use path::extension here because some globs specify more than
188        // just a single extension (like .d.ts).
189        whitelisted_file_extensions.iter().any(|extension| {
190            path.to_str()
191                .expect("Path should contain only valid UTF-8")
192                .ends_with(extension)
193        })
194    };
195
196    let monorepo_root_two = monorepo_root.clone();
197    let included_files = GlobWalkerBuilder::from_patterns(package_directory, &tsconfig.include)
198        .file_type(FileType::FILE)
199        .min_depth(0)
200        .build()
201        .expect("should be able to create glob walker")
202        .filter(move |maybe_dir_entry| match maybe_dir_entry {
203            Ok(dir_entry) => {
204                is_monorepo_file(&monorepo_root_two, dir_entry.path())
205                    && is_whitelisted_file_extension(dir_entry.path())
206            }
207            Err(_) => true,
208        })
209        .map(move |maybe_dir_entry| -> Result<PathBuf, WalkError> {
210            let dir_entry = maybe_dir_entry?;
211            let path = dir_entry
212                .path()
213                .strip_prefix(&monorepo_root)
214                .map(ToOwned::to_owned)
215                .expect(&format!(
216                    "Should be able to strip monorepo-root prefix from path in monorepo: {:?}",
217                    dir_entry.path()
218                ));
219            Ok(path)
220        });
221
222    Ok(included_files)
223}
224
225#[derive(Debug)]
226#[non_exhaustive]
227pub struct Error {
228    kind: ErrorKind,
229}
230
231impl Display for Error {
232    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233        match &self.kind {
234            ErrorKind::PackageInMonorepoRoot(path) => {
235                write!(f, "unexpected package in monorepo root: {:?}", path)
236            }
237            _ => write!(f, "unable to estimate tsconfig includes"),
238        }
239    }
240}
241
242impl std::error::Error for Error {
243    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
244        match &self.kind {
245            ErrorKind::MonorepoManifest(err) => Some(err),
246            ErrorKind::EnumeratePackageManifestsError(err) => Some(err),
247            ErrorKind::PackageInMonorepoRoot(_) => None,
248            ErrorKind::FromFile(err) => Some(err),
249            ErrorKind::BuildWalker(err) => Some(err),
250            ErrorKind::Walk(err) => Some(err),
251        }
252    }
253}
254
255impl From<ErrorKind> for Error {
256    fn from(kind: ErrorKind) -> Self {
257        Self { kind }
258    }
259}
260
261impl From<typescript_tools::io::FromFileError> for Error {
262    fn from(err: typescript_tools::io::FromFileError) -> Self {
263        Self {
264            kind: ErrorKind::MonorepoManifest(err),
265        }
266    }
267}
268
269impl From<typescript_tools::monorepo_manifest::EnumeratePackageManifestsError> for Error {
270    fn from(err: typescript_tools::monorepo_manifest::EnumeratePackageManifestsError) -> Self {
271        Self {
272            kind: ErrorKind::EnumeratePackageManifestsError(err),
273        }
274    }
275}
276
277impl From<crate::io::FromFileError> for Error {
278    fn from(err: crate::io::FromFileError) -> Self {
279        Self {
280            kind: ErrorKind::FromFile(err),
281        }
282    }
283}
284
285impl From<BuildWalkerError> for Error {
286    fn from(err: BuildWalkerError) -> Self {
287        match err.kind {
288            // avoid nesting this error to present a cleaner backtrace
289            BuildWalkerErrorKind::PackageInMonorepoRoot(path) => Self {
290                kind: ErrorKind::PackageInMonorepoRoot(path),
291            },
292            _ => Self {
293                kind: ErrorKind::BuildWalker(err),
294            },
295        }
296    }
297}
298
299impl From<WalkError> for Error {
300    fn from(err: WalkError) -> Self {
301        Self {
302            kind: ErrorKind::Walk(err),
303        }
304    }
305}
306
307impl From<FromTypescriptConfigFileError> for Error {
308    fn from(err: FromTypescriptConfigFileError) -> Self {
309        let kind = match err {
310            FromTypescriptConfigFileError::PackageInMonorepoRoot(path) => {
311                ErrorKind::PackageInMonorepoRoot(path)
312            }
313            FromTypescriptConfigFileError::FromFile(err) => ErrorKind::FromFile(err),
314        };
315        Self { kind }
316    }
317}
318
319impl From<PackageInMonorepoRootError> for Error {
320    fn from(err: PackageInMonorepoRootError) -> Self {
321        Self {
322            kind: ErrorKind::PackageInMonorepoRoot(err.0),
323        }
324    }
325}
326
327#[derive(Debug)]
328pub enum ErrorKind {
329    #[non_exhaustive]
330    MonorepoManifest(typescript_tools::io::FromFileError),
331    #[non_exhaustive]
332    EnumeratePackageManifestsError(
333        typescript_tools::monorepo_manifest::EnumeratePackageManifestsError,
334    ),
335    #[non_exhaustive]
336    PackageInMonorepoRoot(PathBuf),
337    #[non_exhaustive]
338    FromFile(crate::io::FromFileError),
339    #[non_exhaustive]
340    BuildWalker(BuildWalkerError),
341    #[non_exhaustive]
342    Walk(WalkError),
343}
344
345/// Enumerate source code files used by the TypeScript compiler during
346/// compilation. The return value is a list of alphabetically-sorted relative
347/// paths from the monorepo root, grouped by scoped package name.
348///
349/// - `monorepo_root` may be an absolute path
350/// - `tsconfig_files` should be relative paths from the monorepo root
351pub fn tsconfig_includes_by_package_name<P, T>(
352    monorepo_root: P,
353    tsconfig_files: T,
354) -> Result<HashMap<String, Vec<PathBuf>>, Error>
355where
356    P: AsRef<Path> + Sync,
357    T: IntoIterator,
358    T::Item: AsRef<Path>,
359{
360    // REFACTOR: avoid duplicated code in estimate.rs and exact.rs
361    let lerna_manifest =
362        monorepo_manifest::MonorepoManifest::from_directory(monorepo_root.as_ref())?;
363    let package_manifests_by_package_name = lerna_manifest.package_manifests_by_package_name()?;
364    trace!("{:?}", lerna_manifest);
365
366    // As relative path from monorepo root
367    let transitive_internal_dependency_tsconfigs_inclusive_to_enumerate: HashSet<
368        TypescriptPackage,
369    > = tsconfig_files
370        .into_iter()
371        .map(|tsconfig_file| -> Result<Vec<TypescriptPackage>, Error> {
372            let tsconfig_file: TypescriptConfigFile =
373                monorepo_root.as_ref().join(tsconfig_file.as_ref()).into();
374            let package_manifest: PackageManifest = (&tsconfig_file).try_into()?;
375            let package_manifest = package_manifests_by_package_name
376                .get(&package_manifest.name)
377                .expect(&format!(
378                    "tsconfig {:?} should belong to a package in the lerna monorepo",
379                    tsconfig_file
380                ));
381
382            // RESUME: replace this comment with something sensible
383            // DISCUSS: what's the deal with transitive deps if enumerate is point and shoot?
384            let transitive_internal_dependencies_inclusive = {
385                // Enumerate internal dependencies (exclusive)
386                package_manifest
387                    .transitive_internal_dependency_package_names_exclusive(
388                        &package_manifests_by_package_name,
389                    )
390                    // Make this list inclusive of the target package
391                    .chain(iter::once(package_manifest))
392            };
393
394            Ok(transitive_internal_dependencies_inclusive
395                .map(
396                    |package_manifest| -> Result<_, PackageInMonorepoRootError> {
397                        let package_manifest_file =
398                            PackageManifestFile::from(package_manifest.path());
399                        let tsconfig_file: TypescriptConfigFile =
400                            package_manifest_file.try_into()?;
401                        let typescript_package = TypescriptPackage {
402                            scoped_package_name: package_manifest.contents.name.clone(),
403                            tsconfig_file,
404                        };
405                        Ok(typescript_package)
406                    },
407                )
408                .collect::<Result<_, _>>()?)
409        })
410        // REFACTOR: avoid intermediate allocations
411        .collect::<Result<Vec<_>, _>>()?
412        .into_iter()
413        .flatten()
414        .collect();
415
416    debug!(
417        "transitive_internal_dependency_tsconfigs_inclusive_to_enumerate: {:?}",
418        transitive_internal_dependency_tsconfigs_inclusive_to_enumerate
419    );
420
421    let included_files: HashMap<String, Vec<PathBuf>> =
422        transitive_internal_dependency_tsconfigs_inclusive_to_enumerate
423            .into_par_iter()
424            .map(|typescript_package| -> Result<(_, _), Error> {
425                // This relies on the assumption that tsconfig.json is always the name of the tsconfig file
426                let tsconfig_file = &typescript_package.tsconfig_file;
427                let mut included_files: Vec<_> =
428                    tsconfig_includes_estimate(monorepo_root.as_ref(), tsconfig_file)?
429                        .collect::<Result<_, _>>()?;
430                included_files.sort_unstable();
431                Ok((typescript_package.scoped_package_name, included_files))
432            })
433            .collect::<Result<HashMap<_, _>, _>>()?;
434
435    debug!("tsconfig_includes: {:?}", included_files);
436    Ok(included_files)
437}