Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion docs/dev-tools/backends/spm.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# SPM Backend <Badge type="warning" text="experimental" />

You may install executables managed by [Swift Package Manager](https://www.swift.org/documentation/package-manager) directly from GitHub releases.
You may install executables managed by [Swift Package Manager](https://www.swift.org/documentation/package-manager) directly from GitHub or GitLab releases.

The code for this is inside of the mise repository at [`./src/backend/spm.rs`](https://github.com/jdx/mise/blob/main/src/backend/spm.rs).

Expand Down Expand Up @@ -42,3 +42,28 @@ The version will be set in `~/.config/mise/config.toml` with the following forma
| GitHub url for specific release version | `spm:https://github.com/tuist/tuist.git@4.15.0` |

Other syntax may work but is unsupported and untested.

## Tool Options

The following [tool-options](/dev-tools/#tool-options) are available for the backend — these
go in `[tools]` in `mise.toml`.

### `provider`

Set the provider type to use for fetching assets and release information. Either `github` or `gitlab` (default is `github`).
Ensure the `provider` is set to the correct type if you use shorthand notation and `api_url` for self-hosted repositories
as the type probably cannot be derived correctly from the URL.

```toml
[tools]
"spm:patricklorran/ios-settings" = { version = "latest", provider = "gitlab" }
```

### `api_url`

Set the URL for the provider's API. This is useful when using a self-hosted instance.

```toml
[tools]
"spm:acme/my-tool" = { version = "latest", provider = "gitlab", api_url = "https://gitlab.acme.com/api/v4" }
```
215 changes: 182 additions & 33 deletions src/backend/spm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use crate::config::{Config, Settings};
use crate::git::{CloneOptions, Git};
use crate::install_context::InstallContext;
use crate::toolset::ToolVersion;
use crate::{dirs, file, github};
use crate::{dirs, file, github, gitlab};
use async_trait::async_trait;
use eyre::WrapErr;
use serde::Deserializer;
Expand All @@ -17,6 +17,7 @@ use std::{
fmt::{self, Debug},
sync::Arc,
};
use strum::{AsRefStr, EnumString, VariantNames};
use url::Url;
use xx::regex;

Expand All @@ -40,13 +41,26 @@ impl Backend for SPMBackend {
}

async fn _list_remote_versions(&self, _config: &Arc<Config>) -> eyre::Result<Vec<String>> {
let repo = SwiftPackageRepo::new(&self.tool_name())?;
Ok(github::list_releases(repo.shorthand.as_str())
.await?
.into_iter()
.map(|r| r.tag_name)
.rev()
.collect())
let provider = GitProvider::from_ba(&self.ba);
let repo = SwiftPackageRepo::new(&self.tool_name(), &provider)?;
let releases = match provider.kind {
GitProviderKind::GitLab => {
gitlab::list_releases_from_url(&provider.api_url, repo.shorthand.as_str())
.await?
.into_iter()
.map(|r| r.tag_name)
.rev()
.collect()
}
_ => github::list_releases_from_url(&provider.api_url, repo.shorthand.as_str())
.await?
.into_iter()
.map(|r| r.tag_name)
.rev()
.collect(),
};

Ok(releases)
}

async fn install_version_(
Expand All @@ -66,8 +80,8 @@ impl Backend for SPMBackend {
Or install Swift via https://swift.org/download/",
)
.await;

let repo = SwiftPackageRepo::new(&self.tool_name())?;
let provider = GitProvider::from_ba(&self.ba);
let repo = SwiftPackageRepo::new(&self.tool_name(), &provider)?;
let revision = if tv.version == "latest" {
self.latest_stable_version(&ctx.config)
.await?
Expand Down Expand Up @@ -208,6 +222,56 @@ impl SPMBackend {
}
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitProvider {
pub api_url: String,
pub kind: GitProviderKind,
}

impl Default for GitProvider {
fn default() -> Self {
Self {
api_url: github::API_URL.to_string(),
kind: GitProviderKind::GitHub,
}
}
}

#[derive(AsRefStr, Clone, Debug, Eq, PartialEq, EnumString, VariantNames)]
pub enum GitProviderKind {
#[strum(serialize = "github")]
GitHub,
#[strum(serialize = "gitlab")]
GitLab,
}

impl GitProvider {
fn from_ba(ba: &BackendArg) -> Self {
let opts = ba.opts();

let default_provider = GitProviderKind::GitHub.as_ref().to_string();
let provider = opts.get("provider").unwrap_or(&default_provider);
let kind = if ba.tool_name.contains("gitlab.com") {
GitProviderKind::GitLab
} else {
match provider.to_lowercase().as_str() {
"gitlab" => GitProviderKind::GitLab,
_ => GitProviderKind::GitHub,
}
};

let api_url = match opts.get("api_url") {
Some(api_url) => api_url.trim_end_matches('/').to_string(),
None => match kind {
GitProviderKind::GitHub => github::API_URL.to_string(),
GitProviderKind::GitLab => gitlab::API_URL.to_string(),
},
};

Self { api_url, kind }
}
}

#[derive(Debug)]
struct SwiftPackageRepo {
/// https://github.com/owner/repo.git
Expand All @@ -218,26 +282,31 @@ struct SwiftPackageRepo {

impl SwiftPackageRepo {
/// Parse the slug or the full URL of a GitHub package repository.
fn new(name: &str) -> Result<Self, eyre::Error> {
fn new(name: &str, provider: &GitProvider) -> Result<Self, eyre::Error> {
let name = name.strip_prefix("spm:").unwrap_or(name);
let shorthand_regex = regex!(r"^[a-zA-Z0-9_-]+/[a-zA-Z0-9._-]+$");
let shorthand_in_url_regex =
regex!(r"https://github.com/([a-zA-Z0-9_-]+/[a-zA-Z0-9._-]+)\.git");

let shorthand = if let Some(Some(m)) =
shorthand_in_url_regex.captures(name).map(|c| c.get(1))
{
m.as_str()
let shorthand_regex = regex!(r"^(?:[a-zA-Z0-9_-]+/)+[a-zA-Z0-9._-]+$");
let shorthand_in_url_regex = regex!(
r"^https://(?P<domain>[^/]+)/(?P<shorthand>(?:[a-zA-Z0-9_-]+/)+[a-zA-Z0-9._-]+)\.git"
);

let (shorthand, url) = if let Some(caps) = shorthand_in_url_regex.captures(name) {
let shorthand = caps.name("shorthand").unwrap().as_str();
let url = Url::parse(name)?;
(shorthand, url)
} else if shorthand_regex.is_match(name) {
name
let host = match provider.kind {
GitProviderKind::GitHub => "github.com",
GitProviderKind::GitLab => "gitlab.com",
};
let url_str = format!("https://{}/{}.git", host, name);
let url = Url::parse(&url_str)?;
(name, url)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: SPM Incorrectly Constructs URLs for Custom Git Providers

Shorthand SPM package notation with self-hosted Git providers constructs incorrect clone URLs. The SwiftPackageRepo hardcodes github.com or gitlab.com as the host instead of extracting it from the GitProvider's api_url, which prevents cloning from custom instances.

Fix in Cursor Fix in Web

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this a problem @roele ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not account for supporting shorthand notation for self-hosted repositories. Not sure we can reliably derive the repository URL from the API URL, so i rather only have support for self-hosted via https:// notation. This should probably be stated in the docs though.

} else {
Err(eyre::eyre!(
"Invalid Swift package repository: {}. The repository should either be a GitHub repository slug, owner/name, or the complete URL, https://github.com/owner/name.",
"Invalid Swift package repository: {}. The repository should either be a repository slug (owner/name), or the complete URL (e.g. https://github.com/owner/name.git).",
name
))?
};
let url_str = format!("https://github.com/{shorthand}.git");
let url = Url::parse(&url_str)?;

Ok(Self {
url,
Expand All @@ -248,54 +317,134 @@ impl SwiftPackageRepo {

#[cfg(test)]
mod tests {
use crate::config::Config;
use crate::{config::Config, toolset::ToolVersionOptions};

use super::*;
use indexmap::indexmap;
use pretty_assertions::assert_str_eq;

#[tokio::test]
async fn test_git_provider_from_ba() {
// Example of defining a capture (closure) in Rust:
let get_ba = |tool: String, opts: Option<ToolVersionOptions>| {
BackendArg::new_raw("spm".to_string(), Some(tool.clone()), tool, opts)
};

assert_eq!(
GitProvider::from_ba(&get_ba("tool".to_string(), None)),
GitProvider {
api_url: github::API_URL.to_string(),
kind: GitProviderKind::GitHub
}
);

assert_eq!(
GitProvider::from_ba(&get_ba(
"tool".to_string(),
Some(ToolVersionOptions {
opts: indexmap![
"provider".to_string() => "gitlab".to_string()
],
..Default::default()
})
)),
GitProvider {
api_url: gitlab::API_URL.to_string(),
kind: GitProviderKind::GitLab
}
);

assert_eq!(
GitProvider::from_ba(&get_ba(
"tool".to_string(),
Some(ToolVersionOptions {
opts: indexmap![
"api_url".to_string() => "https://gitlab.acme.com/api/v4".to_string(),
"provider".to_string() => "gitlab".to_string(),
],
..Default::default()
})
)),
GitProvider {
api_url: "https://gitlab.acme.com/api/v4".to_string(),
kind: GitProviderKind::GitLab
}
);
}

#[tokio::test]
async fn test_spm_repo_init_by_shorthand() {
let _config = Config::get().await.unwrap();
let package_name = "nicklockwood/SwiftFormat";
let package_repo = SwiftPackageRepo::new(package_name).unwrap();
let package_repo =
SwiftPackageRepo::new("nicklockwood/SwiftFormat", &GitProvider::default()).unwrap();
assert_str_eq!(
package_repo.url.as_str(),
"https://github.com/nicklockwood/SwiftFormat.git"
);
assert_str_eq!(package_repo.shorthand, "nicklockwood/SwiftFormat");

let package_repo = SwiftPackageRepo::new(
"acme/nicklockwood/SwiftFormat",
&GitProvider {
api_url: gitlab::API_URL.to_string(),
kind: GitProviderKind::GitLab,
},
)
.unwrap();
assert_str_eq!(
package_repo.url.as_str(),
"https://gitlab.com/acme/nicklockwood/SwiftFormat.git"
);
assert_str_eq!(package_repo.shorthand, "acme/nicklockwood/SwiftFormat");
}

#[tokio::test]
async fn test_spm_repo_init_name() {
let _config = Config::get().await.unwrap();
assert!(
SwiftPackageRepo::new("owner/name.swift").is_ok(),
SwiftPackageRepo::new("owner/name.swift", &GitProvider::default()).is_ok(),
"name part can contain ."
);
assert!(
SwiftPackageRepo::new("owner/name_swift").is_ok(),
SwiftPackageRepo::new("owner/name_swift", &GitProvider::default()).is_ok(),
"name part can contain _"
);
assert!(
SwiftPackageRepo::new("owner/name-swift").is_ok(),
SwiftPackageRepo::new("owner/name-swift", &GitProvider::default()).is_ok(),
"name part can contain -"
);
assert!(
SwiftPackageRepo::new("owner/name$swift").is_err(),
SwiftPackageRepo::new("owner/name$swift", &GitProvider::default()).is_err(),
"name part cannot contain characters other than a-zA-Z0-9._-"
);
}

#[tokio::test]
async fn test_spm_repo_init_by_url() {
let _config = Config::get().await.unwrap();
let package_name = "https://github.com/nicklockwood/SwiftFormat.git";
let package_repo = SwiftPackageRepo::new(package_name).unwrap();
let package_repo = SwiftPackageRepo::new(
"https://github.com/nicklockwood/SwiftFormat.git",
&GitProvider::default(),
)
.unwrap();
assert_str_eq!(
package_repo.url.as_str(),
"https://github.com/nicklockwood/SwiftFormat.git"
);
assert_str_eq!(package_repo.shorthand, "nicklockwood/SwiftFormat");

let package_repo = SwiftPackageRepo::new(
"https://gitlab.acme.com/acme/someuser/SwiftTool.git",
&GitProvider {
api_url: "https://api.gitlab.acme.com/api/v4".to_string(),
kind: GitProviderKind::GitLab,
},
)
.unwrap();
assert_str_eq!(
package_repo.url.as_str(),
"https://gitlab.acme.com/acme/someuser/SwiftTool.git"
);
assert_str_eq!(package_repo.shorthand, "acme/someuser/SwiftTool");
}
}

Expand Down
Loading