what_stack/config/rules.rs
1use std::path::Path;
2
3use self::ConfigMatch::{
4 AllOf, Exact, Extension, FileToken, NodeDependency, NodeRuntimeDependency, Prefix,
5 PrefixWithNodeDependency,
6};
7use super::files::ProjectFiles;
8use super::python;
9use crate::ecosystem::Ecosystem as E;
10use crate::{StackLabel, labels};
11
12/// How a config rule recognizes its project files.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum ConfigMatch {
15 /// A root entry with exactly this name.
16 Exact(&'static str),
17 /// A root entry named like the prefix plus a common config suffix, such as
18 /// `next.config.mjs`.
19 Prefix(&'static str),
20 /// Every listed path exists. Nested paths such as `bin/rails` use `/`.
21 AllOf(&'static [&'static str]),
22 /// A root entry with this file extension.
23 Extension(&'static str),
24 /// `package.json` lists any of these packages under `"dependencies"` or
25 /// `"devDependencies"`.
26 NodeDependency(&'static [&'static str]),
27 /// A [`Prefix`] config file plus a [`NodeDependency`] on any of the
28 /// packages, for config names that several tools share.
29 PrefixWithNodeDependency(&'static str, &'static [&'static str]),
30 /// `package.json` lists any of these packages under `"dependencies"`. A
31 /// package that only appears in `"devDependencies"` (a test server, a
32 /// mock) does not describe what the project runs.
33 NodeRuntimeDependency(&'static [&'static str]),
34 /// The named root file mentions the token outside comment lines.
35 FileToken(&'static str, &'static str),
36}
37
38/// One config rule: matcher, label, and the ecosystem of the label.
39pub type ConfigRule = (ConfigMatch, StackLabel, E);
40
41/// Rules checked before Python detection, in priority order.
42pub const CONFIG_RULES: &[ConfigRule] = &[
43 (Prefix("next.config"), labels::NEXT_JS, E::Node),
44 (Prefix("nuxt.config"), labels::NUXT, E::Node),
45 (Exact("angular.json"), labels::ANGULAR, E::Node),
46 // Plain Svelte with Vite also has `svelte.config.js`; only SvelteKit
47 // depends on `@sveltejs/kit`.
48 (
49 PrefixWithNodeDependency("svelte.config", &["@sveltejs/kit"]),
50 labels::SVELTEKIT,
51 E::Node,
52 ),
53 (Prefix("astro.config"), labels::ASTRO, E::Node),
54 // React Router v7 framework mode and Remix both build with Vite, so they
55 // come before `vite.config`.
56 (Prefix("react-router.config"), labels::REACT_ROUTER, E::Node),
57 (Prefix("remix.config"), labels::REMIX, E::Node),
58 (NodeDependency(REMIX_PACKAGES), labels::REMIX, E::Node),
59 (Exact("nest-cli.json"), labels::NESTJS, E::Node),
60 (NodeDependency(&["@nestjs/core"]), labels::NESTJS, E::Node),
61 // Next.js 13 and later need no config file.
62 (NodeDependency(&["next"]), labels::NEXT_JS, E::Node),
63 (Prefix("vite.config"), labels::VITE, E::Node),
64 (Prefix("gatsby-config"), labels::GATSBY, E::Node),
65 (Prefix("vue.config"), labels::VUE_CLI, E::Node),
66 (Prefix("webpack.config"), labels::WEBPACK, E::Node),
67 (Exact("Cargo.toml"), labels::RUST, E::Rust),
68 (Exact("go.mod"), labels::GO, E::Go),
69 (Exact("go.work"), labels::GO, E::Go),
70 // The Spring Boot parent POM, starters, and Gradle plugin all use this
71 // group id.
72 (
73 FileToken("pom.xml", SPRING_BOOT_GROUP),
74 labels::SPRING_BOOT,
75 E::Jvm,
76 ),
77 (
78 FileToken("build.gradle.kts", SPRING_BOOT_GROUP),
79 labels::SPRING_BOOT,
80 E::Jvm,
81 ),
82 (
83 FileToken("build.gradle", SPRING_BOOT_GROUP),
84 labels::SPRING_BOOT,
85 E::Jvm,
86 ),
87 (Exact("pom.xml"), labels::JAVA_MAVEN, E::Jvm),
88 (Exact("build.gradle.kts"), labels::KOTLIN_GRADLE, E::Jvm),
89 (Exact("build.gradle"), labels::JAVA_GRADLE, E::Jvm),
90 // Root of a multi-module Gradle build without its own build script.
91 (Exact("settings.gradle.kts"), labels::KOTLIN_GRADLE, E::Jvm),
92 (Exact("settings.gradle"), labels::JAVA_GRADLE, E::Jvm),
93 (
94 AllOf(&["artisan", "composer.json"]),
95 labels::LARAVEL,
96 E::Php,
97 ),
98 (
99 AllOf(&["composer.json", "symfony.lock"]),
100 labels::SYMFONY,
101 E::Php,
102 ),
103 (
104 AllOf(&["composer.json", "bin/console"]),
105 labels::SYMFONY,
106 E::Php,
107 ),
108 (Exact("composer.json"), labels::PHP, E::Php),
109 (FileToken("mix.exs", ":phoenix"), labels::PHOENIX, E::Beam),
110 (Exact("mix.exs"), labels::ELIXIR, E::Beam),
111 (Exact("deno.json"), labels::DENO, E::Deno),
112 (Exact("deno.jsonc"), labels::DENO, E::Deno),
113];
114
115const SPRING_BOOT_GROUP: &str = "org.springframework.boot";
116
117/// Remix application packages. `@remix-run/router` is left out: it is the
118/// routing core of React Router 6 and appears in plain React apps.
119const REMIX_PACKAGES: &[&str] = &[
120 "@remix-run/dev",
121 "@remix-run/react",
122 "@remix-run/node",
123 "@remix-run/serve",
124 "@remix-run/cloudflare",
125];
126
127/// Rules checked after Python detection, in priority order.
128pub const LATE_CONFIG_RULES: &[ConfigRule] = &[
129 (
130 AllOf(&["Gemfile", "config.ru", "bin/rails"]),
131 labels::RAILS,
132 E::Ruby,
133 ),
134 (AllOf(&["Gemfile", "config.ru"]), labels::RUBY_RACK, E::Ruby),
135 (Extension("csproj"), labels::DOTNET, E::DotNet),
136 (Extension("fsproj"), labels::DOTNET_FSHARP, E::DotNet),
137 // A solution root whose projects live in subdirectories.
138 (Extension("sln"), labels::DOTNET, E::DotNet),
139 (Extension("slnx"), labels::DOTNET, E::DotNet),
140 // Express is the weakest signal of all: many projects use it beside a
141 // framework, a bundler, or another language's build that describes them
142 // better, so it comes after every other rule, including Python detection.
143 (
144 NodeRuntimeDependency(&["express"]),
145 labels::EXPRESS,
146 E::Node,
147 ),
148];
149
150/// Detect a stack label from configuration files in a project root.
151///
152/// The function scans only `project_root` and checks built-in rules in a fixed
153/// priority order. More specific frontend framework config files are evaluated
154/// before generic runtime markers such as `Cargo.toml` or `go.mod`. Node
155/// projects are also recognized from `package.json` dependencies: `next`
156/// (`Next.js` without a config file), `@nestjs/core` (`NestJS`, also from
157/// `nest-cli.json`), Remix packages such as `@remix-run/react` (`Remix`, even
158/// with `vite.config.ts`), and `express` under `"dependencies"` (`Express`,
159/// only when no other rule matches, so a repo with `package.json` and
160/// `Cargo.toml`, `go.mod`, or a `.csproj` keeps the label of that build).
161/// `react-router.config.ts` is `React Router`, and `svelte.config.js` is
162/// `SvelteKit` only when `package.json` lists `@sveltejs/kit` (plain Svelte
163/// with Vite is `Vite`). Python
164/// projects get a second pass that can identify `Django`, `Flask`, `FastAPI`,
165/// `Starlette`, and `Litestar` from entry files or dependency files. Ruby
166/// projects need `Gemfile` and `config.ru` (`Ruby (Rack)`), plus `bin/rails`
167/// for `Rails`. PHP projects with `artisan` next to `composer.json` are
168/// `Laravel`, and those with `symfony.lock` or `bin/console` are `Symfony`.
169/// Maven and Gradle builds that use `org.springframework.boot` are
170/// `Spring Boot`, and a `mix.exs` that depends on `:phoenix` is `Phoenix`; a
171/// multi-module Gradle root with only `settings.gradle` or
172/// `settings.gradle.kts` is a Gradle project.
173///
174/// This function knows nothing about the process, so it uses the same rules
175/// [`StackDetector`](crate::StackDetector) applies to an unknown process.
176/// Python entry files such as `server.py` alone do not make a Python project
177/// when `package.json` is present without a Python dependency file. The
178/// detector narrows the rules to the process's ecosystem: a `php` process in a
179/// Laravel project with `vite.config.js` is `Laravel`, while a `node` process
180/// there is `Vite`.
181///
182/// # Examples
183///
184/// ```no_run
185/// use std::path::Path;
186/// use what_stack::detect_from_config;
187///
188/// let label = detect_from_config(Path::new("/workspace/api"));
189/// println!("{label:?}");
190/// ```
191///
192/// # Performance
193///
194/// Directory entries are read once into a small in-memory set. Source and
195/// dependency files used for Python detection are capped to the first 64 KiB.
196/// Python frameworks come from entry files and from dependency manifests
197/// (`pyproject.toml`, `requirements.txt`, `requirements-dev.txt`, `Pipfile`,
198/// `setup.py`), ignoring `#` comment lines. Lock files (`uv.lock`,
199/// `poetry.lock`) list transitive dependencies, so they only confirm a
200/// framework a manifest names and never add one.
201#[must_use]
202pub fn detect_from_config(project_root: &Path) -> Option<StackLabel> {
203 detect_for_scope(project_root, ConfigScope::All)
204}
205
206/// Which config rules apply to a process.
207#[derive(Clone, Copy, Debug, PartialEq, Eq)]
208pub enum ConfigScope {
209 /// Unknown process: every rule, in the order of [`detect_from_config`].
210 All,
211 /// Unknown process whose executable lies inside the project: a compiled
212 /// project binary is far more likely than a Node or Python program, so
213 /// rules of compiled ecosystems (see [`E::is_compiled`]) are tried first,
214 /// then every rule in the usual order.
215 CompiledFirst,
216 /// Unknown process whose executable lies in the project's `node_modules`,
217 /// such as the native binary of esbuild, turbo, Biome, or SWC: a Node
218 /// build tool, so Node rules are tried first, then every rule in the usual
219 /// order.
220 NodeFirst,
221 /// A process of a known ecosystem, either from its name or because its
222 /// executable was built by that ecosystem's tools: only the rules the
223 /// ecosystem accepts (see [`E::accepts_config`]). A Python process gets
224 /// only framework labels (see [`python::detect_python_project`]).
225 Ecosystem(E),
226}
227
228/// Config detection limited to `scope`.
229pub fn detect_for_scope(project_root: &Path, scope: ConfigScope) -> Option<StackLabel> {
230 let files = ProjectFiles::read(project_root)?;
231
232 match scope {
233 ConfigScope::All => detect_with(project_root, &files, |_| true, false),
234 ConfigScope::CompiledFirst => detect_with(project_root, &files, E::is_compiled, false)
235 .or_else(|| detect_with(project_root, &files, |_| true, false)),
236 ConfigScope::NodeFirst => detect_with(project_root, &files, |rule| rule == E::Node, false)
237 .or_else(|| detect_with(project_root, &files, |_| true, false)),
238 ConfigScope::Ecosystem(process) => detect_with(
239 project_root,
240 &files,
241 |rule| process.accepts_config(rule),
242 process == E::Python,
243 ),
244 }
245}
246
247fn detect_with(
248 project_root: &Path,
249 files: &ProjectFiles,
250 in_scope: impl Fn(E) -> bool + Copy,
251 python_process: bool,
252) -> Option<StackLabel> {
253 detect_from_rules(project_root, files, CONFIG_RULES, in_scope)
254 .or_else(|| {
255 in_scope(E::Python)
256 .then(|| python::detect_python_project(project_root, files, python_process))
257 .flatten()
258 })
259 .or_else(|| detect_from_rules(project_root, files, LATE_CONFIG_RULES, in_scope))
260}
261
262fn detect_from_rules(
263 project_root: &Path,
264 files: &ProjectFiles,
265 rules: &[ConfigRule],
266 in_scope: impl Fn(E) -> bool,
267) -> Option<StackLabel> {
268 rules
269 .iter()
270 .filter(|(_, _, ecosystem)| in_scope(*ecosystem))
271 .find(|(matcher, _, _)| rule_matches(project_root, files, *matcher))
272 .map(|(_, label, _)| label.clone())
273}
274
275fn rule_matches(project_root: &Path, files: &ProjectFiles, matcher: ConfigMatch) -> bool {
276 match matcher {
277 Exact(name) => files.contains_exact(name),
278 Prefix(prefix) => files.contains_prefix(prefix),
279 AllOf(paths) => paths
280 .iter()
281 .all(|path| files.contains_path(project_root, path)),
282 Extension(extension) => files.contains_extension(extension),
283 NodeDependency(packages) => files.node_dependencies(project_root).has_any(packages),
284 NodeRuntimeDependency(packages) => {
285 files.node_dependencies(project_root).has_runtime(packages)
286 }
287 PrefixWithNodeDependency(prefix, packages) => {
288 files.contains_prefix(prefix) && files.node_dependencies(project_root).has_any(packages)
289 }
290 FileToken(file_name, token) => files.mentions_token(project_root, file_name, token),
291 }
292}