1use eyre::Result;
4use regex::Regex;
5use std::{str::FromStr, sync::LazyLock};
6
7static GH_REPO_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[\w-]+/[\w.-]+").unwrap());
8
9pub static GH_REPO_PREFIX_REGEX: LazyLock<Regex> = LazyLock::new(|| {
11 Regex::new(r"((git@)|(git\+https://)|(https://)|https://(?P<token>[^@]+)@|(org-([A-Za-z0-9-])+@))?(?P<brand>[A-Za-z0-9-]+)\.(?P<tld>[A-Za-z0-9-]+)(/|:)")
12 .unwrap()
13});
14
15static VERSION_PREFIX_REGEX: LazyLock<Regex> =
16 LazyLock::new(|| Regex::new(r#"@(tag|branch|rev)="#).unwrap());
17
18const GITHUB: &str = "github.com";
19const VERSION_SEPARATOR: char = '@';
20const ALIAS_SEPARATOR: char = '=';
21
22const COMMON_ORG_ALIASES: &[(&str, &str); 2] =
26 &[("@openzeppelin", "openzeppelin"), ("@aave", "aave")];
27
28#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct Dependency {
42 pub name: String,
44 pub url: Option<String>,
46 pub tag: Option<String>,
48 pub alias: Option<String>,
50 pub ref_kind: Option<DependencyRefKind>,
52}
53
54impl FromStr for Dependency {
55 type Err = eyre::Error;
56 fn from_str(dependency: &str) -> Result<Self, Self::Err> {
57 let ref_kind =
60 VERSION_PREFIX_REGEX.captures(dependency).map(|captures| match &captures[1] {
61 "tag" => DependencyRefKind::Tag,
62 "branch" => DependencyRefKind::Branch,
63 "rev" => DependencyRefKind::Rev,
64 _ => unreachable!(),
65 });
66 let url_and_version: Vec<&str> = VERSION_PREFIX_REGEX.split(dependency).collect();
67 let dependency = url_and_version[0];
68 let mut tag_or_branch = url_and_version.get(1).map(|version| version.to_string());
69
70 let (mut alias, dependency) = if let Some(split) = dependency.split_once(ALIAS_SEPARATOR) {
72 (Some(String::from(split.0)), split.1.to_string())
73 } else {
74 let mut dependency = dependency.to_string();
75 for (alias, real_org) in COMMON_ORG_ALIASES {
78 if dependency.starts_with(alias) {
79 dependency = dependency.replacen(alias, real_org, 1);
80 break;
81 }
82 }
83
84 (None, dependency)
85 };
86
87 let dependency = dependency.as_str();
88
89 let url_with_version = if let Some(captures) = GH_REPO_PREFIX_REGEX.captures(dependency) {
90 let brand = captures.name("brand").unwrap().as_str();
91 let tld = captures.name("tld").unwrap().as_str();
92 let project = GH_REPO_PREFIX_REGEX.replace(dependency, "");
93 if let Some(token) = captures.name("token") {
94 Some(format!(
95 "https://{}@{brand}.{tld}/{}",
96 token.as_str(),
97 project.trim_end_matches(".git")
98 ))
99 } else if dependency.starts_with("git@") {
100 Some(format!("git@{brand}.{tld}:{}", project.trim_end_matches(".git")))
101 } else {
102 Some(format!("https://{brand}.{tld}/{}", project.trim_end_matches(".git")))
103 }
104 } else {
105 if GH_REPO_REGEX.is_match(dependency) {
111 Some(format!("https://{GITHUB}/{dependency}"))
112 } else {
113 alias = Some(dependency.to_string());
114 None
115 }
116 };
117
118 let (url, name, tag) = if let Some(url_with_version) = url_with_version {
121 let mut split = url_with_version.rsplit(VERSION_SEPARATOR);
126
127 let mut url = url_with_version.as_str();
128
129 if tag_or_branch.is_none() {
130 let maybe_tag_or_branch = split.next().unwrap();
131 if let Some(actual_url) = split.next()
132 && !maybe_tag_or_branch.contains('/')
133 {
134 tag_or_branch = Some(maybe_tag_or_branch.to_string());
135 url = actual_url;
136 }
137 }
138
139 let url = url.to_string();
140 let name = url
141 .split('/')
142 .next_back()
143 .ok_or_else(|| eyre::eyre!("no dependency name found"))?
144 .to_string();
145
146 (Some(url), Some(name), tag_or_branch)
147 } else {
148 (None, None, None)
149 };
150
151 Ok(Self { name: name.or_else(|| alias.clone()).unwrap(), url, tag, alias, ref_kind })
152 }
153}
154
155impl Dependency {
156 pub fn name(&self) -> &str {
158 self.alias.as_deref().unwrap_or(self.name.as_str())
159 }
160
161 pub fn require_url(&self) -> Result<&str> {
163 self.url.as_deref().ok_or_else(|| eyre::eyre!("dependency {} has no url", self.name()))
164 }
165}
166
167#[derive(Clone, Copy, Debug, PartialEq, Eq)]
169pub enum DependencyRefKind {
170 Tag,
172 Branch,
174 Rev,
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use foundry_compilers::info::ContractInfo;
182
183 #[test]
184 fn parses_dependencies() {
185 for (input, expected_path, expected_tag, expected_alias) in [
186 ("gakonst/lootloose", "https://github.com/gakonst/lootloose", None, None),
187 ("github.com/gakonst/lootloose", "https://github.com/gakonst/lootloose", None, None),
188 (
189 "https://github.com/gakonst/lootloose",
190 "https://github.com/gakonst/lootloose",
191 None,
192 None,
193 ),
194 (
195 "git+https://github.com/gakonst/lootloose",
196 "https://github.com/gakonst/lootloose",
197 None,
198 None,
199 ),
200 (
201 "git@github.com:gakonst/lootloose@tag=v1",
202 "git@github.com:gakonst/lootloose",
203 Some("v1"),
204 None,
205 ),
206 ("git@github.com:gakonst/lootloose", "git@github.com:gakonst/lootloose", None, None),
207 (
208 "https://gitlab.com/gakonst/lootloose",
209 "https://gitlab.com/gakonst/lootloose",
210 None,
211 None,
212 ),
213 (
214 "https://github.xyz/gakonst/lootloose",
215 "https://github.xyz/gakonst/lootloose",
216 None,
217 None,
218 ),
219 (
220 "gakonst/lootloose@0.1.0",
221 "https://github.com/gakonst/lootloose",
222 Some("0.1.0"),
223 None,
224 ),
225 (
226 "gakonst/lootloose@develop",
227 "https://github.com/gakonst/lootloose",
228 Some("develop"),
229 None,
230 ),
231 (
232 "gakonst/lootloose@98369d0edc900c71d0ec33a01dfba1d92111deed",
233 "https://github.com/gakonst/lootloose",
234 Some("98369d0edc900c71d0ec33a01dfba1d92111deed"),
235 None,
236 ),
237 ("loot=gakonst/lootloose", "https://github.com/gakonst/lootloose", None, Some("loot")),
238 (
239 "loot=github.com/gakonst/lootloose",
240 "https://github.com/gakonst/lootloose",
241 None,
242 Some("loot"),
243 ),
244 (
245 "loot=https://github.com/gakonst/lootloose",
246 "https://github.com/gakonst/lootloose",
247 None,
248 Some("loot"),
249 ),
250 (
251 "loot=git+https://github.com/gakonst/lootloose",
252 "https://github.com/gakonst/lootloose",
253 None,
254 Some("loot"),
255 ),
256 (
257 "loot=git@github.com:gakonst/lootloose@tag=v1",
258 "git@github.com:gakonst/lootloose",
259 Some("v1"),
260 Some("loot"),
261 ),
262 ] {
263 let dep = Dependency::from_str(input).unwrap();
264 assert_eq!(dep.url, Some(expected_path.to_string()));
265 assert_eq!(dep.tag, expected_tag.map(ToString::to_string));
266 assert_eq!(dep.name, "lootloose");
267 assert_eq!(dep.alias, expected_alias.map(ToString::to_string));
268 }
269 }
270
271 #[test]
272 fn can_parse_alias_only() {
273 let dep = Dependency::from_str("foo").unwrap();
274 assert_eq!(dep.name, "foo");
275 assert_eq!(dep.url, None);
276 assert_eq!(dep.tag, None);
277 assert_eq!(dep.alias, Some("foo".to_string()));
278 }
279
280 #[test]
281 fn test_invalid_github_repo_dependency() {
282 let dep = Dependency::from_str("solmate").unwrap();
283 assert_eq!(dep.url, None);
284 }
285
286 #[test]
287 fn parses_contract_info() {
288 for (input, expected_path, expected_name) in [
289 (
290 "src/contracts/Contracts.sol:Contract",
291 Some("src/contracts/Contracts.sol"),
292 "Contract",
293 ),
294 ("Contract", None, "Contract"),
295 ] {
296 let contract = ContractInfo::from_str(input).unwrap();
297 assert_eq!(contract.path, expected_path.map(ToString::to_string));
298 assert_eq!(contract.name, expected_name.to_string());
299 }
300 }
301
302 #[test]
303 fn contract_info_should_reject_without_name() {
304 for input in ["src/contracts/", "src/contracts/Contracts.sol"] {
305 let contract = ContractInfo::from_str(input);
306 assert!(contract.is_err());
307 }
308 }
309
310 #[test]
311 fn can_parse_oz_dep() {
312 let dep = Dependency::from_str("@openzeppelin/contracts-upgradeable").unwrap();
313 assert_eq!(dep.name, "contracts-upgradeable");
314 assert_eq!(
315 dep.url,
316 Some("https://github.com/openzeppelin/contracts-upgradeable".to_string())
317 );
318 assert_eq!(dep.tag, None);
319 assert_eq!(dep.alias, None);
320 }
321
322 #[test]
323 fn can_parse_oz_dep_tag() {
324 let dep = Dependency::from_str("@openzeppelin/contracts-upgradeable@v1").unwrap();
325 assert_eq!(dep.name, "contracts-upgradeable");
326 assert_eq!(
327 dep.url,
328 Some("https://github.com/openzeppelin/contracts-upgradeable".to_string())
329 );
330 assert_eq!(dep.tag, Some("v1".to_string()));
331 assert_eq!(dep.alias, None);
332 }
333
334 #[test]
335 fn can_parse_oz_with_tag() {
336 let dep = Dependency::from_str("OpenZeppelin/openzeppelin-contracts@v4.7.0").unwrap();
337 assert_eq!(dep.name, "openzeppelin-contracts");
338 assert_eq!(
339 dep.url,
340 Some("https://github.com/OpenZeppelin/openzeppelin-contracts".to_string())
341 );
342 assert_eq!(dep.tag, Some("v4.7.0".to_string()));
343 assert_eq!(dep.alias, None);
344
345 let dep = Dependency::from_str("OpenZeppelin/openzeppelin-contracts@4.7.0").unwrap();
346 assert_eq!(dep.name, "openzeppelin-contracts");
347 assert_eq!(
348 dep.url,
349 Some("https://github.com/OpenZeppelin/openzeppelin-contracts".to_string())
350 );
351 assert_eq!(dep.tag, Some("4.7.0".to_string()));
352 assert_eq!(dep.alias, None);
353 }
354
355 #[test]
357 fn can_parse_oz_with_alias() {
358 let dep =
359 Dependency::from_str("@openzeppelin=OpenZeppelin/openzeppelin-contracts").unwrap();
360 assert_eq!(dep.name, "openzeppelin-contracts");
361 assert_eq!(dep.alias, Some("@openzeppelin".to_string()));
362 assert_eq!(
363 dep.url,
364 Some("https://github.com/OpenZeppelin/openzeppelin-contracts".to_string())
365 );
366 }
367
368 #[test]
369 fn can_parse_aave() {
370 let dep = Dependency::from_str("@aave/aave-v3-core").unwrap();
371 assert_eq!(dep.name, "aave-v3-core");
372 assert_eq!(dep.url, Some("https://github.com/aave/aave-v3-core".to_string()));
373 }
374
375 #[test]
376 fn can_parse_aave_with_alias() {
377 let dep = Dependency::from_str("@aave=aave/aave-v3-core").unwrap();
378 assert_eq!(dep.name, "aave-v3-core");
379 assert_eq!(dep.alias, Some("@aave".to_string()));
380 assert_eq!(dep.url, Some("https://github.com/aave/aave-v3-core".to_string()));
381 }
382
383 #[test]
384 fn can_parse_org_ssh_url() {
385 let org_url = "org-git12345678@github.com:my-org/my-repo.git";
386 assert!(GH_REPO_PREFIX_REGEX.is_match(org_url));
387 }
388
389 #[test]
390 fn can_parse_org_shh_url_dependency() {
391 let dep: Dependency = "org-git12345678@github.com:my-org/my-repo.git".parse().unwrap();
392 assert_eq!(dep.url.unwrap(), "https://github.com/my-org/my-repo");
393 }
394
395 #[test]
396 fn can_parse_with_explicit_ref_type() {
397 let dep = Dependency::from_str("smartcontractkit/ccip@tag=contracts-ccip/v1.2.1").unwrap();
398 assert_eq!(dep.name, "ccip");
399 assert_eq!(dep.url, Some("https://github.com/smartcontractkit/ccip".to_string()));
400 assert_eq!(dep.tag, Some("contracts-ccip/v1.2.1".to_string()));
401 assert_eq!(dep.alias, None);
402 assert_eq!(dep.ref_kind, Some(DependencyRefKind::Tag));
403
404 let dep =
405 Dependency::from_str("smartcontractkit/ccip@branch=contracts-ccip/v1.2.1").unwrap();
406 assert_eq!(dep.name, "ccip");
407 assert_eq!(dep.url, Some("https://github.com/smartcontractkit/ccip".to_string()));
408 assert_eq!(dep.tag, Some("contracts-ccip/v1.2.1".to_string()));
409 assert_eq!(dep.alias, None);
410 assert_eq!(dep.ref_kind, Some(DependencyRefKind::Branch));
411
412 let dep = Dependency::from_str("smartcontractkit/ccip@rev=80eb41b").unwrap();
413 assert_eq!(dep.name, "ccip");
414 assert_eq!(dep.url, Some("https://github.com/smartcontractkit/ccip".to_string()));
415 assert_eq!(dep.tag, Some("80eb41b".to_string()));
416 assert_eq!(dep.alias, None);
417 assert_eq!(dep.ref_kind, Some(DependencyRefKind::Rev));
418 }
419
420 #[test]
421 fn can_parse_https_with_github_token() {
422 let dep = Dependency::from_str(
424 "https://ghp_mytoken@github.com/private-org/precompiles-solidity.git",
425 )
426 .unwrap();
427 assert_eq!(dep.name, "precompiles-solidity");
428 assert_eq!(
429 dep.url,
430 Some("https://ghp_mytoken@github.com/private-org/precompiles-solidity".to_string())
431 );
432 }
433}