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 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 we don't have a URL and we don't have a valid
97            // GitHub repository name, then we assume this is the alias.
98            //
99            // This is to allow for conveniently removing aliased dependencies
100            // using `forge remove <alias>`
101            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        // everything after the last "@" should be considered the version if there are no path
110        // segments
111        let (url, name, tag) = if let Some(url_with_version) = url_with_version {
112            // `@`s are actually valid github project name chars but we assume this is unlikely and
113            // treat everything after the last `@` as the version tag there's still the
114            // case that the user tries to use `@<org>/<project>`, so we need to check that the
115            // `tag` does not contain a slash
116            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    /// Returns the name of the dependency, prioritizing the alias if it exists.
148    pub fn name(&self) -> &str {
149        self.alias.as_deref().unwrap_or(self.name.as_str())
150    }
151
152    /// Returns the URL of the dependency if it exists, or an error if not.
153    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    // <https://github.com/foundry-rs/foundry/pull/3130>
340    #[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        // <https://github.com/foundry-rs/foundry/issues/9717>
404        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}