foundry_cli/opts/
dependency.rs

1//! CLI dependency parsing
2
3use 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
9/// Git repo prefix regex
10pub 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
22/// Commonly used aliases for solidity repos,
23///
24/// These will be autocorrected when used in place of the `org`
25const COMMON_ORG_ALIASES: &[(&str, &str); 2] =
26    &[("@openzeppelin", "openzeppelin"), ("@aave", "aave")];
27
28/// A git dependency which will be installed as a submodule
29///
30/// A dependency can be provided as a raw URL, or as a path to a Github repository
31/// e.g. `org-name/repo-name`
32///
33/// Providing a ref can be done in the following 3 ways:
34/// * branch: master
35/// * tag: v0.1.1
36/// * commit: 8e8128
37///
38/// Non Github URLs must be provided with an https:// prefix.
39/// Adding dependencies as local paths is not supported yet.
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct Dependency {
42    /// The name of the dependency
43    pub name: String,
44    /// The url to the git repository corresponding to the dependency
45    pub url: Option<String>,
46    /// Optional tag corresponding to a Git SHA, tag, or branch.
47    pub tag: Option<String>,
48    /// Optional alias of the dependency
49    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        // Handle dependency exact ref type (`@tag=`, `@branch=` or `@rev=`)`.
56        // Only extract version for first tag/branch/commit specified.
57        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        // everything before "=" should be considered the alias
62        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            // this will autocorrect wrong conventional aliases for tag, but only autocorrect if
67            // it's not used as alias
68            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 we don't have a URL and we don't have a valid
95            // GitHub repository name, then we assume this is the alias.
96            //
97            // This is to allow for conveniently removing aliased dependencies
98            // using `forge remove <alias>`
99            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        // everything after the last "@" should be considered the version if there are no path
108        // segments
109        let (url, name, tag) = if let Some(url_with_version) = url_with_version {
110            // `@`s are actually valid github project name chars but we assume this is unlikely and
111            // treat everything after the last `@` as the version tag there's still the
112            // case that the user tries to use `@<org>/<project>`, so we need to check that the
113            // `tag` does not contain a slash
114            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    /// Returns the name of the dependency, prioritizing the alias if it exists.
146    pub fn name(&self) -> &str {
147        self.alias.as_deref().unwrap_or(self.name.as_str())
148    }
149
150    /// Returns the URL of the dependency if it exists, or an error if not.
151    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    // <https://github.com/foundry-rs/foundry/pull/3130>
343    #[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        // <https://github.com/foundry-rs/foundry/issues/9717>
407        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}