Skip to main content

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}