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