diff --git a/Cargo.lock b/Cargo.lock index 16bd1b22dd..e3e2fc1737 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5036,6 +5036,7 @@ dependencies = [ "dotenvy", "duct 0.13.7", "exec", + "expr-lang", "eyre", "filetime", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 563fd701ce..8fb7543e38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,6 +89,7 @@ digest = "0.10.7" dotenvy = "0.15" duct = "0.13" homedir = "0.3" +expr-lang = "0.3" eyre = "0.6" filetime = "0.2" flate2 = "1" diff --git a/docs/dev-tools/backends/forgejo.md b/docs/dev-tools/backends/forgejo.md index ab97cbc2af..b3675e88ec 100644 --- a/docs/dev-tools/backends/forgejo.md +++ b/docs/dev-tools/backends/forgejo.md @@ -172,12 +172,12 @@ Use `rename_exe` for archives where the binary inside has a different name than ### `bin_path` -Specify the directory containing binaries within the extracted archive, or where to place the downloaded file. This supports templating with `{name}`, `{version}`, `{os}`, `{arch}`, and `{ext}`: +Specify the directory containing binaries within the extracted archive, or where to place the downloaded file. This supports Tera templating with variables like `{{ version }}`, `{{ os }}`, `{{ arch }}`, and arch aliases (`{{ darwin_os }}`, `{{ amd64_arch }}`, `{{ x86_64_arch }}`, `{{ gnu_arch }}`): ```toml [tools."forgejo:user/repo"] version = "latest" -bin_path = "{name}-{version}/bin" # expands to tool-1.0.0/bin +bin_path = "tool-{{ version }}/bin" # expands to tool-1.0.0/bin ``` **Binary path lookup order:** diff --git a/docs/dev-tools/backends/github.md b/docs/dev-tools/backends/github.md index 998ef642f8..b51456e6d8 100644 --- a/docs/dev-tools/backends/github.md +++ b/docs/dev-tools/backends/github.md @@ -170,12 +170,12 @@ Use `rename_exe` for archives where the binary inside has a different name than ### `bin_path` -Specify the directory containing binaries within the extracted archive, or where to place the downloaded file. This supports templating with `{name}`, `{version}`, `{os}`, `{arch}`, and `{ext}`: +Specify the directory containing binaries within the extracted archive, or where to place the downloaded file. This supports Tera templating with variables like `{{ version }}`, `{{ os }}`, `{{ arch }}`, and arch aliases (`{{ darwin_os }}`, `{{ amd64_arch }}`, `{{ x86_64_arch }}`, `{{ gnu_arch }}`): ```toml [tools."github:cli/cli"] version = "latest" -bin_path = "{name}-{version}/bin" # expands to cli-1.0.0/bin +bin_path = "cli-{{ version }}/bin" # expands to cli-1.0.0/bin ``` **Binary path lookup order:** diff --git a/docs/dev-tools/backends/gitlab.md b/docs/dev-tools/backends/gitlab.md index b528992e54..f8bd563185 100644 --- a/docs/dev-tools/backends/gitlab.md +++ b/docs/dev-tools/backends/gitlab.md @@ -185,12 +185,12 @@ Use `rename_exe` for archives where the binary inside has a different name than ### `bin_path` -Specify the directory containing binaries within the extracted archive, or where to place the downloaded file. This supports templating with `{name}`, `{version}`, `{os}`, `{arch}`, and `{ext}`: +Specify the directory containing binaries within the extracted archive, or where to place the downloaded file. This supports Tera templating with variables like `{{ version }}`, `{{ os }}`, `{{ arch }}`, and arch aliases (`{{ darwin_os }}`, `{{ amd64_arch }}`, `{{ x86_64_arch }}`, `{{ gnu_arch }}`): ```toml [tools."gitlab:gitlab-org/gitlab-runner"] version = "latest" -bin_path = "{name}-{version}/bin" # expands to gitlab-runner-1.0.0/bin +bin_path = "gitlab-runner-{{ version }}/bin" # expands to gitlab-runner-1.0.0/bin ``` **Binary path lookup order:** diff --git a/docs/dev-tools/backends/http.md b/docs/dev-tools/backends/http.md index 6d034bdc41..324fb64240 100644 --- a/docs/dev-tools/backends/http.md +++ b/docs/dev-tools/backends/http.md @@ -139,7 +139,7 @@ Rename the downloaded binary to a specific name. This is useful when downloading ```toml [tools."http:docker-compose"] version = "2.29.1" -url = "https://github.com/docker/compose/releases/download/v{version}/docker-compose-linux-x86_64" +url = "https://github.com/docker/compose/releases/download/v{{ version }}/docker-compose-linux-x86_64" bin = "docker-compose" # Rename from docker-compose-linux-x86_64 to docker-compose ``` @@ -252,6 +252,34 @@ version_json_path = ".releases[?channel=stable].version" The filter syntax `[?field=value]` allows filtering JSON arrays before extraction. This is useful for APIs that return multiple release channels (stable, beta, dev) and you only want specific ones. +### `version_expr` + +Extract versions using an [expr-lang](https://expr-lang.org/) expression. This provides the most flexibility for complex version extraction logic: + +```toml +[tools."http:my-tool"] +version = "latest" +url = "https://example.com/releases/my-tool-v{{ version }}.tar.gz" +version_list_url = "https://example.com/versions.txt" +version_expr = 'split(body, "\n")' +``` + +The expression receives the HTTP response body as the `body` variable and should return an array of version strings. + +Example expressions: + +```toml +# Split newline-separated versions +version_expr = 'split(body, "\n")' + +# Split and filter empty lines +version_expr = 'filter(split(body, "\n"), # != "")' +``` + +::: tip +`version_expr` takes precedence over `version_regex` and `version_json_path` if multiple are specified. Use it when the other options aren't flexible enough for your use case. +::: + ### `bin_path` Specify the directory containing binaries within the extracted archive, or where to place the downloaded file. This supports templating with `{{version}}`: diff --git a/docs/dev-tools/backends/s3.md b/docs/dev-tools/backends/s3.md index a4825519db..98ba9da359 100644 --- a/docs/dev-tools/backends/s3.md +++ b/docs/dev-tools/backends/s3.md @@ -47,11 +47,11 @@ go in `[tools]` in `mise.toml`. ### `url` (Required) -Specifies the S3 URL to download the tool from. The URL supports templating with `{version}`: +Specifies the S3 URL to download the tool from. The URL supports templating with `{{ version }}`: ```toml [tools] -"s3:my-tool" = { version = "1.0.0", url = "s3://my-bucket/tools/my-tool-v{version}.tar.gz" } +"s3:my-tool" = { version = "1.0.0", url = "s3://my-bucket/tools/my-tool-v{{ version }}.tar.gz" } ``` ### `endpoint` @@ -61,7 +61,7 @@ Specify a custom S3-compatible endpoint for services like MinIO, DigitalOcean Sp ```toml [tools."s3:my-tool"] version = "1.0.0" -url = "s3://my-bucket/tools/my-tool-v{version}.tar.gz" +url = "s3://my-bucket/tools/my-tool-v{{ version }}.tar.gz" endpoint = "http://minio.internal:9000" ``` @@ -72,7 +72,7 @@ Specify the AWS region for the S3 bucket: ```toml [tools."s3:my-tool"] version = "1.0.0" -url = "s3://my-bucket/tools/my-tool-v{version}.tar.gz" +url = "s3://my-bucket/tools/my-tool-v{{ version }}.tar.gz" region = "us-west-2" ``` @@ -111,7 +111,7 @@ Specify the directory containing binaries within the extracted archive: [tools."s3:my-tool"] version = "1.0.0" url = "s3://my-bucket/tools/my-tool-v1.0.0.tar.gz" -bin_path = "my-tool-{version}/bin" +bin_path = "my-tool-{{ version }}/bin" ``` ### `format` @@ -136,7 +136,7 @@ Fetch available versions from a JSON manifest file stored in S3: ```toml [tools."s3:my-tool"] version = "latest" -url = "s3://my-bucket/tools/my-tool-v{version}.tar.gz" +url = "s3://my-bucket/tools/my-tool-v{{ version }}.tar.gz" version_list_url = "s3://my-bucket/tools/versions.json" ``` @@ -159,11 +159,25 @@ Extract versions from JSON responses using a jq-like path expression: ```toml [tools."s3:my-tool"] version = "latest" -url = "s3://my-bucket/tools/my-tool-v{version}.tar.gz" +url = "s3://my-bucket/tools/my-tool-v{{ version }}.tar.gz" version_list_url = "s3://my-bucket/tools/releases.json" version_json_path = ".[].version" ``` +### `version_expr` + +Extract versions using an [expr-lang](https://expr-lang.org/) expression for complex version extraction logic: + +```toml +[tools."s3:my-tool"] +version = "latest" +url = "s3://my-bucket/tools/my-tool-v{{ version }}.tar.gz" +version_list_url = "s3://my-bucket/tools/versions.txt" +version_expr = 'split(body, "\n")' +``` + +The expression receives the response body as the `body` variable and should return an array of version strings. + ### S3 Object Listing Discover versions by listing objects in the S3 bucket: @@ -171,7 +185,7 @@ Discover versions by listing objects in the S3 bucket: ```toml [tools."s3:my-tool"] version = "latest" -url = "s3://my-bucket/tools/my-tool-v{version}.tar.gz" +url = "s3://my-bucket/tools/my-tool-v{{ version }}.tar.gz" version_prefix = "tools/my-tool-v" version_regex = "my-tool-v([0-9.]+)" ``` @@ -186,7 +200,7 @@ Here's a complete example using MinIO as an S3-compatible backend: ```toml [tools."s3:my-internal-tool"] version = "latest" -url = "s3://tools-bucket/releases/my-tool-{version}.tar.gz" +url = "s3://tools-bucket/releases/my-tool-{{ version }}.tar.gz" endpoint = "http://minio.internal:9000" region = "us-east-1" version_list_url = "s3://tools-bucket/releases/versions.json" diff --git a/registry.toml b/registry.toml index b23434c623..503e2e17cd 100644 --- a/registry.toml +++ b/registry.toml @@ -782,20 +782,20 @@ bin = "claude" version_list_url = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/latest" [tools.claude.backends.options.platforms.macos-arm64] -url = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/{version}/darwin-arm64/claude" +url = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/{{ version }}/darwin-arm64/claude" [tools.claude.backends.options.platforms.macos-x64] -url = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/{version}/darwin-x64/claude" +url = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/{{ version }}/darwin-x64/claude" [tools.claude.backends.options.platforms.linux-arm64] -url = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/{version}/linux-arm64/claude" +url = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/{{ version }}/linux-arm64/claude" [tools.claude.backends.options.platforms.linux-x64] -url = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/{version}/linux-x64/claude" +url = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/{{ version }}/linux-x64/claude" [tools.claude.backends.options.platforms.windows-x64] bin = "claude.exe" -url = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/{version}/win32-x64/claude.exe" +url = "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/{{ version }}/win32-x64/claude.exe" [[tools.claude.backends]] full = "npm:@anthropic-ai/claude-code" @@ -1154,9 +1154,22 @@ description = "Command-line tools for Dapr" test = ["dapr --version", "CLI version: {{version}}"] [tools.dart] -backends = ["asdf:mise-plugins/mise-dart", "vfox:mise-plugins/vfox-dart"] description = "An approachable, portable, and productive language for high-quality apps on any platform" +[[tools.dart.backends]] +full = "http:dart" + +[tools.dart.backends.options] +url = "https://storage.googleapis.com/dart-archive/channels/stable/release/{{ version }}/sdk/dartsdk-{{ os() }}-{{ arch() }}-release.zip" +version_list_url = "https://storage.googleapis.com/storage/v1/b/dart-archive/o?prefix=channels/stable/release/&delimiter=/" +version_regex = "channels/stable/release/(\\d+\\.\\d+\\.\\d+)/" + +[[tools.dart.backends]] +full = "asdf:mise-plugins/mise-dart" + +[[tools.dart.backends]] +full = "vfox:mise-plugins/vfox-dart" + [tools.dasel] backends = ["aqua:TomWright/dasel", "asdf:asdf-community/asdf-dasel"] description = "Select, put and delete data from JSON, TOML, YAML, XML and CSV files with a single tool. Supports conversion between formats and can be used as a Go package" @@ -1316,19 +1329,19 @@ os = ["linux", "macos", "windows"] full = "github:domeengine/dome" [tools.dome.backends.options.platforms.macos-x64] -asset_pattern = "dome-v{version}-macosx-all.zip" +asset_pattern = "dome-v{{ version }}-macosx-all.zip" bin_path = "dome-macosx-all" [tools.dome.backends.options.platforms.macos-arm64] -asset_pattern = "dome-v{version}-macosx-all.zip" +asset_pattern = "dome-v{{ version }}-macosx-all.zip" bin_path = "dome-macosx-all" [tools.dome.backends.options.platforms.linux-x64] -asset_pattern = "dome-v{version}-linux-x64.zip" +asset_pattern = "dome-v{{ version }}-linux-x64.zip" bin_path = "dome-linux-x64" [tools.dome.backends.options.platforms.windows-x64] -asset_pattern = "dome-v{version}-windows-x64.zip" +asset_pattern = "dome-v{{ version }}-windows-x64.zip" bin_path = "dome-windows-x64" [[tools.dome.backends]] @@ -1611,16 +1624,16 @@ version_json_path = ".releases[?channel=stable].version" version_list_url = "https://storage.googleapis.com/flutter_infra_release/releases/releases_linux.json" [tools.flutter.backends.options.platforms.macos-x64] -url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_{version}-stable.zip" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_{{ version }}-stable.zip" [tools.flutter.backends.options.platforms.macos-arm64] -url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_{version}-stable.zip" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_{{ version }}-stable.zip" [tools.flutter.backends.options.platforms.linux-x64] -url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_{version}-stable.tar.xz" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_{{ version }}-stable.tar.xz" [tools.flutter.backends.options.platforms.windows-x64] -url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_{version}-stable.zip" +url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_{{ version }}-stable.zip" [[tools.flutter.backends]] full = "asdf:mise-plugins/mise-flutter" @@ -1661,20 +1674,20 @@ test = ["flyway --version", "{{version}}"] full = "github:flyway/flyway[version_prefix=flyway-]" [tools.flyway.backends.options.platforms.macos-arm64] -asset_pattern = "flyway-commandline-{version}-macosx-arm64.tar.gz" -bin_path = "flyway-{version}" +asset_pattern = "flyway-commandline-{{ version }}-macosx-arm64.tar.gz" +bin_path = "flyway-{{ version }}" [tools.flyway.backends.options.platforms.macos-x64] -asset_pattern = "flyway-commandline-{version}-macosx-x64.tar.gz" -bin_path = "flyway-{version}" +asset_pattern = "flyway-commandline-{{ version }}-macosx-x64.tar.gz" +bin_path = "flyway-{{ version }}" [tools.flyway.backends.options.platforms.linux-x64] -asset_pattern = "flyway-commandline-{version}-linux-x64.tar.gz" -bin_path = "flyway-{version}" +asset_pattern = "flyway-commandline-{{ version }}-linux-x64.tar.gz" +bin_path = "flyway-{{ version }}" [tools.flyway.backends.options.platforms.windows-x64] -asset_pattern = "flyway-commandline-{version}-windows-x64.zip" -bin_path = "flyway-{version}" +asset_pattern = "flyway-commandline-{{ version }}-windows-x64.zip" +bin_path = "flyway-{{ version }}" [[tools.flyway.backends]] full = "asdf:mise-plugins/mise-flyway" @@ -4949,13 +4962,13 @@ description = "Executables, uberjar, and library to beautifully format Clojure a full = "github:kkinnear/zprint[bin=zprint]" [tools.zprint.backends.options.platforms.macos-arm64] -asset_pattern = "zprintma-{version}" +asset_pattern = "zprintma-{{ version }}" [tools.zprint.backends.options.platforms.macos-x64] -asset_pattern = "zprintm-{version}" +asset_pattern = "zprintm-{{ version }}" [tools.zprint.backends.options.platforms.linux-x64] -asset_pattern = "zprintl-{version}" +asset_pattern = "zprintl-{{ version }}" [[tools.zprint.backends]] full = "asdf:mise-plugins/mise-zprint" diff --git a/src/backend/github.rs b/src/backend/github.rs index 67653a9052..822eec9e6a 100644 --- a/src/backend/github.rs +++ b/src/backend/github.rs @@ -1217,15 +1217,53 @@ fn template_string_for_target(template: &str, tv: &ToolVersion, target: &Platfor _ => arch, }; - template - .replace("{version}", version) - .replace("{os}", os) - .replace("{arch}", arch) - // Common aliases - .replace("{darwin_os}", darwin_os) - .replace("{amd64_arch}", amd64_arch) - .replace("{x86_64_arch}", x86_64_arch) - .replace("{gnu_arch}", gnu_arch) + // Check for legacy {placeholder} syntax (any of the supported placeholders) + let has_legacy_placeholder = [ + "{version}", + "{os}", + "{arch}", + "{darwin_os}", + "{amd64_arch}", + "{x86_64_arch}", + "{gnu_arch}", + ] + .iter() + .any(|p| template.contains(p) && !template.contains(&format!("{{{p}}}"))); + + if has_legacy_placeholder { + deprecated_at!( + "2026.3.0", + "legacy-version-template", + "Use Tera syntax (e.g., {{{{ version }}}}) instead of legacy {{version}} in templates" + ); + // Legacy support: replace {placeholder} patterns + return template + .replace("{version}", version) + .replace("{os}", os) + .replace("{arch}", arch) + .replace("{darwin_os}", darwin_os) + .replace("{amd64_arch}", amd64_arch) + .replace("{x86_64_arch}", x86_64_arch) + .replace("{gnu_arch}", gnu_arch); + } + + // Use Tera rendering for templates + let mut ctx = crate::tera::BASE_CONTEXT.clone(); + ctx.insert("version", version); + ctx.insert("os", os); + ctx.insert("arch", arch); + ctx.insert("darwin_os", darwin_os); + ctx.insert("amd64_arch", amd64_arch); + ctx.insert("x86_64_arch", x86_64_arch); + ctx.insert("gnu_arch", gnu_arch); + + match crate::tera::get_tera(None).render_str(template, &ctx) { + Ok(rendered) => rendered, + Err(e) => { + warn!("Failed to render template '{}': {}", template, e); + template.to_string() + } + } } #[cfg(test)] diff --git a/src/backend/http.rs b/src/backend/http.rs index 213dfbbc5d..c264f62831 100644 --- a/src/backend/http.rs +++ b/src/backend/http.rs @@ -558,8 +558,9 @@ impl HttpBackend { let regex = opts.get("version_regex").map(|s| s.as_str()); let json_path = opts.get("version_json_path").map(|s| s.as_str()); + let version_expr = opts.get("version_expr").map(|s| s.as_str()); - version_list::fetch_versions(&url, regex, json_path).await + version_list::fetch_versions(&url, regex, json_path, version_expr).await } } @@ -571,6 +572,7 @@ pub fn install_time_option_keys() -> Vec { "version_list_url".into(), "version_regex".into(), "version_json_path".into(), + "version_expr".into(), "format".into(), ] } diff --git a/src/backend/s3.rs b/src/backend/s3.rs index 29f4789dc0..947f5421fc 100644 --- a/src/backend/s3.rs +++ b/src/backend/s3.rs @@ -202,8 +202,14 @@ impl S3Backend { let content = file::read_to_string(&tmp_path)?; let regex = Self::get_opt(opts, "version_regex"); let json_path = Self::get_opt(opts, "version_json_path"); + let version_expr = Self::get_opt(opts, "version_expr"); - version_list::parse_version_list(&content, regex.as_deref(), json_path.as_deref()) + version_list::parse_version_list( + &content, + regex.as_deref(), + json_path.as_deref(), + version_expr.as_deref(), + ) } /// Fetch versions by listing S3 objects @@ -351,6 +357,7 @@ pub fn install_time_option_keys() -> Vec { "version_list_url".into(), "version_regex".into(), "version_json_path".into(), + "version_expr".into(), "version_prefix".into(), "format".into(), "region".into(), diff --git a/src/backend/static_helpers.rs b/src/backend/static_helpers.rs index 5a12e826eb..b8bb99425a 100644 --- a/src/backend/static_helpers.rs +++ b/src/backend/static_helpers.rs @@ -327,8 +327,29 @@ pub fn list_available_platforms_with_key(opts: &ToolVersionOptions, key_type: &s } pub fn template_string(template: &str, tv: &ToolVersion) -> String { - let version = &tv.version; - template.replace("{version}", version) + // Check for legacy {version} syntax and emit deprecation warning + if template.contains("{version}") && !template.contains("{{version}}") { + deprecated_at!( + "2026.3.0", + "legacy-version-template", + "Use {{{{ version }}}} instead of {{version}} in URL templates" + ); + // Legacy support: replace {version} placeholder + return template.replace("{version}", &tv.version); + } + + // Use Tera rendering for templates + // Supports {{ version }}, {{ os() }}, {{ arch() }}, etc. + let mut ctx = crate::tera::BASE_CONTEXT.clone(); + ctx.insert("version", &tv.version); + + match crate::tera::get_tera(None).render_str(template, &ctx) { + Ok(rendered) => rendered, + Err(e) => { + warn!("Failed to render template '{}': {}", template, e); + template.to_string() + } + } } pub fn get_filename_from_url(url_str: &str) -> String { diff --git a/src/backend/version_list.rs b/src/backend/version_list.rs index 37c1174fed..4623778c03 100644 --- a/src/backend/version_list.rs +++ b/src/backend/version_list.rs @@ -11,7 +11,9 @@ /// - `version_list_url`: URL to fetch version list from /// - `version_regex`: Regex pattern to extract versions (first capturing group or full match) /// - `version_json_path`: JQ-like path to extract versions from JSON (e.g., `.[].version`) +/// - `version_expr`: Expression using expr-lang syntax to extract versions use crate::backend::jq; +use expr::{Context, Value}; use eyre::Result; use regex::Regex; use std::collections::HashSet; @@ -21,6 +23,7 @@ pub async fn fetch_versions( version_list_url: &str, version_regex: Option<&str>, version_json_path: Option<&str>, + version_expr: Option<&str>, ) -> Result> { use crate::http::HTTP; @@ -29,21 +32,27 @@ pub async fn fetch_versions( let content = response.trim(); // Parse versions based on format - parse_version_list(content, version_regex, version_json_path) + parse_version_list(content, version_regex, version_json_path, version_expr) } -/// Parse version list from content using optional regex pattern or JSON path +/// Parse version list from content using optional regex pattern, JSON path, or expr pub fn parse_version_list( content: &str, version_regex: Option<&str>, version_json_path: Option<&str>, + version_expr: Option<&str>, ) -> Result> { let mut versions = Vec::new(); let trimmed = content.trim(); + // If an expr is provided, use it to evaluate and extract versions + // Fail hard if the expression is invalid - don't silently fall through + if let Some(expr_str) = version_expr { + versions = eval_version_expr(expr_str, trimmed)?; + } // If a JSON path is provided (like ".[].version" or ".versions"), try to use it // but fall back to text parsing if JSON parsing fails - if let Some(json_path) = version_json_path { + else if let Some(json_path) = version_json_path { if let Ok(json) = serde_json::from_str::(trimmed) && let Ok(extracted) = jq::extract(&json, json_path) { @@ -100,6 +109,48 @@ pub fn parse_version_list( Ok(versions) } +/// Evaluate a version expression using expr-lang +fn eval_version_expr(expr_str: &str, body: &str) -> Result> { + let mut ctx = Context::default(); + ctx.insert("body".to_string(), Value::String(body.to_string())); + + let result = expr::eval(expr_str, &ctx)?; + value_to_strings(result) +} + +/// Convert expr Value to a list of strings +fn value_to_strings(value: Value) -> Result> { + match value { + Value::Array(arr) => { + let mut result = Vec::new(); + for v in arr { + if let Some(s) = value_as_string(&v) + && !s.is_empty() + { + result.push(s); + } + } + Ok(result) + } + _ => { + if let Some(s) = value_as_string(&value) + && !s.is_empty() + { + return Ok(vec![s]); + } + Ok(vec![]) + } + } +} + +fn value_as_string(value: &Value) -> Option { + match value { + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -107,84 +158,85 @@ mod tests { #[test] fn test_parse_single_version() { let content = "2.0.53"; - let versions = parse_version_list(content, None, None).unwrap(); + let versions = parse_version_list(content, None, None, None).unwrap(); assert_eq!(versions, vec!["2.0.53"]); } #[test] fn test_parse_single_version_with_v_prefix() { let content = "v2.0.53"; - let versions = parse_version_list(content, None, None).unwrap(); + let versions = parse_version_list(content, None, None, None).unwrap(); assert_eq!(versions, vec!["2.0.53"]); } #[test] fn test_parse_line_separated_versions() { let content = "1.0.0\n1.1.0\n2.0.0"; - let versions = parse_version_list(content, None, None).unwrap(); + let versions = parse_version_list(content, None, None, None).unwrap(); assert_eq!(versions, vec!["1.0.0", "1.1.0", "2.0.0"]); } #[test] fn test_parse_line_separated_with_comments() { let content = "# Latest versions\n1.0.0\n# Stable\n2.0.0"; - let versions = parse_version_list(content, None, None).unwrap(); + let versions = parse_version_list(content, None, None, None).unwrap(); assert_eq!(versions, vec!["1.0.0", "2.0.0"]); } #[test] fn test_parse_json_array_of_strings() { let content = r#"["1.0.0", "1.1.0", "2.0.0"]"#; - let versions = parse_version_list(content, None, None).unwrap(); + let versions = parse_version_list(content, None, None, None).unwrap(); assert_eq!(versions, vec!["1.0.0", "1.1.0", "2.0.0"]); } #[test] fn test_parse_json_array_with_v_prefix() { let content = r#"["v1.0.0", "v1.1.0", "v2.0.0"]"#; - let versions = parse_version_list(content, None, None).unwrap(); + let versions = parse_version_list(content, None, None, None).unwrap(); assert_eq!(versions, vec!["1.0.0", "1.1.0", "2.0.0"]); } #[test] fn test_parse_json_array_of_objects_with_version() { let content = r#"[{"version": "1.0.0"}, {"version": "2.0.0"}]"#; - let versions = parse_version_list(content, None, None).unwrap(); + let versions = parse_version_list(content, None, None, None).unwrap(); assert_eq!(versions, vec!["1.0.0", "2.0.0"]); } #[test] fn test_parse_json_array_of_objects_with_tag_name() { let content = r#"[{"tag_name": "v1.0.0"}, {"tag_name": "v2.0.0"}]"#; - let versions = parse_version_list(content, None, None).unwrap(); + let versions = parse_version_list(content, None, None, None).unwrap(); assert_eq!(versions, vec!["1.0.0", "2.0.0"]); } #[test] fn test_parse_json_object_with_versions_array() { let content = r#"{"versions": ["1.0.0", "2.0.0"]}"#; - let versions = parse_version_list(content, None, None).unwrap(); + let versions = parse_version_list(content, None, None, None).unwrap(); assert_eq!(versions, vec!["1.0.0", "2.0.0"]); } #[test] fn test_parse_with_regex() { let content = "version 1.0.0\nversion 2.0.0\nother stuff"; - let versions = parse_version_list(content, Some(r"version (\d+\.\d+\.\d+)"), None).unwrap(); + let versions = + parse_version_list(content, Some(r"version (\d+\.\d+\.\d+)"), None, None).unwrap(); assert_eq!(versions, vec!["1.0.0", "2.0.0"]); } #[test] fn test_parse_with_json_path_array_field() { let content = r#"{"data": {"versions": ["1.0.0", "2.0.0"]}}"#; - let versions = parse_version_list(content, None, Some(".data.versions[]")).unwrap(); + let versions = parse_version_list(content, None, Some(".data.versions[]"), None).unwrap(); assert_eq!(versions, vec!["1.0.0", "2.0.0"]); } #[test] fn test_parse_with_json_path_object_array() { let content = r#"[{"version": "1.0.0"}, {"version": "2.0.0"}]"#; - let versions = parse_version_list(content, None, Some(".[].version")).unwrap(); + let versions = parse_version_list(content, None, Some(".[].version"), None).unwrap(); assert_eq!(versions, vec!["1.0.0", "2.0.0"]); } @@ -192,28 +244,29 @@ mod tests { fn test_parse_with_json_path_nested() { let content = r#"{"releases": [{"info": {"version": "1.0.0"}}, {"info": {"version": "2.0.0"}}]}"#; - let versions = parse_version_list(content, None, Some(".releases[].info.version")).unwrap(); + let versions = + parse_version_list(content, None, Some(".releases[].info.version"), None).unwrap(); assert_eq!(versions, vec!["1.0.0", "2.0.0"]); } #[test] fn test_parse_removes_duplicates() { let content = "1.0.0\n1.0.0\n2.0.0\n2.0.0"; - let versions = parse_version_list(content, None, None).unwrap(); + let versions = parse_version_list(content, None, None, None).unwrap(); assert_eq!(versions, vec!["1.0.0", "2.0.0"]); } #[test] fn test_parse_empty_content() { let content = ""; - let versions = parse_version_list(content, None, None).unwrap(); + let versions = parse_version_list(content, None, None, None).unwrap(); assert!(versions.is_empty()); } #[test] fn test_parse_whitespace_only() { let content = " \n\n "; - let versions = parse_version_list(content, None, None).unwrap(); + let versions = parse_version_list(content, None, None, None).unwrap(); assert!(versions.is_empty()); } @@ -222,7 +275,7 @@ mod tests { // When version_json_path is provided but content is not valid JSON, // it should gracefully fall back to text parsing let content = "1.0.0\n2.0.0"; - let versions = parse_version_list(content, None, Some(".[].version")).unwrap(); + let versions = parse_version_list(content, None, Some(".[].version"), None).unwrap(); assert_eq!(versions, vec!["1.0.0", "2.0.0"]); } @@ -231,7 +284,7 @@ mod tests { // When version_json_path doesn't match the JSON structure, // it should fall back to text parsing let content = r#"{"other": "data"}"#; - let versions = parse_version_list(content, None, Some(".[].version")).unwrap(); + let versions = parse_version_list(content, None, Some(".[].version"), None).unwrap(); // Falls back to treating JSON as a single line of text assert_eq!(versions, vec![r#"{"other": "data"}"#]); } @@ -246,8 +299,22 @@ mod tests { {"version": "3.38.6", "channel": "stable"} ] }"#; - let versions = - parse_version_list(content, None, Some(".releases[?channel=stable].version")).unwrap(); + let versions = parse_version_list( + content, + None, + Some(".releases[?channel=stable].version"), + None, + ) + .unwrap(); assert_eq!(versions, vec!["3.38.7", "3.38.6"]); } + + #[test] + fn test_parse_with_version_expr_split() { + // Test version_expr with split function + let content = "1.0.0\n2.0.0\n3.0.0"; + let versions = + parse_version_list(content, None, None, Some(r#"split(body, "\n")"#)).unwrap(); + assert_eq!(versions, vec!["1.0.0", "2.0.0", "3.0.0"]); + } } diff --git a/src/output.rs b/src/output.rs index ed9213be2b..1932cd125a 100644 --- a/src/output.rs +++ b/src/output.rs @@ -158,6 +158,26 @@ macro_rules! deprecated { }}; } +/// Emits a deprecation warning only if the current mise version is >= the specified version. +/// Use this for gradual deprecations that should start warning at a future version. +/// +/// # Example +/// ```ignore +/// deprecated_at!("2026.3.0", "legacy-syntax", "Use {{version}} instead of {version}"); +/// ``` +#[macro_export] +macro_rules! deprecated_at { + ($version:tt, $id:tt, $($arg:tt)*) => {{ + use versions::Versioning; + let target = Versioning::new($version).expect("invalid version in deprecated_at!"); + if *$crate::cli::version::V >= target { + if $crate::output::DEPRECATED.lock().unwrap().insert($id) { + warn!("deprecated [{}]: {}", $id, format!($($arg)*)); + } + } + }}; +} + #[cfg(test)] pub mod tests { use std::sync::Mutex;