| 1 | //! Which dependencies a project names itself, from its manifests: what |
| 2 | //! version updates keep current by default (`allow`'s `direct`), and |
| 3 | //! whether each is for production or development. |
| 4 | |
| 5 | use serde_json::Value; |
| 6 | |
| 7 | /// How a project depends on a package. |
| 8 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] |
| 9 | pub enum DependencyType { |
| 10 | /// Named in a manifest, needed to run. |
| 11 | Production, |
| 12 | /// Named in a manifest, needed only to build or test. |
| 13 | Development, |
| 14 | /// Not named: something else depends on it. |
| 15 | Indirect, |
| 16 | } |
| 17 | |
| 18 | impl DependencyType { |
| 19 | /// As update pull requests' commit messages put it. |
| 20 | pub fn label(self) -> &'static str { |
| 21 | match self { |
| 22 | DependencyType::Production => "direct:production", |
| 23 | DependencyType::Development => "direct:development", |
| 24 | DependencyType::Indirect => "indirect", |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | pub fn direct(self) -> bool { |
| 29 | self != DependencyType::Indirect |
| 30 | } |
| 31 | |
| 32 | /// Whether `allow`'s or a group's `dependency-type` covers it. |
| 33 | pub fn is(self, kind: &str) -> bool { |
| 34 | match kind { |
| 35 | "all" => true, |
| 36 | "direct" => self.direct(), |
| 37 | "indirect" => self == DependencyType::Indirect, |
| 38 | "production" => self == DependencyType::Production, |
| 39 | "development" => self == DependencyType::Development, |
| 40 | _ => false, |
| 41 | } |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | /// A dependency a manifest names. |
| 46 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 47 | pub struct Declared { |
| 48 | pub name: String, |
| 49 | /// The requirement as written: `^1.2.0`, `1.2`, `>=2,<3`; none when it |
| 50 | /// names no version. |
| 51 | pub requirement: Option<String>, |
| 52 | pub kind: DependencyType, |
| 53 | } |
| 54 | |
| 55 | /// The manifest files each supported ecosystem keeps in a directory. |
| 56 | pub fn manifest_names(ecosystem: &str) -> &'static [&'static str] { |
| 57 | match ecosystem { |
| 58 | "npm" => &["package.json"], |
| 59 | "cargo" => &["Cargo.toml"], |
| 60 | "gomod" => &["go.mod"], |
| 61 | "pip" => &["requirements.txt", "pyproject.toml"], |
| 62 | _ => &[], |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | fn push(found: &mut Vec<Declared>, name: &str, requirement: Option<String>, kind: DependencyType) { |
| 67 | if name.is_empty() { |
| 68 | return; |
| 69 | } |
| 70 | match found.iter_mut().find(|known| known.name == name) { |
| 71 | // Production wins over development when a package is both. |
| 72 | Some(known) if kind < known.kind => { |
| 73 | known.kind = kind; |
| 74 | known.requirement = requirement.or(known.requirement.take()); |
| 75 | } |
| 76 | Some(_) => {} |
| 77 | None => found.push(Declared { name: name.to_owned(), requirement, kind }), |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | /// `package.json`: `dependencies` and `optionalDependencies` are for |
| 82 | /// production, `devDependencies` for development. Packages from anywhere |
| 83 | /// but the registry (a path, a workspace, git, an alias) are left out. |
| 84 | pub fn package_json(text: &str) -> Vec<Declared> { |
| 85 | let Ok(manifest) = serde_json::from_str::<Value>(text) else { return Vec::new() }; |
| 86 | let mut found = Vec::new(); |
| 87 | for (section, kind) in [ |
| 88 | ("dependencies", DependencyType::Production), |
| 89 | ("optionalDependencies", DependencyType::Production), |
| 90 | ("devDependencies", DependencyType::Development), |
| 91 | ] { |
| 92 | let Some(entries) = manifest.get(section).and_then(Value::as_object) else { continue }; |
| 93 | for (name, range) in entries { |
| 94 | let Some(range) = range.as_str() else { continue }; |
| 95 | if range.contains(':') || range.contains('/') { |
| 96 | continue; |
| 97 | } |
| 98 | push(&mut found, name, Some(range.to_owned()), kind); |
| 99 | } |
| 100 | } |
| 101 | found |
| 102 | } |
| 103 | |
| 104 | /// `Cargo.toml`: `[dependencies]` and `[build-dependencies]` are for |
| 105 | /// production, `[dev-dependencies]` for development, in a workspace's |
| 106 | /// `[workspace.dependencies]` and per target too. A crate renamed with |
| 107 | /// `package = "…"` is listed by its real name; one from a path or git with |
| 108 | /// no version is left out. |
| 109 | pub fn cargo_toml(text: &str) -> Vec<Declared> { |
| 110 | let Ok(manifest) = toml::from_str::<toml::Value>(text) else { return Vec::new() }; |
| 111 | let mut found = Vec::new(); |
| 112 | let mut read = |table: Option<&toml::Value>, kind: DependencyType| { |
| 113 | let Some(table) = table.and_then(toml::Value::as_table) else { return }; |
| 114 | for (key, spec) in table { |
| 115 | let (name, requirement) = match spec { |
| 116 | toml::Value::String(requirement) => (key.clone(), Some(requirement.clone())), |
| 117 | toml::Value::Table(fields) => { |
| 118 | if fields.get("workspace").and_then(toml::Value::as_bool) == Some(true) { |
| 119 | continue; |
| 120 | } |
| 121 | let Some(version) = fields.get("version").and_then(toml::Value::as_str) else { continue }; |
| 122 | let name = fields.get("package").and_then(toml::Value::as_str).unwrap_or(key); |
| 123 | (name.to_owned(), Some(version.to_owned())) |
| 124 | } |
| 125 | _ => continue, |
| 126 | }; |
| 127 | push(&mut found, &name, requirement, kind); |
| 128 | } |
| 129 | }; |
| 130 | let sections = [ |
| 131 | ("dependencies", DependencyType::Production), |
| 132 | ("build-dependencies", DependencyType::Production), |
| 133 | ("dev-dependencies", DependencyType::Development), |
| 134 | ]; |
| 135 | for (section, kind) in sections { |
| 136 | read(manifest.get(section), kind); |
| 137 | read(manifest.get("workspace").and_then(|workspace| workspace.get(section)), kind); |
| 138 | } |
| 139 | if let Some(targets) = manifest.get("target").and_then(toml::Value::as_table) { |
| 140 | for target in targets.values() { |
| 141 | for (section, kind) in sections { |
| 142 | read(target.get(section), kind); |
| 143 | } |
| 144 | } |
| 145 | } |
| 146 | found |
| 147 | } |
| 148 | |
| 149 | /// `go.mod`'s `require`s: those marked `// indirect` are indirect, the |
| 150 | /// rest the module's own. |
| 151 | pub fn go_mod(text: &str) -> Vec<Declared> { |
| 152 | let mut found = Vec::new(); |
| 153 | let mut block = false; |
| 154 | for raw in text.lines() { |
| 155 | let (code, comment) = raw.split_once("//").unwrap_or((raw, "")); |
| 156 | let line = code.trim(); |
| 157 | if block { |
| 158 | if line == ")" { |
| 159 | block = false; |
| 160 | continue; |
| 161 | } |
| 162 | } else if line.starts_with("require (") || line == "require(" { |
| 163 | block = true; |
| 164 | continue; |
| 165 | } |
| 166 | let line = if block { line } else if let Some(rest) = line.strip_prefix("require ") { rest.trim() } else { continue }; |
| 167 | let mut words = line.split_whitespace(); |
| 168 | if let (Some(module), Some(version)) = (words.next(), words.next()) { |
| 169 | let kind = if comment.trim() == "indirect" { DependencyType::Indirect } else { DependencyType::Production }; |
| 170 | found.push(Declared { name: module.to_owned(), requirement: Some(version.to_owned()), kind }); |
| 171 | } |
| 172 | } |
| 173 | found |
| 174 | } |
| 175 | |
| 176 | /// A Python package name as PyPI compares it. |
| 177 | pub fn python_name(name: &str) -> String { |
| 178 | name.trim().to_lowercase().replace(['_', '.'], "-") |
| 179 | } |
| 180 | |
| 181 | /// One requirement line: `requests[security]>=2,<3 ; python_version > "3"`. |
| 182 | fn python_requirement(line: &str) -> Option<(String, Option<String>)> { |
| 183 | let line = line.split(';').next()?.trim(); |
| 184 | let end = line.find(|c: char| !(c.is_ascii_alphanumeric() || "-_.".contains(c))).unwrap_or(line.len()); |
| 185 | let name = &line[..end]; |
| 186 | if name.is_empty() { |
| 187 | return None; |
| 188 | } |
| 189 | let rest = line[end..].trim(); |
| 190 | let rest = match rest.strip_prefix('[') { |
| 191 | Some(extras) => extras.split_once(']').map_or("", |(_, after)| after).trim(), |
| 192 | None => rest, |
| 193 | }; |
| 194 | Some((python_name(name), (!rest.is_empty()).then(|| rest.replace(' ', "")))) |
| 195 | } |
| 196 | |
| 197 | /// `requirements.txt`: every package it names is the project's own. |
| 198 | pub fn requirements_txt(text: &str) -> Vec<Declared> { |
| 199 | let mut found = Vec::new(); |
| 200 | for line in text.lines() { |
| 201 | let line = line.split(" #").next().unwrap_or_default().trim(); |
| 202 | if line.is_empty() || line.starts_with('#') || line.starts_with('-') || line.contains("://") { |
| 203 | continue; |
| 204 | } |
| 205 | if let Some((name, requirement)) = python_requirement(line) { |
| 206 | push(&mut found, &name, requirement, DependencyType::Production); |
| 207 | } |
| 208 | } |
| 209 | found |
| 210 | } |
| 211 | |
| 212 | /// `pyproject.toml`: `[project]`'s dependencies and Poetry's main group are |
| 213 | /// for production; Poetry's other groups and `dev-dependencies` for |
| 214 | /// development. `python` itself is left out. |
| 215 | pub fn pyproject_toml(text: &str) -> Vec<Declared> { |
| 216 | let Ok(manifest) = toml::from_str::<toml::Value>(text) else { return Vec::new() }; |
| 217 | let mut found = Vec::new(); |
| 218 | let project = manifest.get("project"); |
| 219 | for requirement in project.and_then(|p| p.get("dependencies")).and_then(toml::Value::as_array).into_iter().flatten() { |
| 220 | if let Some((name, spec)) = requirement.as_str().and_then(python_requirement) { |
| 221 | push(&mut found, &name, spec, DependencyType::Production); |
| 222 | } |
| 223 | } |
| 224 | let poetry = manifest.get("tool").and_then(|tool| tool.get("poetry")); |
| 225 | let mut table = |table: Option<&toml::Value>, kind: DependencyType| { |
| 226 | for (name, spec) in table.and_then(toml::Value::as_table).into_iter().flatten() { |
| 227 | if name == "python" { |
| 228 | continue; |
| 229 | } |
| 230 | let requirement = match spec { |
| 231 | toml::Value::String(text) => Some(text.clone()), |
| 232 | toml::Value::Table(fields) => match fields.get("version").and_then(toml::Value::as_str) { |
| 233 | Some(version) => Some(version.to_owned()), |
| 234 | None => continue, |
| 235 | }, |
| 236 | _ => continue, |
| 237 | }; |
| 238 | push(&mut found, &python_name(name), requirement, kind); |
| 239 | } |
| 240 | }; |
| 241 | table(poetry.and_then(|p| p.get("dependencies")), DependencyType::Production); |
| 242 | table(poetry.and_then(|p| p.get("dev-dependencies")), DependencyType::Development); |
| 243 | if let Some(groups) = poetry.and_then(|p| p.get("group")).and_then(toml::Value::as_table) { |
| 244 | for group in groups.values() { |
| 245 | table(group.get("dependencies"), DependencyType::Development); |
| 246 | } |
| 247 | } |
| 248 | found |
| 249 | } |
| 250 | |
| 251 | /// The dependencies one manifest file names, by its file name. |
| 252 | pub fn declared(file_name: &str, text: &str) -> Vec<Declared> { |
| 253 | match file_name { |
| 254 | "package.json" => package_json(text), |
| 255 | "Cargo.toml" => cargo_toml(text), |
| 256 | "go.mod" => go_mod(text), |
| 257 | "requirements.txt" => requirements_txt(text), |
| 258 | "pyproject.toml" => pyproject_toml(text), |
| 259 | _ => Vec::new(), |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | #[cfg(test)] |
| 264 | mod tests { |
| 265 | use super::*; |
| 266 | |
| 267 | fn names(found: &[Declared]) -> Vec<String> { |
| 268 | found |
| 269 | .iter() |
| 270 | .map(|d| format!("{}@{}:{}", d.name, d.requirement.as_deref().unwrap_or("-"), d.kind.label())) |
| 271 | .collect() |
| 272 | } |
| 273 | |
| 274 | #[test] |
| 275 | fn package_json_by_section() { |
| 276 | let text = r#"{"dependencies":{"react":"^18.2.0","local":"file:../x","alias":"npm:react@18","ws":"workspace:*"}, |
| 277 | "devDependencies":{"vitest":"~1.0.0","react":"^18.2.0"},"optionalDependencies":{"fsevents":"2.3.3"}, |
| 278 | "peerDependencies":{"react-dom":"*"}}"#; |
| 279 | assert_eq!( |
| 280 | names(&package_json(text)), |
| 281 | ["react@^18.2.0:direct:production", "fsevents@2.3.3:direct:production", "vitest@~1.0.0:direct:development"] |
| 282 | ); |
| 283 | } |
| 284 | |
| 285 | #[test] |
| 286 | fn cargo_toml_sections_targets_and_workspaces() { |
| 287 | let text = r#" |
| 288 | [workspace] |
| 289 | members = ["crates/*", "app"] |
| 290 | |
| 291 | [workspace.dependencies] |
| 292 | serde = { version = "1", features = ["derive"] } |
| 293 | |
| 294 | [dependencies] |
| 295 | anyhow = "1.0" |
| 296 | local = { path = "../local" } |
| 297 | shared = { workspace = true } |
| 298 | renamed = { package = "real-name", version = "0.3" } |
| 299 | |
| 300 | [dev-dependencies] |
| 301 | proptest = "1" |
| 302 | |
| 303 | [target.'cfg(unix)'.dependencies] |
| 304 | libc = "0.2" |
| 305 | "#; |
| 306 | assert_eq!( |
| 307 | names(&cargo_toml(text)), |
| 308 | [ |
| 309 | "anyhow@1.0:direct:production", |
| 310 | "real-name@0.3:direct:production", |
| 311 | "serde@1:direct:production", |
| 312 | "proptest@1:direct:development", |
| 313 | "libc@0.2:direct:production" |
| 314 | ] |
| 315 | ); |
| 316 | } |
| 317 | |
| 318 | #[test] |
| 319 | fn go_mod_marks_indirect() { |
| 320 | let text = "module x\n\nrequire golang.org/x/text v0.3.0\n\nrequire (\n\tgithub.com/a/b v1.6.0 // indirect\n\tgolang.org/x/net v0.7.0\n)\n"; |
| 321 | assert_eq!( |
| 322 | names(&go_mod(text)), |
| 323 | ["golang.org/x/text@v0.3.0:direct:production", "github.com/a/b@v1.6.0:indirect", "golang.org/x/net@v0.7.0:direct:production"] |
| 324 | ); |
| 325 | } |
| 326 | |
| 327 | #[test] |
| 328 | fn python_manifests() { |
| 329 | let requirements = "# web\nDjango==3.2.0\nrequests[security] >= 2.19, < 3 ; python_version >= '3'\nflask\n-r base.txt\nhttps://x/y.whl\n"; |
| 330 | assert_eq!( |
| 331 | names(&requirements_txt(requirements)), |
| 332 | ["django@==3.2.0:direct:production", "requests@>=2.19,<3:direct:production", "flask@-:direct:production"] |
| 333 | ); |
| 334 | let pyproject = r#" |
| 335 | [project] |
| 336 | dependencies = ["Jinja2>=3.0", "Flask_Cors"] |
| 337 | |
| 338 | [tool.poetry.dependencies] |
| 339 | python = "^3.11" |
| 340 | requests = "^2.0" |
| 341 | pinned = { version = "1.2.3", optional = true } |
| 342 | fromgit = { git = "https://x" } |
| 343 | |
| 344 | [tool.poetry.group.test.dependencies] |
| 345 | pytest = "^7" |
| 346 | "#; |
| 347 | assert_eq!( |
| 348 | names(&pyproject_toml(pyproject)), |
| 349 | [ |
| 350 | "jinja2@>=3.0:direct:production", |
| 351 | "flask-cors@-:direct:production", |
| 352 | "pinned@1.2.3:direct:production", |
| 353 | "requests@^2.0:direct:production", |
| 354 | "pytest@^7:direct:development" |
| 355 | ] |
| 356 | ); |
| 357 | } |
| 358 | |
| 359 | #[test] |
| 360 | fn dependency_types_as_rules_name_them() { |
| 361 | assert!(DependencyType::Production.is("direct") && DependencyType::Production.is("production") && DependencyType::Production.is("all")); |
| 362 | assert!(!DependencyType::Development.is("production") && DependencyType::Development.is("development")); |
| 363 | assert!(DependencyType::Indirect.is("indirect") && !DependencyType::Indirect.is("direct")); |
| 364 | assert_eq!(manifest_names("pip"), ["requirements.txt", "pyproject.toml"]); |
| 365 | } |
| 366 | } |