use crate::lang::ANALYZERS;
use crate::resolve::normalize;
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
use rustc_hash::FxHashSet;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
const BUILTIN: &str = include_str!("../frameworks.yaml");
pub const PROJECT_FILES: &[&str] = &[
"basta.frameworks.yaml",
"basta.frameworks.yml",
"basta.frameworks.json",
];
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Framework {
pub name: String,
#[serde(default)]
pub detect: Detect,
#[serde(default)]
pub variables: BTreeMap<String, String>,
#[serde(default = "project_directory")]
pub bases: Vec<String>,
#[serde(default)]
pub entry: Vec<String>,
#[serde(default)]
pub directories: Vec<String>,
#[serde(default)]
pub auto_imports: Option<AutoImports>,
#[serde(default)]
pub globals: Vec<Globals>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(untagged)]
pub enum Globals {
Name(String),
Scoped(ScopedGlobals),
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ScopedGlobals {
pub names: Vec<String>,
#[serde(default)]
pub files: Vec<String>,
}
impl Globals {
fn names(&self) -> &[String] {
match self {
Self::Name(name) => std::slice::from_ref(name),
Self::Scoped(scoped) => &scoped.names,
}
}
fn files(&self) -> &[String] {
match self {
Self::Name(_) => &[],
Self::Scoped(scoped) => &scoped.files,
}
}
}
fn project_directory() -> Vec<String> {
vec![".".to_string()]
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Detect {
#[serde(default)]
pub config_files: Vec<String>,
#[serde(default)]
pub dependencies: Vec<String>,
#[serde(default)]
pub package_json_keys: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AutoImports {
#[serde(default)]
pub disabled_by: Option<String>,
pub directories: Vec<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct FrameworkFile {
frameworks: Vec<Framework>,
}
#[derive(Debug, Default)]
pub struct ManifestSignals {
pub dependencies: FxHashSet<String>,
pub sections: FxHashSet<String>,
}
impl ManifestSignals {
pub fn merge(&mut self, other: ManifestSignals) {
self.dependencies.extend(other.dependencies);
self.sections.extend(other.sections);
}
}
#[derive(Debug, PartialEq)]
pub enum Setting {
Text(String),
Off,
}
#[derive(Debug, Clone)]
pub struct Registry {
frameworks: Vec<Known>,
forced: Vec<String>,
enabled: bool,
}
#[derive(Debug, Clone)]
struct Known {
definition: Framework,
config_names: Vec<String>,
}
impl Default for Registry {
fn default() -> Self {
static PARSED: OnceLock<Vec<Framework>> = OnceLock::new();
let builtin = PARSED.get_or_init(|| {
parse(BUILTIN, Format::Yaml).expect("frameworks.yaml is valid")
});
let mut registry = Self {
frameworks: Vec::new(),
forced: Vec::new(),
enabled: true,
};
registry.extend(builtin.iter().cloned());
registry
}
}
#[derive(Debug, Default)]
pub struct Sources<'a> {
pub file: Option<PathBuf>,
pub inline: &'a [Framework],
pub forced: &'a [String],
pub disabled: bool,
}
impl Registry {
pub fn assemble(sources: Sources<'_>) -> (Self, Vec<String>) {
let mut registry = Self::default();
let mut problems = Vec::new();
let file = sources.file.or_else(|| {
std::env::current_dir()
.ok()
.and_then(|directory| project_file(&directory))
});
if let Some(file) = file {
match load(&file) {
Ok(definitions) => registry.extend(definitions),
Err(error) => problems.push(error),
}
}
match sources.inline.iter().try_for_each(Framework::validate) {
Ok(()) => registry.extend(sources.inline.iter().cloned()),
Err(error) => problems.push(error),
}
for name in sources.forced {
if !registry.knows(name) {
problems.push(format!(
"'{name}' is not a framework basta knows (basta --list-frameworks prints them)"
));
}
}
registry.force(sources.forced.iter().cloned());
if sources.disabled {
registry.disable();
}
(registry, problems)
}
pub fn extend(&mut self, definitions: impl IntoIterator<Item = Framework>) {
for definition in definitions {
let known = Known {
config_names: definition
.detect
.config_files
.iter()
.flat_map(|pattern| expand_braces(pattern))
.collect(),
definition,
};
match self
.frameworks
.iter_mut()
.find(|existing| existing.definition.name == known.definition.name)
{
Some(existing) => *existing = known,
None => self.frameworks.push(known),
}
}
}
pub fn force(&mut self, names: impl IntoIterator<Item = String>) {
self.forced.extend(names);
}
pub fn disable(&mut self) {
self.enabled = false;
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn forced(&self) -> &[String] {
&self.forced
}
pub fn knows(&self, name: &str) -> bool {
self.frameworks.iter().any(|f| f.definition.name == name)
}
pub fn definitions(&self) -> impl Iterator<Item = &Framework> {
self.frameworks.iter().map(|known| &known.definition)
}
pub(crate) fn detect(
&self,
directory: &Path,
signals: &ManifestSignals,
is_root: bool,
) -> Vec<Rooted> {
if !self.enabled {
return Vec::new();
}
let present: FxHashSet<String> = std::fs::read_dir(directory)
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| entry.file_name().into_string().ok())
.collect();
self.frameworks
.iter()
.filter_map(|known| {
let definition = &known.definition;
let config = known
.config_names
.iter()
.find(|name| match name.contains('/') {
true => directory.join(name.as_str()).is_file(),
false => present.contains(name.as_str()),
});
let detected = config.is_some()
|| definition
.detect
.dependencies
.iter()
.any(|wanted| depends_on(&signals.dependencies, wanted))
|| definition
.detect
.package_json_keys
.iter()
.any(|key| signals.sections.contains(key))
|| (is_root && self.forced.contains(&definition.name));
if !detected {
return None;
}
let text = config.and_then(|name| {
let text = std::fs::read_to_string(directory.join(name)).ok()?;
Some((name.as_str(), text))
});
Some(definition.root(directory, text.as_ref().map(|(n, t)| (*n, t.as_str()))))
})
.collect()
}
}
fn depends_on(dependencies: &FxHashSet<String>, wanted: &str) -> bool {
match wanted.strip_suffix('*') {
Some(prefix) => dependencies.iter().any(|name| name.starts_with(prefix)),
None => dependencies.contains(wanted),
}
}
impl Framework {
fn root(&self, directory: &Path, config: Option<(&str, &str)>) -> Rooted {
let setting = |key: &str| config.and_then(|(name, text)| config_setting(name, text, key));
let variables: Vec<(String, String)> = self
.variables
.iter()
.map(|(key, default)| {
let value = match setting(key) {
Some(Setting::Text(value)) => relative_directory(&value),
_ => None,
};
(
format!("${{{key}}}"),
value.unwrap_or_else(|| default.clone()),
)
})
.collect();
let substitute = |template: &str, escape: bool| {
variables
.iter()
.fold(template.to_string(), |text, (name, value)| match escape {
true => text.replace(name, &globset::escape(value)),
false => text.replace(name, value),
})
};
let mut bases: Vec<PathBuf> = Vec::new();
for base in &self.bases {
let base = normalize(&directory.join(substitute(base, false)));
if !bases.contains(&base) {
bases.push(base);
}
}
let auto_imported = self.auto_imports.iter().flat_map(|auto| {
let off = auto
.disabled_by
.as_deref()
.is_some_and(|key| setting(key) == Some(Setting::Off));
auto.directories.iter().filter(move |_| !off)
});
let mut directories: Vec<PathBuf> = Vec::new();
for name in self.directories.iter().chain(auto_imported) {
let name = substitute(name, false);
for base in &bases {
let path = normalize(&base.join(&name));
if path != *base && !directories.contains(&path) {
directories.push(path);
}
}
}
let mut entry = GlobSetBuilder::new();
for pattern in &self.entry {
let pattern = substitute(pattern, true);
if let Ok(glob) = entry_glob(pattern.trim_start_matches("./")) {
entry.add(glob);
}
}
let globals = self
.globals
.iter()
.map(|globals| {
let mut files = GlobSetBuilder::new();
for pattern in globals.files() {
let pattern = substitute(pattern, true);
if let Ok(glob) = entry_glob(pattern.trim_start_matches("./")) {
files.add(glob);
}
}
GlobalScope {
names: globals.names().iter().cloned().collect(),
files: (!globals.files().is_empty())
.then(|| files.build().unwrap_or_else(|_| GlobSet::empty())),
}
})
.collect();
let global_names = self
.globals
.iter()
.flat_map(|globals| globals.names().iter().cloned())
.collect();
Rooted {
name: self.name.clone(),
directory: directory.to_path_buf(),
bases,
entry: entry.build().unwrap_or_else(|_| GlobSet::empty()),
directories,
globals,
global_names,
}
}
fn validate(&self) -> Result<(), String> {
let name = &self.name;
if name.trim().is_empty() {
return Err("a framework needs a name".to_string());
}
let paths = self
.bases
.iter()
.chain(&self.directories)
.chain(self.auto_imports.iter().flat_map(|auto| &auto.directories))
.chain(&self.entry)
.chain(self.globals.iter().flat_map(Globals::files))
.chain(self.variables.values());
for path in paths {
if path.starts_with('/') || path.split('/').any(|segment| segment == "..") {
return Err(format!(
"{name}: '{path}' must stay inside the project directory"
));
}
let mut rest = path.as_str();
while let Some(at) = rest.find("${") {
let variable = rest[at + 2..].split('}').next().unwrap_or_default();
if !self.variables.contains_key(variable) {
return Err(format!(
"{name}: '{path}' uses ${{{variable}}}, which `variables` does not declare"
));
}
rest = &rest[at + 2..];
}
}
for globals in &self.globals {
if globals
.names()
.iter()
.any(|global| global.trim().is_empty())
{
return Err(format!("{name}: a global needs a name"));
}
}
let globs = self
.entry
.iter()
.chain(self.globals.iter().flat_map(Globals::files));
for pattern in globs {
let probe = self.variables.keys().fold(pattern.clone(), |text, key| {
text.replace(&format!("${{{key}}}"), "x")
});
entry_glob(&probe).map_err(|error| format!("{name}: glob '{pattern}': {error}"))?;
}
Ok(())
}
}
fn entry_glob(pattern: &str) -> Result<globset::Glob, globset::Error> {
GlobBuilder::new(pattern).literal_separator(true).build()
}
#[derive(Debug)]
pub(crate) struct Rooted {
pub name: String,
pub directory: PathBuf,
bases: Vec<PathBuf>,
entry: GlobSet,
directories: Vec<PathBuf>,
globals: Vec<GlobalScope>,
global_names: FxHashSet<String>,
}
#[derive(Debug)]
struct GlobalScope {
names: FxHashSet<String>,
files: Option<GlobSet>,
}
impl Rooted {
pub fn reaches(&self, path: &Path) -> bool {
self.directories
.iter()
.any(|directory| path.starts_with(directory))
|| self.bases.iter().any(|base| {
path.strip_prefix(base).is_ok_and(|relative| {
self.entry
.is_match(relative.to_string_lossy().replace('\\', "/"))
})
})
}
}
impl Rooted {
pub fn reads(&self, path: &Path, name: &str) -> bool {
if !self.global_names.contains(name) || !path.starts_with(&self.directory) {
return false;
}
self.globals
.iter()
.filter(|scope| scope.names.contains(name))
.any(|scope| match &scope.files {
None => true,
Some(files) => self.bases.iter().any(|base| {
path.strip_prefix(base).is_ok_and(|relative| {
files.is_match(relative.to_string_lossy().replace('\\', "/"))
})
}),
})
}
pub fn has_globals(&self) -> bool {
!self.globals.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct DetectedFramework {
pub name: String,
pub directory: PathBuf,
}
fn config_setting(name: &str, text: &str, key: &str) -> Option<Setting> {
if name.ends_with(".json") {
let json: serde_json::Value = serde_json::from_str(text).ok()?;
return match json.get(key)? {
serde_json::Value::String(value) => Some(Setting::Text(value.clone())),
serde_json::Value::Bool(false) => Some(Setting::Off),
_ => None,
};
}
ANALYZERS
.iter()
.find_map(|analyzer| analyzer.config_setting(name, text, key))
}
fn relative_directory(value: &str) -> Option<String> {
let value = value.trim();
let inside = !value.starts_with(['/', '\\'])
&& !value.contains(':')
&& value.split(['/', '\\']).all(|segment| segment != "..");
match value.trim_start_matches("./").trim_end_matches('/') {
_ if !inside => None,
"" | "." => Some(".".to_string()),
value => Some(value.to_string()),
}
}
fn expand_braces(pattern: &str) -> Vec<String> {
let Some(open) = pattern.find('{') else {
return vec![pattern.to_string()];
};
let mut depth = 0usize;
let mut close = None;
let mut commas = Vec::new();
for (at, byte) in pattern.bytes().enumerate().skip(open) {
match byte {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
close = Some(at);
break;
}
}
b',' if depth == 1 => commas.push(at),
_ => {}
}
}
let Some(close) = close else {
return vec![pattern.to_string()]; };
let (head, tail) = (&pattern[..open], &pattern[close + 1..]);
let mut starts = vec![open + 1];
starts.extend(commas.iter().map(|comma| comma + 1));
let ends = commas.iter().copied().chain(std::iter::once(close));
starts
.into_iter()
.zip(ends)
.flat_map(|(start, end)| expand_braces(&format!("{head}{}{tail}", &pattern[start..end])))
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Format {
Yaml,
Json,
}
impl Format {
pub fn of(path: &Path) -> Self {
match path.extension().and_then(|extension| extension.to_str()) {
Some(extension) if extension.eq_ignore_ascii_case("json") => Self::Json,
_ => Self::Yaml,
}
}
}
pub fn parse(text: &str, format: Format) -> Result<Vec<Framework>, String> {
let file: FrameworkFile = match format {
Format::Yaml => serde_yaml_ng::from_str(text).map_err(|error| error.to_string())?,
Format::Json => serde_json::from_str(text).map_err(|error| error.to_string())?,
};
for framework in &file.frameworks {
framework.validate()?;
}
Ok(file.frameworks)
}
pub fn load(path: &Path) -> Result<Vec<Framework>, String> {
let text =
std::fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))?;
parse(&text, Format::of(path)).map_err(|error| format!("{}: {error}", path.display()))
}
pub fn project_file(directory: &Path) -> Option<PathBuf> {
PROJECT_FILES
.iter()
.map(|name| directory.join(name))
.find(|path| path.is_file())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_scan::TempTree;
fn builtin(name: &str) -> Framework {
Registry::default()
.definitions()
.find(|framework| framework.name == name)
.unwrap_or_else(|| panic!("no built-in framework called {name}"))
.clone()
}
fn signals(dependencies: &[&str], sections: &[&str]) -> ManifestSignals {
ManifestSignals {
dependencies: dependencies.iter().map(|d| d.to_string()).collect(),
sections: sections.iter().map(|s| s.to_string()).collect(),
}
}
fn names(detected: &[Rooted]) -> Vec<&str> {
detected.iter().map(|rooted| rooted.name.as_str()).collect()
}
#[test]
fn the_built_in_table_is_valid() {
let frameworks = parse(BUILTIN, Format::Yaml).expect("frameworks.yaml parses");
assert!(frameworks.len() >= 40, "{} frameworks", frameworks.len());
let mut seen = FxHashSet::default();
for framework in &frameworks {
assert!(
seen.insert(framework.name.as_str()),
"{} is defined twice",
framework.name
);
let detect = &framework.detect;
assert!(
!(detect.config_files.is_empty()
&& detect.dependencies.is_empty()
&& detect.package_json_keys.is_empty()),
"{} can never be detected",
framework.name
);
assert!(
!(framework.entry.is_empty()
&& framework.directories.is_empty()
&& framework.auto_imports.is_none()),
"{} starts nothing",
framework.name
);
}
for name in ["vite", "nitro", "wxt", "nuxt", "next"] {
assert!(seen.contains(name), "{name}");
}
}
#[test]
fn no_config_file_name_gives_away_two_frameworks() {
let mut owner: rustc_hash::FxHashMap<String, String> = Default::default();
for known in &Registry::default().frameworks {
for name in &known.config_names {
if let Some(other) = owner.insert(name.clone(), known.definition.name.clone()) {
panic!("{name} detects both {other} and {}", known.definition.name);
}
}
}
assert!(!owner.contains_key("app.config.ts"));
}
#[test]
fn a_config_file_a_dependency_or_a_manifest_section_each_detect_a_framework() {
let registry = Registry::default();
let by_config = TempTree::new("framework-by-config");
by_config.write("next.config.mjs", "export default {}\n");
assert_eq!(
names(®istry.detect(by_config.path(), &signals(&[], &[]), false)),
vec!["next"]
);
let bare = TempTree::new("framework-by-manifest");
bare.write("index.js", "");
assert_eq!(
names(®istry.detect(bare.path(), &signals(&["astro"], &[]), false)),
vec!["astro"]
);
assert_eq!(
names(®istry.detect(bare.path(), &signals(&[], &["jest"]), false)),
vec!["jest"]
);
assert!(
registry
.detect(bare.path(), &signals(&["left-pad"], &["scripts"]), false)
.is_empty()
);
}
#[test]
fn a_dependency_wildcard_matches_a_family_of_packages() {
let tree = TempTree::new("framework-wildcard");
tree.write("index.js", "");
let detected = Registry::default().detect(
tree.path(),
&signals(&["@storybook/react-vite"], &[]),
false,
);
assert_eq!(names(&detected), vec!["storybook"]);
}
#[test]
fn a_config_in_a_subdirectory_is_found() {
let tree = TempTree::new("framework-nested-config");
tree.write(".storybook/main.ts", "export default {}\n");
let detected = Registry::default().detect(tree.path(), &signals(&[], &[]), false);
assert_eq!(names(&detected), vec!["storybook"]);
}
#[test]
fn entry_globs_are_anchored_at_the_directory_the_framework_was_found_in() {
let next = builtin("next").root(Path::new("/p/apps/web"), None);
assert!(next.reaches(Path::new("/p/apps/web/app/blog/[slug]/page.tsx")));
assert!(next.reaches(Path::new("/p/apps/web/src/pages/index.tsx")));
assert!(next.reaches(Path::new("/p/apps/web/middleware.ts")));
assert!(
!next.reaches(Path::new("/p/apps/web/app/blog/helpers.ts")),
"only the file names the router knows"
);
assert!(
!next.reaches(Path::new("/p/apps/api/app/page.tsx")),
"another package is another project"
);
}
#[test]
fn a_star_stays_inside_one_path_segment() {
let electron = builtin("electron-vite").root(Path::new("/p"), None);
assert!(electron.reaches(Path::new("/p/src/preload/webview.ts")));
assert!(!electron.reaches(Path::new("/p/src/preload/lib/util.ts")));
}
#[test]
fn nuxt_roots_its_auto_imported_directories_in_both_layouts() {
let nuxt = builtin("nuxt").root(
Path::new("/p"),
Some(("nuxt.config.ts", "export default defineNuxtConfig({})")),
);
assert!(nuxt.reaches(Path::new("/p/components/Card.vue")));
assert!(nuxt.reaches(Path::new("/p/composables/useThing.ts")));
assert!(nuxt.reaches(Path::new("/p/app/components/Card.vue")));
assert!(!nuxt.reaches(Path::new("/p/lib/helper.ts")));
}
#[test]
fn a_nuxt_config_that_moves_the_source_tree_is_followed() {
let nuxt = builtin("nuxt").root(
Path::new("/p"),
Some((
"nuxt.config.ts",
"export default defineNuxtConfig({ srcDir: 'src/' })",
)),
);
assert!(nuxt.reaches(Path::new("/p/src/components/Card.vue")));
assert!(nuxt.reaches(Path::new("/p/components/Card.vue")));
}
#[test]
fn wxt_entrypoints_are_rooted_wherever_the_config_puts_them() {
let wxt = builtin("wxt");
let default_layout = wxt.root(
Path::new("/p"),
Some(("wxt.config.ts", "export default defineConfig({})")),
);
assert!(default_layout.reaches(Path::new("/p/entrypoints/background.ts")));
assert!(default_layout.reaches(Path::new("/p/utils/storage.ts")));
let moved = wxt.root(
Path::new("/p"),
Some((
"wxt.config.ts",
"export default defineConfig({ srcDir: 'src', entrypointsDir: 'entries' })",
)),
);
assert!(moved.reaches(Path::new("/p/src/entries/popup/main.ts")));
assert!(moved.reaches(Path::new("/p/src/components/Button.tsx")));
assert!(!moved.reaches(Path::new("/p/src/entrypoints/background.ts")));
assert!(!moved.reaches(Path::new("/p/utils/storage.ts")));
let commented = wxt.root(
Path::new("/p"),
Some((
"wxt.config.ts",
"// srcDir: 'nowhere'\nexport default defineConfig({})",
)),
);
assert!(commented.reaches(Path::new("/p/entrypoints/background.ts")));
assert!(!commented.reaches(Path::new("/p/nowhere/entrypoints/background.ts")));
}
#[test]
fn imports_false_turns_the_auto_import_directories_off() {
let wxt = builtin("wxt");
let off = wxt.root(
Path::new("/p"),
Some((
"wxt.config.ts",
"export default defineConfig({ srcDir: 'src', imports: false })",
)),
);
assert!(off.reaches(Path::new("/p/src/entrypoints/background.ts")));
assert!(!off.reaches(Path::new("/p/src/utils/storage.ts")));
for kept in [
"defineConfig({ imports: { eslintrc: { enabled: true } } })",
"defineConfig({ imports: falsework })",
] {
let rooted = wxt.root(Path::new("/p"), Some(("wxt.config.ts", kept)));
assert!(rooted.reaches(Path::new("/p/utils/storage.ts")), "{kept}");
}
}
#[test]
fn a_scoped_global_holds_only_in_the_files_the_framework_reads_it_from() {
let remix = builtin("remix").root(
Path::new("/p/apps/shop"),
Some((
"remix.config.js",
"module.exports = { appDirectory: 'source' }",
)),
);
assert!(remix.reads(Path::new("/p/apps/shop/source/routes/cart.tsx"), "loader"));
assert!(remix.reads(Path::new("/p/apps/shop/source/root.tsx"), "links"));
assert!(
remix.reads(
Path::new("/p/apps/shop/source/features/cart/route.tsx"),
"loader"
),
"a route named from routes.ts lives anywhere under the app directory"
);
assert!(
!remix.reads(Path::new("/p/apps/shop/scripts/images.ts"), "loader"),
"anybody's word outside the app directory"
);
assert!(
!remix.reads(Path::new("/p/apps/shop/source/routes/cart.tsx"), "helper"),
"not a name Remix has"
);
assert!(
!remix.reads(Path::new("/p/apps/shop/app/routes/cart.tsx"), "loader"),
"the config moved the routes"
);
}
#[test]
fn a_bare_global_holds_anywhere_in_its_own_project_and_nowhere_else() {
let angular = builtin("angular").root(Path::new("/p/apps/admin"), None);
assert!(angular.has_globals());
assert!(angular.reads(
Path::new("/p/apps/admin/src/app/orders/orders.component.ts"),
"ngOnInit"
));
assert!(
!angular.reads(Path::new("/p/apps/api/src/orders.ts"), "ngOnInit"),
"the package next door is not an Angular application"
);
assert!(!builtin("vite").root(Path::new("/p"), None).has_globals());
}
#[test]
fn globals_are_written_as_names_or_as_names_with_their_files() {
let yaml = "
frameworks:
- name: house
detect: { dependencies: [house] }
globals:
- onBoot
- names: [screen, guard]
files: ['screens/**/*.ts']
";
let house = parse(yaml, Format::Yaml).unwrap().remove(0);
assert_eq!(house.globals.len(), 2);
let rooted = house.root(Path::new("/p"), None);
assert!(rooted.reads(Path::new("/p/kit/start.ts"), "onBoot"));
assert!(rooted.reads(Path::new("/p/screens/home/index.ts"), "guard"));
assert!(!rooted.reads(Path::new("/p/kit/start.ts"), "guard"));
for (broken, complaint) in [
("globals: ['']", "a global needs a name"),
(
"globals: [{ names: [x], files: ['../up/*.ts'] }]",
"must stay inside",
),
("globals: [{ names: [x], files: ['a/{b'] }]", "glob 'a/{b'"),
] {
let yaml = format!("frameworks: [{{ name: x, {broken} }}]");
let error = parse(&yaml, Format::Yaml).unwrap_err();
assert!(error.contains(complaint), "{broken}: {error}");
}
}
#[test]
fn a_python_framework_is_a_row_of_the_same_table() {
let tree = TempTree::new("framework-django");
tree.write("backend/manage.py", "");
let registry = Registry::default();
let detected = registry.detect(&tree.path().join("backend"), &signals(&[], &[]), false);
assert_eq!(names(&detected), vec!["django"]);
let django = &detected[0];
let at = |path: &str| tree.path().join("backend").join(path);
assert!(django.reaches(&at("shop/migrations/0007_add_index.py")));
assert!(django.reaches(&at("shop/management/commands/reindex.py")));
assert!(django.reaches(&at("shop/templatetags/shop_tags.py")));
assert!(django.reaches(&at("shop/admin.py")));
assert!(!django.reaches(&at("shop/services/pricing.py")));
assert!(django.reads(&at("shop/urls.py"), "urlpatterns"));
assert!(!django.reads(&at("shop/urls.py"), "helper"));
}
#[test]
fn a_json_config_sets_a_variable_too() {
let nest = builtin("nest").root(
Path::new("/p"),
Some(("nest-cli.json", r#"{ "sourceRoot": "server" }"#)),
);
assert!(nest.reaches(Path::new("/p/server/main.ts")));
assert!(!nest.reaches(Path::new("/p/src/main.ts")));
}
#[test]
fn a_variable_cannot_lead_out_of_the_project() {
for escape in ["../elsewhere", "/etc", "a/../../b", "C:\\code"] {
assert_eq!(relative_directory(escape), None, "{escape}");
}
assert_eq!(relative_directory("./src/"), Some("src".to_string()));
assert_eq!(relative_directory("./"), Some(".".to_string()));
let nuxt = builtin("nuxt").root(
Path::new("/p"),
Some(("nuxt.config.ts", "export default { srcDir: '../shared' }")),
);
assert!(!nuxt.reaches(Path::new("/shared/components/Card.vue")));
}
#[test]
fn a_forced_framework_applies_at_a_scan_root_only() {
let tree = TempTree::new("framework-forced");
tree.write("pages/index.tsx", "");
let mut registry = Registry::default();
registry.force(["next".to_string()]);
let nothing = signals(&[], &[]);
assert_eq!(
names(®istry.detect(tree.path(), ¬hing, true)),
vec!["next"]
);
assert!(registry.detect(tree.path(), ¬hing, false).is_empty());
}
#[test]
fn a_disabled_registry_detects_nothing() {
let tree = TempTree::new("framework-disabled");
tree.write("next.config.js", "module.exports = {}\n");
let mut registry = Registry::default();
registry.disable();
assert!(
registry
.detect(tree.path(), &signals(&["next"], &[]), true)
.is_empty()
);
}
#[test]
fn a_project_definition_adds_a_framework_or_replaces_a_built_in() {
let yaml = "
frameworks:
- name: house-router
detect:
dependencies: ['@acme/router']
variables:
screensDir: screens
entry: ['${screensDir}/**/*.screen.tsx']
- name: next
detect:
configFiles: ['next.config.js']
directories: [everything]
";
let mut registry = Registry::default();
let before = registry.definitions().count();
registry.extend(parse(yaml, Format::Yaml).unwrap());
assert_eq!(registry.definitions().count(), before + 1);
assert!(registry.knows("house-router"));
assert_eq!(
builtin_from(®istry, "next").directories,
vec!["everything"]
);
let rooted = builtin_from(®istry, "house-router").root(Path::new("/p"), None);
assert!(rooted.reaches(Path::new("/p/screens/home/index.screen.tsx")));
}
fn builtin_from(registry: &Registry, name: &str) -> Framework {
registry
.definitions()
.find(|framework| framework.name == name)
.unwrap()
.clone()
}
#[test]
fn json_and_yaml_say_the_same_thing() {
let json = r#"{ "frameworks": [ { "name": "x", "detect": { "packageJsonKeys": ["x"] }, "entry": ["x/*.ts"] } ] }"#;
let yaml = "frameworks:\n - name: x\n detect:\n packageJsonKeys: [x]\n entry: ['x/*.ts']\n";
assert_eq!(
parse(json, Format::Json).unwrap(),
parse(yaml, Format::Yaml).unwrap()
);
assert_eq!(Format::of(Path::new("a/frameworks.JSON")), Format::Json);
assert_eq!(Format::of(Path::new("a/frameworks.yml")), Format::Yaml);
}
#[test]
fn a_definition_that_cannot_work_is_refused_with_its_name() {
for (yaml, complaint) in [
("frameworks: [{ name: '' }]", "needs a name"),
(
"frameworks: [{ name: x, directories: ['../up'] }]",
"must stay inside",
),
(
"frameworks: [{ name: x, entry: ['${missing}/a.ts'] }]",
"does not declare",
),
("frameworks: [{ name: x, entry: ['a/{b'] }]", "glob 'a/{b'"),
("frameworks: [{ name: x, entrys: [] }]", "unknown field"),
] {
let error = parse(yaml, Format::Yaml).unwrap_err();
assert!(error.contains(complaint), "{yaml}: {error}");
}
}
#[test]
fn brace_alternatives_expand_to_every_name() {
assert_eq!(
expand_braces("vite.config.{js,ts}"),
vec!["vite.config.js", "vite.config.ts"]
);
assert_eq!(
expand_braces("{a,b}.{x,y}"),
vec!["a.x", "a.y", "b.x", "b.y"]
);
assert_eq!(expand_braces("x{a,{b,c}}"), vec!["xa", "xb", "xc"]);
assert_eq!(expand_braces("angular.json"), vec!["angular.json"]);
assert_eq!(expand_braces("odd{name"), vec!["odd{name"]);
}
#[test]
fn the_project_file_is_found_by_any_of_its_names() {
let tree = TempTree::new("framework-project-file");
assert_eq!(project_file(tree.path()), None);
tree.write("basta.frameworks.yml", "frameworks: []\n");
assert_eq!(
project_file(tree.path()),
Some(tree.path().join("basta.frameworks.yml"))
);
assert!(
load(&tree.path().join("basta.frameworks.yml"))
.unwrap()
.is_empty()
);
let missing = load(&tree.path().join("nope.yaml")).unwrap_err();
assert!(missing.contains("nope.yaml"), "{missing}");
}
}