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