Skip to content

Commit

Permalink
feat(turborepo): Framework inference (#5746)
Browse files Browse the repository at this point in the history
### Description

Implements framework inference for turborepo.

### Testing Instructions

Ports tests from `inference_test.go`

---------

Co-authored-by: nicholaslyang <Nicholas Yang>
Co-authored-by: Chris Olszewski <chris.olszewski@vercel.com>
  • Loading branch information
NicholasLYang and chris-olszewski committed Aug 18, 2023
1 parent 71fda43 commit b1b74d7
Show file tree
Hide file tree
Showing 4 changed files with 305 additions and 23 deletions.
286 changes: 286 additions & 0 deletions crates/turborepo-lib/src/framework.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
use std::sync::OnceLock;

use crate::package_graph::WorkspaceInfo;

#[derive(Debug, PartialEq)]
enum Strategy {
All,
Some,
}

#[derive(Debug, PartialEq)]
struct Matcher {
strategy: Strategy,
dependencies: Vec<&'static str>,
}

#[derive(Debug, PartialEq)]
pub struct Framework {
slug: &'static str,
env_wildcards: Vec<&'static str>,
dependency_match: Matcher,
}

static FRAMEWORKS: OnceLock<[Framework; 12]> = OnceLock::new();

fn get_frameworks() -> &'static [Framework] {
FRAMEWORKS.get_or_init(|| {
[
Framework {
slug: "blitzjs",
env_wildcards: vec!["NEXT_PUBLIC_*"],
dependency_match: Matcher {
strategy: Strategy::All,
dependencies: vec!["blitz"],
},
},
Framework {
slug: "nextjs",
env_wildcards: vec!["NEXT_PUBLIC_*"],
dependency_match: Matcher {
strategy: Strategy::All,
dependencies: vec!["next"],
},
},
Framework {
slug: "gatsby",
env_wildcards: vec!["GATSBY_*"],
dependency_match: Matcher {
strategy: Strategy::All,
dependencies: vec!["gatsby"],
},
},
Framework {
slug: "astro",
env_wildcards: vec!["PUBLIC_*"],
dependency_match: Matcher {
strategy: Strategy::All,
dependencies: vec!["astro"],
},
},
Framework {
slug: "solidstart",
env_wildcards: vec!["VITE_*"],
dependency_match: Matcher {
strategy: Strategy::All,
dependencies: vec!["solid-js", "solid-start"],
},
},
Framework {
slug: "vue",
env_wildcards: vec!["VUE_APP_*"],
dependency_match: Matcher {
strategy: Strategy::All,
dependencies: vec!["@vue/cli-service"],
},
},
Framework {
slug: "sveltekit",
env_wildcards: vec!["VITE_*"],
dependency_match: Matcher {
strategy: Strategy::All,
dependencies: vec!["@sveltejs/kit"],
},
},
Framework {
slug: "create-react-app",
env_wildcards: vec!["REACT_APP_*"],
dependency_match: Matcher {
strategy: Strategy::Some,
dependencies: vec!["react-scripts", "react-dev-utils"],
},
},
Framework {
slug: "nuxtjs",
env_wildcards: vec!["NUXT_ENV_*"],
dependency_match: Matcher {
strategy: Strategy::Some,
dependencies: vec!["nuxt", "nuxt-edge", "nuxt3", "nuxt3-edge"],
},
},
Framework {
slug: "redwoodjs",
env_wildcards: vec!["REDWOOD_ENV_*"],
dependency_match: Matcher {
strategy: Strategy::All,
dependencies: vec!["@redwoodjs/core"],
},
},
Framework {
slug: "vite",
env_wildcards: vec!["VITE_*"],
dependency_match: Matcher {
strategy: Strategy::All,
dependencies: vec!["vite"],
},
},
Framework {
slug: "sanity",
env_wildcards: vec!["SANITY_STUDIO_*"],
dependency_match: Matcher {
strategy: Strategy::All,
dependencies: vec!["@sanity/cli"],
},
},
]
})
}

impl Matcher {
pub fn test(&self, workspace: &WorkspaceInfo, is_monorepo: bool) -> bool {
// In the case where we're not in a monorepo, i.e. single package mode
// `unresolved_external_dependencies` is not populated. In which
// case we should check `dependencies` instead.
let deps = if is_monorepo {
workspace.unresolved_external_dependencies.as_ref()
} else {
workspace.package_json.dependencies.as_ref()
};

match self.strategy {
Strategy::All => self
.dependencies
.iter()
.all(|dep| deps.map_or(false, |deps| deps.contains_key(*dep))),
Strategy::Some => self
.dependencies
.iter()
.any(|dep| deps.map_or(false, |deps| deps.contains_key(*dep))),
}
}
}

#[allow(dead_code)]
pub fn infer_framework(workspace: &WorkspaceInfo, is_monorepo: bool) -> Option<&'static Framework> {
let frameworks = get_frameworks();

frameworks
.iter()
.find(|framework| framework.dependency_match.test(workspace, is_monorepo))
}

#[cfg(test)]
mod tests {
use test_case::test_case;

use crate::{
framework::{get_frameworks, infer_framework, Framework},
package_graph::WorkspaceInfo,
package_json::PackageJson,
};

fn get_framework_by_slug(slug: &str) -> &'static Framework {
get_frameworks()
.iter()
.find(|framework| framework.slug == slug)
.expect("framework not found")
}

#[test_case(WorkspaceInfo::default(), None, true; "empty dependencies")]
#[test_case(
WorkspaceInfo {
unresolved_external_dependencies: Some(
vec![("blitz".to_string(), "*".to_string())].into_iter().collect()
),
..Default::default()
},
Some(get_framework_by_slug("blitzjs")),
true;
"blitz"
)]
#[test_case(
WorkspaceInfo {
unresolved_external_dependencies: Some(
vec![("blitz", "*"), ("next", "*")]
.into_iter()
.map(|(s1, s2)| (s1.to_string(), s2.to_string()))
.collect()
),
..Default::default()
},
Some(get_framework_by_slug("blitzjs")),
true;
"Order is preserved (returns blitz, not next)"
)]
#[test_case(
WorkspaceInfo {
unresolved_external_dependencies: Some(
vec![("next", "*")]
.into_iter()
.map(|(s1, s2)| (s1.to_string(), s2.to_string()))
.collect()
),
..Default::default()
},
Some(get_framework_by_slug("nextjs")),
true;
"Finds next without blitz"
)]
#[test_case(
WorkspaceInfo {
unresolved_external_dependencies: Some(
vec![("solid-js", "*"), ("solid-start", "*")]
.into_iter()
.map(|(s1, s2)| (s1.to_string(), s2.to_string()))
.collect()
),
..Default::default()
},
Some(get_framework_by_slug("solidstart")),
true;
"match all strategy works (solid)"
)]
#[test_case(
WorkspaceInfo {
unresolved_external_dependencies: Some(
vec![("nuxt3", "*")]
.into_iter()
.map(|(s1, s2)| (s1.to_string(), s2.to_string()))
.collect()
),
..Default::default()
},
Some(get_framework_by_slug("nuxtjs")),
true;
"match some strategy works (nuxt)"
)]
#[test_case(
WorkspaceInfo {
unresolved_external_dependencies: Some(
vec![("react-scripts", "*")]
.into_iter()
.map(|(s1, s2)| (s1.to_string(), s2.to_string()))
.collect()
),
..Default::default()
},
Some(get_framework_by_slug("create-react-app")),
true;
"match some strategy works (create-react-app)"
)]
#[test_case(
WorkspaceInfo {
package_json: PackageJson {
dependencies: Some(
vec![("next", "*")]
.into_iter()
.map(|(s1, s2)| (s1.to_string(), s2.to_string()))
.collect()
),
..Default::default()
},
..Default::default()
},
Some(get_framework_by_slug("nextjs")),
false;
"Finds next in non-monorepo"
)]
fn test_infer_framework(
workspace_info: WorkspaceInfo,
expected: Option<&'static Framework>,
is_monorepo: bool,
) {
let framework = infer_framework(&workspace_info, is_monorepo);
assert_eq!(framework, expected);
}
}
1 change: 1 addition & 0 deletions crates/turborepo-lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ mod config;
mod daemon;
mod engine;
mod execution_state;
mod framework;
pub(crate) mod globwatcher;
pub mod graph;
mod manager;
Expand Down
23 changes: 11 additions & 12 deletions crates/turborepo-lib/src/package_graph/builder.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use std::{
collections::{HashMap, HashSet},
collections::{BTreeMap, HashMap, HashSet},
fmt,
};

Expand All @@ -11,8 +11,12 @@ use turbopath::{
};
use turborepo_lockfiles::Lockfile;

use super::{Package, PackageGraph, WorkspaceInfo, WorkspaceName, WorkspaceNode};
use crate::{package_json::PackageJson, package_manager::PackageManager};
use super::{PackageGraph, WorkspaceInfo, WorkspaceName, WorkspaceNode};
use crate::{
package_graph::{PackageName, PackageVersion},
package_json::PackageJson,
package_manager::PackageManager,
};

pub struct PackageGraphBuilder<'a> {
repo_root: &'a AbsoluteSystemPath,
Expand Down Expand Up @@ -397,9 +401,7 @@ impl<'a> BuildState<'a, ResolvedLockfile> {
.as_ref()
.map(|deps| {
deps.iter()
.map(|Package { name, version }| {
(name.to_string(), version.to_string())
})
.map(|(name, version)| (name.to_string(), version.to_string()))
.collect()
})
.unwrap_or_default();
Expand Down Expand Up @@ -447,7 +449,7 @@ impl<'a> BuildState<'a, ResolvedLockfile> {

struct Dependencies {
internal: HashSet<WorkspaceName>,
external: HashSet<Package>,
external: BTreeMap<PackageName, PackageVersion>,
}

impl Dependencies {
Expand All @@ -462,7 +464,7 @@ impl Dependencies {
.parent()
.expect("package.json path should have parent");
let mut internal = HashSet::new();
let mut external = HashSet::new();
let mut external = BTreeMap::new();
let splitter = DependencySplitter {
repo_root,
workspace_dir,
Expand All @@ -472,10 +474,7 @@ impl Dependencies {
if let Some(workspace) = splitter.is_internal(name, version) {
internal.insert(workspace);
} else {
external.insert(Package {
name: name.clone(),
version: version.clone(),
});
external.insert(name.clone(), version.clone());
}
}
Self { internal, external }
Expand Down
Loading

0 comments on commit b1b74d7

Please sign in to comment.