Skip to main content

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    /// The explicitly requested ref kind, if any.
51    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        // Handle dependency exact ref type (`@tag=`, `@branch=` or `@rev=`)`.
58        // Only extract version for first tag/branch/commit specified.
59        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        // everything before "=" should be considered the alias
71        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            // this will autocorrect wrong conventional aliases for tag, but only autocorrect if
76            // it's not used as alias
77            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 we don't have a URL and we don't have a valid
106            // GitHub repository name, then we assume this is the alias.
107            //
108            // This is to allow for conveniently removing aliased dependencies
109            // using `forge remove <alias>`
110            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        // everything after the last "@" should be considered the version if there are no path
119        // segments
120        let (url, name, tag) = if let Some(url_with_version) = url_with_version {
121            // `@`s are actually valid github project name chars but we assume this is unlikely and
122            // treat everything after the last `@` as the version tag there's still the
123            // case that the user tries to use `@<org>/<project>`, so we need to check that the
124            // `tag` does not contain a slash
125            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    /// Returns the name of the dependency, prioritizing the alias if it exists.
157    pub fn name(&self) -> &str {
158        self.alias.as_deref().unwrap_or(self.name.as_str())
159    }
160
161    /// Returns the URL of the dependency if it exists, or an error if not.
162    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/// An explicitly requested Git ref kind.
168#[derive(Clone, Copy, Debug, PartialEq, Eq)]
169pub enum DependencyRefKind {
170    /// A Git tag.
171    Tag,
172    /// A Git branch.
173    Branch,
174    /// A Git commit revision.
175    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    // <https://github.com/foundry-rs/foundry/pull/3130>
356    #[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        // <https://github.com/foundry-rs/foundry/issues/9717>
423        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}