use anyhow::{Context, Result};
use base64::Engine;
use clap::{Parser, Subcommand, ValueEnum};
use std::{
collections::BTreeSet,
ffi::OsString,
fs,
io::{BufRead, BufReader, Read, Write},
net::{TcpListener, TcpStream},
path::{Path, PathBuf},
process::Command as ProcessCommand,
thread,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use jbx::{
alias_add, alias_remove, app_bin_dir, app_install, app_list, app_uninstall, build_java,
cache_entries, catalog_add, catalog_aliases, catalog_refs, catalog_templates, clear_cache,
default_cache_dir, export_jar, export_native, init_script, maven_tool, parse_directives,
resolve_catalog_alias, run_java, split_directive_words, trust_add, trust_clear, trust_entries,
trust_remove, AliasAddOptions, AliasRemoveOptions, AppInstallOptions, BuildOptions,
CatalogAddOptions, ExportKind, ExportOptions, InitOptions, KeyValue, NativeExportOptions,
ProgressOptions, RunOptions,
};
#[derive(Parser, Debug)]
#[command(
name = "jbx",
version,
about = "jbx: one-stop Java toolbox for scripts, tools, and agents"
)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
#[arg(long = "repo", alias = "repos")]
repos: Vec<String>,
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
#[arg(long = "main")]
main_class: Option<String>,
#[arg(long = "quiet", short = 'q')]
quiet: bool,
#[arg(long = "verbose", short = 'v')]
verbose: bool,
#[arg(long = "progress", value_enum, default_value_t = ProgressModeArg::Auto)]
progress: ProgressModeArg,
script: Option<PathBuf>,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
enum ProgressModeArg {
Auto,
Always,
Never,
}
impl From<ProgressModeArg> for jbx::ProgressMode {
fn from(value: ProgressModeArg) -> Self {
match value {
ProgressModeArg::Auto => jbx::ProgressMode::Auto,
ProgressModeArg::Always => jbx::ProgressMode::Always,
ProgressModeArg::Never => jbx::ProgressMode::Never,
}
}
}
fn progress_options(quiet: bool, verbose: bool, progress: ProgressModeArg) -> ProgressOptions {
ProgressOptions {
quiet,
verbose,
mode: progress.into(),
}
}
#[derive(Subcommand, Debug)]
enum Commands {
Run(RunCommand),
Build(BuildCommand),
Publish(PublishCommand),
Install(InstallCommand),
Docs(DocsCommand),
Check(CheckCommand),
Init(InitCommand),
Cache(CacheCommand),
Trust(TrustCommand),
Info(InfoCommand),
Doctor(DoctorCommand),
App(AppCommand),
Alias(AliasCommand),
Catalog(CatalogCommand),
Export(ExportCommand),
Template(TemplateCommand),
Resolve(ResolveCommand),
Fetch(FetchCommand),
Search(SearchCommand),
Test(TestCommand),
Fmt(FmtCommand),
Graph(GraphCommand),
Rewrite(RewriteCommand),
Skill(SkillCommand),
Jdk(JdkCommand),
}
#[derive(Parser, Debug)]
struct RunCommand {
#[arg(long = "quiet", short = 'q')]
quiet: bool,
#[arg(long = "verbose", short = 'v')]
verbose: bool,
#[arg(long = "progress", value_enum, default_value_t = ProgressModeArg::Auto)]
progress: ProgressModeArg,
#[arg(long = "deps")]
deps: Vec<String>,
#[arg(long = "repo", alias = "repos")]
repos: Vec<String>,
#[arg(long = "source", alias = "sources")]
sources: Vec<String>,
#[arg(long = "files", alias = "file")]
files: Vec<String>,
#[arg(long = "class-path", alias = "cp")]
classpath: Vec<PathBuf>,
#[arg(
long = "javac-option",
alias = "compile-option",
allow_hyphen_values = true
)]
javac_options: Vec<String>,
#[arg(
long = "runtime-option",
alias = "java-option",
allow_hyphen_values = true
)]
runtime_options: Vec<String>,
#[arg(long = "java")]
java_version: Option<String>,
#[arg(long = "javaagent")]
java_agents: Vec<String>,
#[arg(long = "main")]
main_class: Option<String>,
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
#[arg(long = "trust")]
trust: bool,
script: PathBuf,
#[arg(trailing_var_arg = true)]
args: Vec<String>,
}
#[derive(Parser, Debug)]
struct BuildCommand {
#[arg(long = "deps")]
deps: Vec<String>,
#[arg(long = "repo", alias = "repos")]
repos: Vec<String>,
#[arg(long = "source", alias = "sources")]
sources: Vec<String>,
#[arg(long = "files", alias = "file")]
files: Vec<String>,
#[arg(long = "class-path", alias = "cp")]
classpath: Vec<PathBuf>,
#[arg(
long = "javac-option",
alias = "compile-option",
allow_hyphen_values = true
)]
javac_options: Vec<String>,
#[arg(
long = "runtime-option",
alias = "java-option",
allow_hyphen_values = true
)]
runtime_options: Vec<String>,
#[arg(long = "java")]
java_version: Option<String>,
#[arg(long = "javaagent")]
java_agents: Vec<String>,
#[arg(long = "main")]
main_class: Option<String>,
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
#[arg(long = "trust")]
trust: bool,
script: PathBuf,
}
#[derive(Parser, Debug)]
struct DocsCommand {
target: String,
#[arg(long = "json")]
json: bool,
#[arg(long = "repo", alias = "repos")]
repos: Vec<String>,
#[arg(long = "type", alias = "types")]
types: Vec<String>,
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
}
#[derive(Parser, Debug)]
struct PublishCommand {
script: Option<PathBuf>,
#[arg(long = "file")]
file: Option<PathBuf>,
#[arg(long = "version")]
version: Option<String>,
#[arg(long = "output", short = 'o')]
output: Option<PathBuf>,
#[arg(long = "target-dir")]
target_dir: Option<PathBuf>,
#[arg(long = "package")]
package_name: Option<String>,
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
#[arg(long = "dry-run", conflicts_with = "publish")]
dry_run: bool,
#[arg(long = "skip-signing")]
skip_signing: bool,
#[arg(long = "gpg-key")]
gpg_key: Option<String>,
#[arg(long = "publish")]
publish: bool,
#[arg(long = "serve", conflicts_with_all = ["dry_run", "publish"])]
serve: Option<u16>,
#[arg(long = "publishing-type", default_value = "automatic")]
publishing_type: CentralPublishingType,
#[arg(long = "central-url", hide = true)]
central_url: Option<String>,
#[arg(long = "no-wait")]
no_wait: bool,
#[arg(long = "poll-interval", default_value_t = 5, hide = true)]
poll_interval: u64,
#[arg(long = "max-wait-seconds", default_value_t = 600)]
max_wait_seconds: u64,
}
#[derive(Parser, Debug)]
struct InstallCommand {
script: Option<PathBuf>,
#[arg(long = "file")]
file: Option<PathBuf>,
#[arg(long = "version")]
version: Option<String>,
#[arg(long = "destination", alias = "to")]
destination: Option<PathBuf>,
#[arg(long = "target-dir")]
target_dir: Option<PathBuf>,
#[arg(long = "package")]
package_name: Option<String>,
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
enum CentralPublishingType {
Automatic,
UserManaged,
}
impl CentralPublishingType {
fn as_query_value(self) -> &'static str {
match self {
CentralPublishingType::Automatic => "AUTOMATIC",
CentralPublishingType::UserManaged => "USER_MANAGED",
}
}
}
#[derive(Parser, Debug)]
struct CheckCommand {
#[arg(long = "json")]
json: bool,
#[arg(long = "no-error-prone")]
no_error_prone: bool,
#[arg(long = "error-prone-version", default_value = DEFAULT_ERROR_PRONE_VERSION)]
error_prone_version: String,
#[arg(long = "warnings-as-errors", alias = "Werror")]
warnings_as_errors: bool,
#[arg(long = "deps")]
deps: Vec<String>,
#[arg(long = "repo", alias = "repos")]
repos: Vec<String>,
#[arg(long = "class-path", alias = "cp")]
classpath: Vec<PathBuf>,
#[arg(
long = "javac-option",
alias = "compile-option",
allow_hyphen_values = true
)]
javac_options: Vec<String>,
#[arg(long = "java")]
java_version: Option<String>,
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
#[arg(default_value = ".")]
paths: Vec<PathBuf>,
}
#[derive(Parser, Debug)]
struct TestCommand {
#[arg(long = "json", conflicts_with = "xml")]
json: bool,
#[arg(long = "xml", conflicts_with = "json")]
xml: bool,
#[arg(long = "coverage")]
coverage: bool,
#[arg(long = "jacoco-version", requires = "coverage")]
jacoco_version: Option<String>,
#[arg(long = "junit-version")]
junit_version: Option<String>,
#[arg(long = "deps")]
deps: Vec<String>,
#[arg(long = "repo", alias = "repos")]
repos: Vec<String>,
#[arg(long = "source", alias = "sources")]
sources: Vec<String>,
#[arg(long = "files", alias = "file")]
files: Vec<String>,
#[arg(long = "class-path", alias = "cp")]
classpath: Vec<PathBuf>,
#[arg(
long = "javac-option",
alias = "compile-option",
allow_hyphen_values = true
)]
javac_options: Vec<String>,
#[arg(
long = "runtime-option",
alias = "java-option",
allow_hyphen_values = true
)]
runtime_options: Vec<String>,
#[arg(long = "java")]
java_version: Option<String>,
#[arg(long = "javaagent")]
java_agents: Vec<String>,
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
#[arg(long = "trust")]
trust: bool,
#[arg(default_value = ".")]
script: PathBuf,
#[arg(trailing_var_arg = true)]
args: Vec<String>,
}
#[derive(Parser, Debug)]
struct FmtCommand {
#[arg(long = "check")]
check: bool,
#[arg(long = "formatter-version")]
formatter_version: Option<String>,
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
#[arg(default_value = ".")]
paths: Vec<PathBuf>,
}
#[derive(Parser, Debug)]
struct DoctorCommand {
#[arg(long = "json")]
json: bool,
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
#[arg(long = "repo", alias = "repos")]
repos: Vec<String>,
#[arg(long = "publish")]
publish: bool,
#[arg(long = "native")]
native: bool,
target: Option<String>,
}
#[derive(Parser, Debug)]
struct GraphCommand {
#[command(subcommand)]
command: GraphSubcommand,
}
#[derive(Parser, Debug)]
struct RewriteCommand {
#[command(subcommand)]
command: RewriteSubcommand,
}
#[derive(Subcommand, Debug)]
enum RewriteSubcommand {
Apply(RewriteRunCommand),
Patch(RewriteRunCommand),
Modules(RewriteModulesCommand),
Recipes(RewriteRecipesCommand),
}
#[derive(Parser, Debug)]
struct RewriteRunCommand {
#[arg(long = "recipe", value_delimiter = ',')]
recipes: Vec<String>,
#[arg(long = "module", value_delimiter = ',')]
modules: Vec<String>,
#[arg(long = "source")]
sources: Vec<PathBuf>,
#[arg(long = "option")]
options: Vec<String>,
#[arg(long = "report", default_value = "rewrite")]
report: PathBuf,
#[arg(long = "json")]
json: bool,
#[arg(long = "fail-on-changes")]
fail_on_changes: bool,
#[arg(long = "no-fail-on-invalid-recipes")]
no_fail_on_invalid_recipes: bool,
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
#[arg(long = "repo", alias = "repos")]
repos: Vec<String>,
#[arg(long = "rewrite-version", default_value = DEFAULT_OPENREWRITE_VERSION)]
rewrite_version: String,
}
#[derive(Parser, Debug)]
struct RewriteModulesCommand {
#[arg(long = "search")]
search: Option<String>,
#[arg(long = "group")]
groups: Vec<String>,
#[arg(long = "limit")]
limit: Option<usize>,
#[arg(long = "json")]
json: bool,
#[arg(long = "rewrite-version")]
rewrite_version: Option<String>,
}
#[derive(Parser, Debug)]
struct RewriteRecipesCommand {
module: String,
#[arg(long = "search")]
search: Option<String>,
#[arg(long = "limit")]
limit: Option<usize>,
#[arg(long = "detail")]
detail: bool,
#[arg(long = "json")]
json: bool,
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
#[arg(long = "repo", alias = "repos")]
repos: Vec<String>,
#[arg(long = "rewrite-version", default_value = DEFAULT_OPENREWRITE_VERSION)]
rewrite_version: String,
}
#[derive(Subcommand, Debug)]
enum GraphSubcommand {
Dump(GraphDumpCommand),
Import(GraphImportCommand),
}
#[derive(Parser, Debug)]
struct GraphDumpCommand {
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
script: PathBuf,
}
#[derive(Parser, Debug)]
struct GraphImportCommand {
#[arg(long = "output", short = 'o')]
output: Option<PathBuf>,
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
json: PathBuf,
}
#[derive(Parser, Debug)]
struct InitCommand {
#[arg(long = "template", short = 't')]
template: Option<String>,
#[arg(long = "force")]
force: bool,
#[arg(long = "java")]
java_version: Option<String>,
#[arg(long = "deps")]
deps: Vec<String>,
script: PathBuf,
}
#[derive(Parser, Debug)]
struct CacheCommand {
#[command(subcommand)]
command: CacheSubcommand,
}
#[derive(Subcommand, Debug)]
enum CacheSubcommand {
Clear(CacheClearCommand),
Path(CachePathCommand),
List(CacheListCommand),
}
#[derive(Parser, Debug)]
struct TrustCommand {
#[command(subcommand)]
command: TrustSubcommand,
}
#[derive(Subcommand, Debug)]
enum TrustSubcommand {
Add(TrustUrlCommand),
Remove(TrustUrlCommand),
List(TrustListCommand),
Clear(TrustListCommand),
}
#[derive(Parser, Debug)]
struct TrustUrlCommand {
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
url: String,
}
#[derive(Parser, Debug)]
struct TrustListCommand {
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
}
#[derive(Parser, Debug)]
struct AppCommand {
#[command(subcommand)]
command: AppSubcommand,
}
#[derive(Subcommand, Debug)]
enum AppSubcommand {
Install(AppInstallCommand),
Uninstall(AppUninstallCommand),
List(AppListCommand),
}
#[derive(Parser, Debug)]
struct AppInstallCommand {
#[arg(long = "name", short = 'n')]
name: Option<String>,
#[arg(long = "force")]
force: bool,
script: PathBuf,
}
#[derive(Parser, Debug)]
struct AppUninstallCommand {
name: String,
}
#[derive(Parser, Debug)]
struct AppListCommand;
#[derive(Parser, Debug)]
struct SkillCommand {
#[command(subcommand)]
command: SkillSubcommand,
}
#[derive(Subcommand, Debug)]
enum SkillSubcommand {
List(SkillListCommand),
Get(SkillGetCommand),
}
#[derive(Parser, Debug)]
struct SkillListCommand {
#[arg(long = "json")]
json: bool,
}
#[derive(Parser, Debug)]
struct SkillGetCommand {
name: Option<String>,
}
#[derive(Parser, Debug)]
struct JdkCommand {
#[command(subcommand)]
command: JdkSubcommand,
}
#[derive(Subcommand, Debug)]
enum JdkSubcommand {
List(JdkListCommand),
Install(JdkInstallCommand),
Home(JdkHomeCommand),
}
#[derive(Parser, Debug)]
struct JdkListCommand;
#[derive(Parser, Debug)]
struct JdkInstallCommand {
version: String,
}
#[derive(Parser, Debug)]
struct JdkHomeCommand {
#[arg(default_value = "25")]
version: String,
}
#[derive(Parser, Debug)]
struct AliasCommand {
#[command(subcommand)]
command: AliasSubcommand,
}
#[derive(Subcommand, Debug)]
enum AliasSubcommand {
Add(Box<AliasAddCommand>),
Remove(AliasRemoveCommand),
List(AliasListCommand),
}
#[derive(Parser, Debug)]
struct AliasCatalogOptions {
#[arg(long = "global", short = 'g', conflicts_with = "file")]
global: bool,
#[arg(long = "file", short = 'f')]
file: Option<PathBuf>,
}
#[derive(Parser, Debug)]
struct AliasAddCommand {
#[command(flatten)]
catalog: AliasCatalogOptions,
#[arg(long = "name")]
name: Option<String>,
#[arg(long = "description")]
description: Option<String>,
#[arg(long = "force")]
force: bool,
#[arg(long = "deps")]
deps: Vec<String>,
#[arg(long = "repo", alias = "repos")]
repos: Vec<String>,
#[arg(long = "source", alias = "sources")]
sources: Vec<String>,
#[arg(long = "files")]
files: Vec<String>,
#[arg(long = "class-path", alias = "cp")]
classpath: Vec<PathBuf>,
#[arg(
long = "javac-option",
alias = "compile-option",
allow_hyphen_values = true
)]
javac_options: Vec<String>,
#[arg(
long = "runtime-option",
alias = "java-option",
allow_hyphen_values = true
)]
runtime_options: Vec<String>,
#[arg(long = "java")]
java_version: Option<String>,
#[arg(long = "javaagent")]
java_agents: Vec<String>,
#[arg(long = "main")]
main_class: Option<String>,
#[arg(long = "docs")]
docs: Vec<String>,
script: String,
#[arg(trailing_var_arg = true)]
args: Vec<String>,
}
#[derive(Parser, Debug)]
struct AliasRemoveCommand {
#[command(flatten)]
catalog: AliasCatalogOptions,
name: String,
}
#[derive(Parser, Debug)]
struct AliasListCommand {
#[arg(long = "json")]
json: bool,
}
#[derive(Parser, Debug)]
struct CatalogCommand {
#[command(subcommand)]
command: CatalogSubcommand,
}
#[derive(Subcommand, Debug)]
enum CatalogSubcommand {
Add(CatalogAddCommand),
List(CatalogListCommand),
}
#[derive(Parser, Debug)]
struct CatalogAddCommand {
#[command(flatten)]
catalog: AliasCatalogOptions,
name: String,
catalog_ref: String,
#[arg(long = "description")]
description: Option<String>,
#[arg(long = "import")]
import_items: bool,
#[arg(long = "force")]
force: bool,
}
#[derive(Parser, Debug)]
struct CatalogListCommand {
#[arg(long = "json")]
json: bool,
}
#[derive(Parser, Debug)]
struct TemplateCommand {
#[command(subcommand)]
command: TemplateSubcommand,
}
#[derive(Subcommand, Debug)]
enum TemplateSubcommand {
List(TemplateListCommand),
}
#[derive(Parser, Debug)]
struct TemplateListCommand {
#[arg(long = "json")]
json: bool,
}
#[derive(Parser, Debug)]
struct ExportCommand {
#[command(subcommand)]
command: ExportSubcommand,
}
#[derive(Subcommand, Debug)]
enum ExportSubcommand {
Local(ExportJarCommand),
Portable(ExportJarCommand),
Native(ExportNativeCommand),
}
#[derive(Parser, Debug)]
struct ExportJarCommand {
#[arg(long = "output", short = 'o')]
output: Option<PathBuf>,
#[arg(long = "force")]
force: bool,
#[arg(long = "deps")]
deps: Vec<String>,
#[arg(long = "repo", alias = "repos")]
repos: Vec<String>,
#[arg(long = "source", alias = "sources")]
sources: Vec<String>,
#[arg(long = "files", alias = "file")]
files: Vec<String>,
#[arg(long = "class-path", alias = "cp")]
classpath: Vec<PathBuf>,
#[arg(
long = "javac-option",
alias = "compile-option",
allow_hyphen_values = true
)]
javac_options: Vec<String>,
#[arg(
long = "runtime-option",
alias = "java-option",
allow_hyphen_values = true
)]
runtime_options: Vec<String>,
#[arg(long = "java")]
java_version: Option<String>,
#[arg(long = "javaagent")]
java_agents: Vec<String>,
#[arg(long = "main")]
main_class: Option<String>,
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
#[arg(long = "trust")]
trust: bool,
script: PathBuf,
}
#[derive(Parser, Debug)]
struct ExportNativeCommand {
#[arg(long = "output", short = 'o')]
output: Option<PathBuf>,
#[arg(long = "force")]
force: bool,
#[arg(long = "native-image")]
native_image: Option<PathBuf>,
#[arg(
long = "native-option",
alias = "native-options",
allow_hyphen_values = true
)]
native_options: Vec<String>,
#[arg(long = "deps")]
deps: Vec<String>,
#[arg(long = "repo", alias = "repos")]
repos: Vec<String>,
#[arg(long = "source", alias = "sources")]
sources: Vec<String>,
#[arg(long = "files", alias = "file")]
files: Vec<String>,
#[arg(long = "class-path", alias = "cp")]
classpath: Vec<PathBuf>,
#[arg(
long = "javac-option",
alias = "compile-option",
allow_hyphen_values = true
)]
javac_options: Vec<String>,
#[arg(
long = "runtime-option",
alias = "java-option",
allow_hyphen_values = true
)]
runtime_options: Vec<String>,
#[arg(long = "java")]
java_version: Option<String>,
#[arg(long = "javaagent")]
java_agents: Vec<String>,
#[arg(long = "main")]
main_class: Option<String>,
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
#[arg(long = "trust")]
trust: bool,
script: PathBuf,
}
#[derive(Parser, Debug)]
struct ResolveCommand {
#[arg(required = true)]
coordinates: Vec<String>,
#[arg(long = "repo", alias = "repos")]
repos: Vec<String>,
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
#[arg(long = "classpath", short = 'c')]
classpath: bool,
}
#[derive(Parser, Debug)]
struct FetchCommand {
#[arg(required = true)]
coordinates: Vec<String>,
#[arg(long = "repo", alias = "repos")]
repos: Vec<String>,
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
#[arg(long = "deps-only")]
deps_only: bool,
}
#[derive(Parser, Debug)]
struct SearchCommand {
query: Vec<String>,
#[arg(long = "group")]
group: Option<String>,
#[arg(long = "id", alias = "artifact", alias = "artifact-id")]
id: Option<String>,
#[arg(long = "version")]
version: Option<String>,
#[arg(long = "limit", short = 'n', default_value_t = 20)]
limit: usize,
#[arg(long = "json")]
json: bool,
}
#[derive(Parser, Debug)]
struct CacheClearCommand {
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
}
#[derive(Parser, Debug)]
struct CachePathCommand {
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
}
#[derive(Parser, Debug)]
struct CacheListCommand {
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
#[arg(long = "json")]
json: bool,
}
#[derive(Parser, Debug)]
struct InfoCommand {
#[command(subcommand)]
command: InfoSubcommand,
}
#[derive(Subcommand, Debug)]
enum InfoSubcommand {
Classpath(InfoClasspathCommand),
Tools(InfoToolsCommand),
Docs(InfoDocsCommand),
Cache(InfoCacheCommand),
Main(InfoScriptCommand),
Java(InfoScriptCommand),
Description(InfoScriptCommand),
Gav(InfoScriptCommand),
Module(InfoScriptCommand),
Deps(InfoScriptCommand),
Repos(InfoScriptCommand),
Sources(InfoScriptCommand),
Files(InfoScriptCommand),
CompileOptions(InfoScriptCommand),
RuntimeOptions(InfoScriptCommand),
NativeOptions(InfoScriptCommand),
Javaagents(InfoScriptCommand),
Manifest(InfoScriptCommand),
Directives(InfoDirectivesCommand),
}
#[derive(Parser, Debug)]
struct InfoClasspathCommand {
#[arg(long = "deps-only")]
deps_only: bool,
#[arg(long = "deps")]
deps: Vec<String>,
#[arg(long = "repo", alias = "repos")]
repos: Vec<String>,
#[arg(long = "source", alias = "sources")]
sources: Vec<String>,
#[arg(long = "files", alias = "file")]
files: Vec<String>,
#[arg(long = "class-path", alias = "cp")]
classpath: Vec<PathBuf>,
#[arg(
long = "javac-option",
alias = "compile-option",
allow_hyphen_values = true
)]
javac_options: Vec<String>,
#[arg(
long = "runtime-option",
alias = "java-option",
allow_hyphen_values = true
)]
runtime_options: Vec<String>,
#[arg(long = "java")]
java_version: Option<String>,
#[arg(long = "javaagent")]
java_agents: Vec<String>,
#[arg(long = "main")]
main_class: Option<String>,
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
script: PathBuf,
}
#[derive(Parser, Debug)]
struct InfoToolsCommand {
#[arg(long = "select")]
select: Option<String>,
#[arg(long = "deps")]
deps: Vec<String>,
#[arg(long = "repo", alias = "repos")]
repos: Vec<String>,
#[arg(long = "source", alias = "sources")]
sources: Vec<String>,
#[arg(long = "files", alias = "file")]
files: Vec<String>,
#[arg(long = "class-path", alias = "cp")]
classpath: Vec<PathBuf>,
#[arg(
long = "javac-option",
alias = "compile-option",
allow_hyphen_values = true
)]
javac_options: Vec<String>,
#[arg(
long = "runtime-option",
alias = "java-option",
allow_hyphen_values = true
)]
runtime_options: Vec<String>,
#[arg(long = "java")]
java_version: Option<String>,
#[arg(long = "javaagent")]
java_agents: Vec<String>,
#[arg(long = "main")]
main_class: Option<String>,
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
script: PathBuf,
}
#[derive(Parser, Debug)]
struct InfoDocsCommand {
script: PathBuf,
}
#[derive(Parser, Debug)]
struct InfoCacheCommand {
#[arg(long = "cache-dir")]
cache_dir: Option<PathBuf>,
}
#[derive(Parser, Debug)]
struct InfoScriptCommand {
script: PathBuf,
}
#[derive(Parser, Debug)]
struct InfoDirectivesCommand {
script: PathBuf,
}
fn repo_json(repo: &str) -> serde_json::Value {
match repo.split_once('=') {
Some((id, url)) => serde_json::json!({ "id": id, "url": url }),
None => serde_json::json!({ "id": null, "url": repo }),
}
}
fn key_values_json(values: &[jbx::KeyValue]) -> serde_json::Value {
serde_json::Value::Array(
values
.iter()
.map(|kv| serde_json::json!({ "key": kv.key, "value": kv.value }))
.collect(),
)
}
fn docs_json(values: &[jbx::KeyValue]) -> serde_json::Value {
let mut map = serde_json::Map::new();
for kv in values {
let (id, target) = match &kv.value {
Some(value) => (kv.key.clone(), value.clone()),
None => ("main".to_string(), kv.key.clone()),
};
let entry = map
.entry(id)
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
if let serde_json::Value::Array(items) = entry {
items.push(serde_json::json!({ "originalResource": target }));
}
}
serde_json::Value::Object(map)
}
fn print_lines(values: &[String]) {
for value in values {
println!("{value}");
}
}
fn print_key_values(values: &[KeyValue]) {
for value in values {
match &value.value {
Some(v) => println!("{}={}", value.key, v),
None => println!("{}", value.key),
}
}
}
fn split_cli_words(values: &[String]) -> Vec<String> {
values
.iter()
.flat_map(|value| split_directive_words(value))
.collect()
}
fn split_cli_key_values(values: &[String]) -> Vec<KeyValue> {
split_cli_words(values)
.into_iter()
.map(|value| KeyValue::parse(&value))
.collect()
}
fn native_export_options(cmd: ExportNativeCommand) -> NativeExportOptions {
NativeExportOptions {
script: cmd.script,
output: cmd.output,
force: cmd.force,
native_image: cmd.native_image,
extra_native_options: split_cli_words(&cmd.native_options),
extra_deps: split_cli_words(&cmd.deps),
extra_repos: split_cli_words(&cmd.repos),
extra_sources: split_cli_words(&cmd.sources),
extra_files: split_cli_words(&cmd.files),
classpath: cmd.classpath,
javac_options: cmd.javac_options,
runtime_options: cmd.runtime_options,
java_agents: split_cli_key_values(&cmd.java_agents),
java_version: cmd.java_version,
main_class: cmd.main_class,
cache_dir: cmd.cache_dir,
trust_remote: cmd.trust,
}
}
fn export_options(cmd: ExportJarCommand, kind: ExportKind) -> ExportOptions {
ExportOptions {
script: cmd.script,
output: cmd.output,
force: cmd.force,
kind,
extra_deps: split_cli_words(&cmd.deps),
extra_repos: split_cli_words(&cmd.repos),
extra_sources: split_cli_words(&cmd.sources),
extra_files: split_cli_words(&cmd.files),
classpath: cmd.classpath,
javac_options: cmd.javac_options,
runtime_options: cmd.runtime_options,
java_agents: split_cli_key_values(&cmd.java_agents),
java_version: cmd.java_version,
main_class: cmd.main_class,
cache_dir: cmd.cache_dir,
trust_remote: cmd.trust,
}
}
fn print_required(value: Option<&str>, missing: &str) -> Result<()> {
let Some(value) = value else {
anyhow::bail!("{missing}");
};
println!("{value}");
Ok(())
}
fn parsed_directives(script: &PathBuf) -> Result<jbx::Directives> {
let source = fs::read_to_string(script)?;
Ok(jbx::parse_directives(&source))
}
#[derive(Debug, Clone)]
struct DocsCoordinate {
group: String,
id: String,
version: String,
}
fn run_docs(cmd: DocsCommand) -> Result<i32> {
let target_path = PathBuf::from(&cmd.target);
let output = if target_path.exists() {
render_local_docs(&target_path, cmd.json, &cmd.types)?
} else if looks_like_docs_coordinate(&cmd.target) {
fetch_remote_docs(&cmd)?
} else {
anyhow::bail!("docs target not found: {}", cmd.target);
};
print!("{output}");
Ok(0)
}
fn looks_like_docs_coordinate(value: &str) -> bool {
let part_count = value.split(':').count();
!value.starts_with("http://")
&& !value.starts_with("https://")
&& (part_count == 2 || part_count == 3)
&& value.split(':').all(|part| !part.is_empty())
}
fn parse_docs_coordinate(
value: &str,
repos: &[jbx::resolver::Repository],
) -> Result<DocsCoordinate> {
let parts = value.split(':').collect::<Vec<_>>();
if parts.len() != 2 && parts.len() != 3 {
anyhow::bail!(
"docs requires Maven coordinates as group:artifact or group:artifact:version"
);
}
let group = parts[0].to_string();
let id = parts[1].to_string();
validate_group(&group)?;
validate_path_safe_coordinate_part(&id, "id")?;
let version = match parts.get(2) {
Some(version) => {
validate_path_safe_coordinate_part(version, "version")?;
(*version).to_string()
}
None => jbx::resolver::resolve_latest_version(
&jbx::resolver::Module {
org: group.clone(),
name: id.clone(),
},
repos,
)?,
};
Ok(DocsCoordinate { group, id, version })
}
fn render_local_docs(path: &Path, json: bool, type_filters: &[String]) -> Result<String> {
if is_jar_file(path) {
return render_jar_docs(path, json, type_filters);
}
let sources = collect_java_files(&[path.to_path_buf()])?;
if sources.is_empty() {
anyhow::bail!(
"docs target contains no Java source files: {}",
path.display()
);
}
let docs = sources
.iter()
.map(|source| docs_source_json(source, None))
.collect::<Result<Vec<_>>>()?;
let types = filter_docs_types(extract_docs_types_from_sources(&sources)?, type_filters);
let title = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_else(|| path.to_str().unwrap_or("docs"));
if json {
Ok(format!(
"{}\n",
serde_json::to_string_pretty(&serde_json::json!({
"schema": "https://jbx.telegraphic.dev/schemas/jbx-docs/v1.json",
"target": path.to_string_lossy(),
"sources": docs,
"types": types,
"generatedFrom": {
"source": "jbx-directives",
"jbxVersion": env!("CARGO_PKG_VERSION"),
}
}))?
))
} else {
render_docs_markdown(title, None, &docs, &types)
}
}
fn docs_source_json(source: &Path, artifact: Option<&DocsCoordinate>) -> Result<serde_json::Value> {
let directives = parsed_directives(&source.to_path_buf())?;
let name = source
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("source");
let docs = directives
.docs
.iter()
.map(|doc| match &doc.value {
Some(value) => serde_json::json!({"key": doc.key, "value": value}),
None => serde_json::json!({"key": doc.key}),
})
.collect::<Vec<_>>();
Ok(serde_json::json!({
"path": source.to_string_lossy(),
"name": name,
"description": directives.description,
"docs": docs,
"dependencies": directives.deps,
"repositories": directives.repos,
"sources": directives.sources,
"java": directives.java_version,
"main": directives.main_class,
"artifact": artifact.map(|coordinate| serde_json::json!({
"group": coordinate.group,
"id": coordinate.id,
"version": coordinate.version,
"coordinate": format!("{}:{}:{}", coordinate.group, coordinate.id, coordinate.version),
})),
}))
}
fn render_docs_markdown(
title: &str,
artifact: Option<&DocsCoordinate>,
sources: &[serde_json::Value],
types: &[serde_json::Value],
) -> Result<String> {
let mut out = String::new();
out.push_str(&format!("# {title}\n\n"));
if let Some(coordinate) = artifact {
out.push_str(&format!(
"Artifact: `{}:{}:{}`\n\n",
coordinate.group, coordinate.id, coordinate.version
));
}
for source in sources {
let name = source
.get("name")
.and_then(|value| value.as_str())
.unwrap_or("source");
if sources.len() > 1 || artifact.is_some() {
out.push_str(&format!("## {name}\n\n"));
}
if let Some(description) = source.get("description").and_then(|value| value.as_str()) {
out.push_str(description);
out.push_str("\n\n");
}
if let Some(docs) = source.get("docs").and_then(|value| value.as_array()) {
for doc in docs {
let key = doc
.get("key")
.and_then(|value| value.as_str())
.unwrap_or_default();
match doc.get("value").and_then(|value| value.as_str()) {
Some(value) => out.push_str(&format!("- {key}: {value}\n")),
None => out.push_str(&format!("- {key}\n")),
}
}
if !docs.is_empty() {
out.push('\n');
}
}
if let Some(deps) = source
.get("dependencies")
.and_then(|value| value.as_array())
{
if !deps.is_empty() {
out.push_str("Dependencies:\n");
for dep in deps.iter().filter_map(|value| value.as_str()) {
out.push_str(&format!("- `{dep}`\n"));
}
out.push('\n');
}
}
}
for ty in types {
if let Some(qualified_name) = ty.get("qualifiedName").and_then(|value| value.as_str()) {
out.push_str(&format!("## `{qualified_name}`\n\n"));
if let Some(kind) = ty.get("kind").and_then(|value| value.as_str()) {
out.push_str(&format!("Kind: {kind}\n\n"));
}
if let Some(description) = ty.get("description").and_then(|value| value.as_str()) {
if !description.is_empty() {
out.push_str(description);
out.push_str("\n\n");
}
}
render_examples_section(&mut out, ty.get("examples"));
render_member_section(&mut out, "Fields", ty.get("fields"), render_field_signature);
render_member_section(
&mut out,
"Constructors",
ty.get("constructors"),
render_constructor_signature,
);
render_member_section(
&mut out,
"Methods",
ty.get("methods"),
render_method_signature,
);
}
}
Ok(out)
}
fn render_examples_section(out: &mut String, value: Option<&serde_json::Value>) {
let Some(examples) = value.and_then(|value| value.as_array()) else {
return;
};
let examples = examples
.iter()
.filter_map(|value| value.as_str())
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>();
if examples.is_empty() {
return;
}
out.push_str("### Examples\n\n");
for example in examples {
out.push_str("```java\n");
out.push_str(example.trim());
out.push_str("\n```\n\n");
}
}
fn render_member_section(
out: &mut String,
title: &str,
value: Option<&serde_json::Value>,
render: fn(&serde_json::Value) -> Option<String>,
) {
let Some(members) = value.and_then(|value| value.as_array()) else {
return;
};
if members.iter().filter_map(render).next().is_none() {
return;
}
out.push_str(&format!("### {title}\n\n"));
for member in members {
let Some(signature) = render(member) else {
continue;
};
out.push_str(&format!("- `{signature}`"));
if let Some(description) = member.get("description").and_then(|value| value.as_str()) {
if !description.trim().is_empty() {
out.push_str(&format!(" — {}", inline_markdown(description)));
}
}
out.push('\n');
render_parameter_descriptions(out, member);
if let Some(description) = member
.get("returnDescription")
.and_then(|value| value.as_str())
{
if !description.trim().is_empty() {
out.push_str(&format!(" - Returns: {}\n", inline_markdown(description)));
}
}
}
out.push('\n');
}
fn render_parameter_descriptions(out: &mut String, member: &serde_json::Value) {
let Some(parameters) = member.get("parameters").and_then(|value| value.as_array()) else {
return;
};
for parameter in parameters {
let Some(description) = parameter
.get("description")
.and_then(|value| value.as_str())
else {
continue;
};
if description.trim().is_empty() {
continue;
}
let name = parameter
.get("name")
.and_then(|value| value.as_str())
.unwrap_or("parameter");
out.push_str(&format!(" - `{name}`: {}\n", inline_markdown(description)));
}
}
fn inline_markdown(markdown: &str) -> String {
markdown.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn render_field_signature(field: &serde_json::Value) -> Option<String> {
let name = field.get("name")?.as_str()?;
let ty = field.get("type")?.as_str()?;
Some(join_signature_parts(&[
member_modifiers(field),
ty.to_string(),
name.to_string(),
]))
}
fn render_constructor_signature(constructor: &serde_json::Value) -> Option<String> {
let name = constructor.get("name")?.as_str()?;
Some(render_callable_signature(None, name, constructor))
}
fn render_method_signature(method: &serde_json::Value) -> Option<String> {
let name = method.get("name")?.as_str()?;
let return_type = method.get("returnType")?.as_str().unwrap_or("void");
Some(render_callable_signature(Some(return_type), name, method))
}
fn render_callable_signature(
return_type: Option<&str>,
name: &str,
member: &serde_json::Value,
) -> String {
let mut head = Vec::new();
if let Some(return_type) = return_type {
head.push(return_type.to_string());
}
head.push(format!(
"{name}({})",
render_parameters(member.get("parameters"))
));
let mut signature = join_signature_parts(&head);
if let Some(throws) = render_throws(member.get("throws")) {
signature.push_str(" throws ");
signature.push_str(&throws);
}
signature
}
fn member_modifiers(member: &serde_json::Value) -> String {
member
.get("modifiers")
.and_then(|value| value.as_array())
.map(|values| {
values
.iter()
.filter_map(|value| value.as_str())
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default()
}
fn render_parameters(value: Option<&serde_json::Value>) -> String {
value
.and_then(|value| value.as_array())
.map(|params| {
params
.iter()
.filter_map(|param| {
Some(format!(
"{} {}",
param.get("type")?.as_str()?,
param.get("name")?.as_str()?
))
})
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default()
}
fn render_throws(value: Option<&serde_json::Value>) -> Option<String> {
let throws = value
.and_then(|value| value.as_array())?
.iter()
.filter_map(|value| value.as_str())
.collect::<Vec<_>>();
(!throws.is_empty()).then(|| throws.join(", "))
}
fn join_signature_parts(parts: &[String]) -> String {
parts
.iter()
.filter(|part| !part.is_empty())
.cloned()
.collect::<Vec<_>>()
.join(" ")
}
fn is_jar_file(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("jar"))
}
fn render_jar_docs(path: &Path, json: bool, type_filters: &[String]) -> Result<String> {
let docs_source = if find_javadoc_jar(path).is_some() {
"javadoc"
} else {
"javap"
};
let types = filter_docs_types(extract_docs_types_from_jar(path)?, type_filters);
let title = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_else(|| path.to_str().unwrap_or("jar"));
if json {
Ok(format!(
"{}\n",
serde_json::to_string_pretty(&serde_json::json!({
"schema": "https://jbx.telegraphic.dev/schemas/jbx-docs/v1.json",
"target": path.to_string_lossy(),
"types": types,
"generatedFrom": {
"source": docs_source,
"jbxVersion": env!("CARGO_PKG_VERSION"),
}
}))?
))
} else {
render_docs_markdown(title, None, &[], &types)
}
}
fn extract_docs_types_from_sources(sources: &[PathBuf]) -> Result<Vec<serde_json::Value>> {
let mut types = Vec::new();
for source in sources {
let content = fs::read_to_string(source)
.with_context(|| format!("failed to read Java source {}", source.display()))?;
types.extend(extract_docs_types_from_source(&content));
}
Ok(types)
}
fn extract_docs_types_from_source(content: &str) -> Vec<serde_json::Value> {
let package = parse_java_package(content).unwrap_or_default();
let mut types = Vec::new();
let mut pending_annotations = Vec::new();
let mut current: Option<DocsTypeBuilder> = None;
let mut brace_depth = 0_i32;
for raw_line in content.lines() {
let line = raw_line.trim();
if line.is_empty()
|| line.starts_with("//")
|| line.starts_with("/*")
|| line.starts_with('*')
{
continue;
}
if line.starts_with('@') {
pending_annotations.push(parse_annotation(line, &package));
continue;
}
if current.is_none() {
if let Some(builder) =
parse_type_declaration(line, &package, std::mem::take(&mut pending_annotations))
{
current = Some(builder);
brace_depth += count_char(line, '{') - count_char(line, '}');
} else {
pending_annotations.clear();
}
continue;
}
brace_depth += count_char(line, '{') - count_char(line, '}');
if let Some(builder) = current.as_mut() {
if let Some(member) = parse_member_declaration(
line,
&package,
&builder.name,
std::mem::take(&mut pending_annotations),
) {
builder.push_member(member);
} else if !line.starts_with('@') {
pending_annotations.clear();
}
}
if brace_depth <= 0 {
if let Some(builder) = current.take() {
types.push(builder.into_json());
}
pending_annotations.clear();
brace_depth = 0;
}
}
if let Some(builder) = current {
types.push(builder.into_json());
}
types
}
#[derive(Debug)]
struct DocsTypeBuilder {
kind: String,
name: String,
qualified_name: String,
package: String,
visibility: String,
modifiers: Vec<String>,
annotations: Vec<serde_json::Value>,
description: Option<String>,
examples: Vec<String>,
extends: Option<String>,
implements: Vec<String>,
fields: Vec<serde_json::Value>,
constructors: Vec<serde_json::Value>,
methods: Vec<serde_json::Value>,
}
impl DocsTypeBuilder {
fn push_member(&mut self, member: DocsMember) {
match member {
DocsMember::Field(value) => self.fields.push(value),
DocsMember::Constructor(value) => self.constructors.push(value),
DocsMember::Method(value) => self.methods.push(value),
}
}
fn into_json(self) -> serde_json::Value {
serde_json::json!({
"kind": self.kind,
"name": self.name,
"qualifiedName": self.qualified_name,
"package": self.package,
"visibility": self.visibility,
"modifiers": self.modifiers,
"annotations": self.annotations,
"description": self.description,
"examples": self.examples,
"extends": self.extends,
"implements": self.implements,
"fields": self.fields,
"constructors": self.constructors,
"methods": self.methods,
})
}
}
enum DocsMember {
Field(serde_json::Value),
Constructor(serde_json::Value),
Method(serde_json::Value),
}
fn parse_java_package(content: &str) -> Option<String> {
content.lines().find_map(|line| {
let line = line.trim();
line.strip_prefix("package ")
.and_then(|rest| rest.strip_suffix(';'))
.map(|package| package.trim().to_string())
})
}
fn parse_type_declaration(
line: &str,
package: &str,
annotations: Vec<serde_json::Value>,
) -> Option<DocsTypeBuilder> {
let header = line.split('{').next().unwrap_or(line).trim();
let tokens = split_java_words(header);
let kind_index = tokens
.iter()
.position(|token| matches!(token.as_str(), "class" | "interface" | "enum" | "record"))?;
let kind = tokens.get(kind_index)?.to_string();
let name = tokens
.get(kind_index + 1)?
.trim_end_matches('(')
.to_string();
let visibility = parse_visibility(&tokens);
let modifiers = parse_modifiers(&tokens[..kind_index]);
let qualified_name = qualify_name(package, &name);
let extends = tokens
.iter()
.position(|token| token == "extends")
.and_then(|index| tokens.get(index + 1))
.map(|name| qualify_type(package, name.trim_end_matches(',')));
let implements = tokens
.iter()
.position(|token| token == "implements")
.map(|index| {
tokens[index + 1..]
.iter()
.map(|token| token.trim_end_matches(','))
.filter(|token| !token.is_empty())
.map(|token| qualify_type(package, token))
.collect::<Vec<_>>()
})
.unwrap_or_default();
Some(DocsTypeBuilder {
kind,
name,
qualified_name,
package: package.to_string(),
visibility,
modifiers,
annotations,
description: None,
examples: Vec::new(),
extends,
implements,
fields: Vec::new(),
constructors: Vec::new(),
methods: Vec::new(),
})
}
fn parse_member_declaration(
line: &str,
package: &str,
type_name: &str,
annotations: Vec<serde_json::Value>,
) -> Option<DocsMember> {
let declaration = line
.split('{')
.next()
.unwrap_or(line)
.split('=')
.next()
.unwrap_or(line)
.trim()
.trim_end_matches(';')
.trim();
if declaration.is_empty() || declaration.starts_with("return ") {
return None;
}
if declaration.contains('(') && declaration.contains(')') {
parse_method_or_constructor(declaration, package, type_name, annotations)
} else {
parse_field(declaration, package, type_name, annotations).map(DocsMember::Field)
}
}
fn parse_field(
declaration: &str,
package: &str,
type_name: &str,
annotations: Vec<serde_json::Value>,
) -> Option<serde_json::Value> {
let (head, name) = split_type_and_name(declaration)?;
let type_token = strip_leading_modifiers(&head);
let tokens = split_java_words(declaration);
let visibility = parse_visibility(&tokens);
let modifiers = parse_modifiers(&tokens);
Some(serde_json::json!({
"name": name,
"qualifiedName": format!("{}.{}.{}", package, type_name, name),
"visibility": visibility,
"modifiers": modifiers,
"annotations": annotations,
"type": qualify_type(package, &type_token),
}))
}
fn parse_method_or_constructor(
declaration: &str,
package: &str,
type_name: &str,
annotations: Vec<serde_json::Value>,
) -> Option<DocsMember> {
let open = declaration.find('(')?;
let close = declaration.rfind(')')?;
let before = declaration[..open].trim();
let params = &declaration[open + 1..close];
let after = declaration[close + 1..].trim();
let (head, name) = split_type_and_name(before)?;
let tokens = split_java_words(before);
let visibility = parse_visibility(&tokens);
let modifiers = parse_modifiers(&tokens);
let parameters = parse_parameters(params, package);
let throws = parse_throws(after, package);
if name == type_name {
return Some(DocsMember::Constructor(serde_json::json!({
"name": name,
"qualifiedName": format!("{}.{}.{}", package, type_name, name),
"visibility": visibility,
"modifiers": modifiers,
"annotations": annotations,
"parameters": parameters,
"throws": throws,
})));
}
let return_type = strip_method_type_parameters(&strip_leading_modifiers(&head));
Some(DocsMember::Method(serde_json::json!({
"name": name,
"qualifiedName": format!("{}.{}.{}", package, type_name, name),
"visibility": visibility,
"modifiers": modifiers,
"annotations": annotations,
"parameters": parameters,
"returnType": qualify_type(package, &return_type),
"throws": throws,
})))
}
fn split_type_and_name(input: &str) -> Option<(String, String)> {
let split_at = input
.char_indices()
.rev()
.find(|(_, ch)| ch.is_whitespace())?
.0;
let head = input[..split_at].trim();
let name = input[split_at..].trim();
if head.is_empty() || name.is_empty() {
return None;
}
Some((head.to_string(), name.to_string()))
}
fn strip_leading_modifiers(input: &str) -> String {
let mut rest = input.trim();
while let Some((candidate, tail)) = rest.split_once(char::is_whitespace) {
if !is_java_modifier(candidate) {
break;
}
rest = tail.trim_start();
}
rest.to_string()
}
fn strip_method_type_parameters(input: &str) -> String {
let input = input.trim();
if !input.starts_with('<') {
return input.to_string();
}
let mut depth = 0_i32;
for (index, ch) in input.char_indices() {
match ch {
'<' => depth += 1,
'>' => {
depth -= 1;
if depth == 0 {
return input[index + ch.len_utf8()..].trim().to_string();
}
}
_ => {}
}
}
input.to_string()
}
fn parse_parameters(params: &str, package: &str) -> Vec<serde_json::Value> {
if params.trim().is_empty() {
return Vec::new();
}
split_java_commas(params)
.into_iter()
.enumerate()
.filter_map(|(index, param)| {
let param = param.trim();
if param.is_empty() {
return None;
}
let (type_name, name) = split_parameter_type_and_name(param)
.unwrap_or_else(|| (param.to_string(), format!("arg{index}")));
Some(serde_json::json!({
"name": name,
"type": qualify_type(package, &type_name),
}))
})
.collect()
}
fn split_parameter_type_and_name(param: &str) -> Option<(String, String)> {
let split_at = param
.char_indices()
.rev()
.find(|(_, ch)| ch.is_whitespace())?
.0;
let type_name = param[..split_at].trim();
let name = param[split_at..].trim();
if type_name.is_empty() || name.is_empty() || name.contains('.') {
return None;
}
let type_name = type_name.strip_prefix("final ").unwrap_or(type_name).trim();
Some((type_name.to_string(), name.to_string()))
}
fn parse_throws(after: &str, package: &str) -> Vec<String> {
after
.strip_prefix("throws ")
.map(|throws| {
split_java_commas(throws)
.into_iter()
.map(|name| qualify_type(package, name.trim()))
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn parse_annotation(line: &str, package: &str) -> serde_json::Value {
let body = line.trim().trim_start_matches('@');
let name = body.split(['(', ' ', '\t']).next().unwrap_or(body).trim();
let qualified_name = if name.contains('.') {
name.to_string()
} else if is_java_lang_type(name) {
format!("java.lang.{name}")
} else {
qualify_name(package, name)
};
serde_json::json!({
"qualifiedName": qualified_name,
"values": {},
})
}
fn extract_docs_types_from_jar(path: &Path) -> Result<Vec<serde_json::Value>> {
if let Some(javadocs) = find_javadoc_jar(path) {
let types = extract_docs_types_from_javadoc_jar(&javadocs)?;
if !types.is_empty() {
return Ok(types);
}
}
extract_docs_types_from_bytecode(path)
}
fn find_javadoc_jar(path: &Path) -> Option<PathBuf> {
let stem = path.file_stem()?.to_str()?;
let parent = path.parent()?;
let candidates = [
parent.join(format!("{stem}-javadoc.jar")),
parent.join(format!("{stem}-javadocs.jar")),
];
candidates.into_iter().find(|candidate| candidate.exists())
}
fn extract_docs_types_from_javadoc_jar(path: &Path) -> Result<Vec<serde_json::Value>> {
let file = fs::File::open(path)
.with_context(|| format!("failed to open javadoc jar {}", path.display()))?;
let mut archive = zip::ZipArchive::new(file)
.with_context(|| format!("failed to read javadoc jar {}", path.display()))?;
let mut types = Vec::new();
for index in 0..archive.len() {
let mut entry = archive.by_index(index)?;
let name = entry.name().to_string();
if !is_javadoc_type_page(&name) {
continue;
}
let mut html = String::new();
entry.read_to_string(&mut html)?;
if let Some(ty) = parse_javadoc_type_page(&name, &html) {
types.push(ty);
}
}
types.sort_by_key(|value| {
value
.get("qualifiedName")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string()
});
Ok(types)
}
fn is_javadoc_type_page(name: &str) -> bool {
name.ends_with(".html")
&& !name.contains('-')
&& !name.ends_with("/package-summary.html")
&& !name.ends_with("/module-summary.html")
&& !name.ends_with("/overview-summary.html")
&& !name.ends_with("/index.html")
&& name.rsplit('/').next().is_some_and(|file| {
file.chars()
.next()
.is_some_and(|ch| ch.is_ascii_uppercase())
})
}
fn parse_javadoc_type_page(path: &str, html: &str) -> Option<serde_json::Value> {
let qualified_name = path.trim_end_matches(".html").replace('/', ".");
let (package, name) = qualified_name
.rsplit_once('.')
.map(|(package, name)| (package.to_string(), name.to_string()))
.unwrap_or_else(|| (String::new(), qualified_name.clone()));
let text = normalize_doc_text(&strip_html_tags(html));
let kind = if text.contains(&format!("interface {name}")) {
"interface"
} else if text.contains(&format!("enum {name}")) {
"enum"
} else if text.contains(&format!("record {name}")) {
"record"
} else {
"class"
};
let member_docs = extract_javadoc_member_docs(html);
let description = extract_javadoc_type_description(html);
let examples = extract_javadoc_examples(html);
let mut builder = DocsTypeBuilder {
kind: kind.to_string(),
name: name.clone(),
qualified_name: qualified_name.clone(),
package: package.clone(),
visibility: "public".to_string(),
modifiers: vec!["public".to_string()],
annotations: Vec::new(),
description,
examples,
extends: None,
implements: Vec::new(),
fields: Vec::new(),
constructors: Vec::new(),
methods: Vec::new(),
};
for member_doc in member_docs {
if let Some(mut member) =
parse_member_declaration(&member_doc.signature, &package, &name, Vec::new())
{
enrich_member_with_javadoc(&mut member, &member_doc);
builder.push_member(member);
}
}
Some(builder.into_json())
}
#[derive(Debug, Clone, Default)]
struct JavadocMemberDoc {
signature: String,
description: Option<String>,
parameter_descriptions: Vec<(String, String)>,
return_description: Option<String>,
}
fn extract_javadoc_member_docs(html: &str) -> Vec<JavadocMemberDoc> {
let detail_re =
regex::Regex::new(r#"(?s)<section class=\"detail\"[^>]*>(.*?)</section>"#).unwrap();
let signature_re =
regex::Regex::new(r#"(?s)<div class=\"member-signature\">(.*?)</div>"#).unwrap();
let mut members = Vec::new();
for detail in detail_re
.captures_iter(html)
.filter_map(|captures| captures.get(1).map(|value| value.as_str()))
{
let Some(signature_html) = signature_re
.captures(detail)
.and_then(|captures| captures.get(1).map(|value| value.as_str()))
else {
continue;
};
let signature = normalize_doc_text(&strip_html_tags(signature_html));
if signature.is_empty() {
continue;
}
members.push(JavadocMemberDoc {
signature,
description: extract_javadoc_member_description(detail),
parameter_descriptions: extract_javadoc_parameter_descriptions(detail),
return_description: extract_javadoc_return_description(detail),
});
}
if members.is_empty() {
members.extend(
extract_javadoc_signatures(html)
.into_iter()
.map(|signature| JavadocMemberDoc {
signature,
..JavadocMemberDoc::default()
}),
);
}
members
}
fn extract_javadoc_member_description(detail_html: &str) -> Option<String> {
let re = regex::Regex::new(r#"(?s)<div class=\"block\">(.*?)</div>"#).unwrap();
re.captures(detail_html)
.and_then(|captures| captures.get(1))
.map(|value| html_fragment_to_markdown(value.as_str()))
.filter(|value| !value.is_empty())
}
fn extract_javadoc_parameter_descriptions(detail_html: &str) -> Vec<(String, String)> {
let re = regex::Regex::new(r#"(?s)<dd>\s*<code>(.*?)</code>\s*-\s*(.*?)</dd>"#).unwrap();
re.captures_iter(detail_html)
.filter_map(|captures| {
let name = captures
.get(1)
.map(|value| normalize_doc_text(&strip_html_tags(value.as_str())))?;
let description = captures
.get(2)
.map(|value| html_fragment_to_markdown(value.as_str()))?;
if name.is_empty() || description.is_empty() {
None
} else {
Some((name, description))
}
})
.collect()
}
fn extract_javadoc_return_description(detail_html: &str) -> Option<String> {
let re = regex::Regex::new(r#"(?s)<dt>Returns:</dt>\s*<dd>(.*?)</dd>"#).unwrap();
re.captures(detail_html)
.and_then(|captures| captures.get(1))
.map(|value| html_fragment_to_markdown(value.as_str()))
.filter(|value| !value.is_empty())
}
fn enrich_member_with_javadoc(member: &mut DocsMember, doc: &JavadocMemberDoc) {
let value = match member {
DocsMember::Field(value) | DocsMember::Constructor(value) | DocsMember::Method(value) => {
value
}
};
if let Some(description) = &doc.description {
value["description"] = serde_json::Value::String(description.clone());
}
if let Some(return_description) = &doc.return_description {
value["returnDescription"] = serde_json::Value::String(return_description.clone());
}
if let Some(parameters) = value
.get_mut("parameters")
.and_then(|value| value.as_array_mut())
{
for parameter in parameters {
let Some(parameter_name) = parameter.get("name").and_then(|value| value.as_str())
else {
continue;
};
if let Some((_, description)) = doc
.parameter_descriptions
.iter()
.find(|(name, _)| name == parameter_name)
{
parameter["description"] = serde_json::Value::String(description.clone());
}
}
}
}
fn extract_javadoc_signatures(html: &str) -> Vec<String> {
let mut signatures = Vec::new();
let re = regex::Regex::new(r#"(?s)<div class="member-signature">(.*?)</div>"#).unwrap();
for captures in re.captures_iter(html) {
if let Some(signature) = captures.get(1) {
let text = normalize_doc_text(&strip_html_tags(signature.as_str()));
if !text.is_empty() {
signatures.push(text);
}
}
}
if signatures.is_empty() {
let pre = regex::Regex::new(r#"(?s)<pre[^>]*>(.*?)</pre>"#).unwrap();
for captures in pre.captures_iter(html) {
if let Some(signature) = captures.get(1) {
let text = normalize_doc_text(&strip_html_tags(signature.as_str()))
.trim_end_matches(';')
.to_string();
if is_javadoc_member_signature(&text) {
signatures.push(text);
}
}
}
}
signatures
}
fn is_javadoc_member_signature(text: &str) -> bool {
let text = strip_leading_annotation_lines(text);
let tokens = split_java_words(text);
if tokens.is_empty() {
return false;
}
if !matches!(tokens[0].as_str(), "public" | "protected" | "private") {
return false;
}
!tokens
.iter()
.any(|token| matches!(token.as_str(), "class" | "interface" | "enum" | "record"))
}
fn is_javadoc_type_signature(text: &str) -> bool {
split_java_words(strip_leading_annotation_lines(text))
.iter()
.any(|token| matches!(token.as_str(), "class" | "interface" | "enum" | "record"))
}
fn strip_leading_annotation_lines(text: &str) -> &str {
let mut rest = text.trim();
while rest.starts_with('@') {
let Some((_, tail)) = rest.split_once(char::is_whitespace) else {
return "";
};
rest = tail.trim_start();
}
rest
}
fn extract_javadoc_examples(html: &str) -> Vec<String> {
let pre = regex::Regex::new(r#"(?s)<pre[^>]*>(.*?)</pre>"#).unwrap();
let mut examples = Vec::new();
for example in pre
.captures_iter(html)
.filter_map(|captures| captures.get(1))
.map(|example| normalize_code_block(&strip_html_tags(example.as_str())))
{
let normalized = normalize_doc_text(&example);
if !example.is_empty()
&& !is_javadoc_member_signature(&normalized)
&& !is_javadoc_type_signature(&normalized)
&& !examples.contains(&example)
{
examples.push(example);
}
}
examples
}
fn normalize_code_block(input: &str) -> String {
input
.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string()
}
fn extract_javadoc_type_description(html: &str) -> Option<String> {
let patterns = [
r#"(?s)<section[^>]*class="[^"]*(?:class|interface|enum|record)-description[^"]*"[^>]*>.*?<div class="block">(.*?)</div>"#,
r#"(?s)<div class="description">.*?<div class="block">(.*?)</div>"#,
];
patterns.iter().find_map(|pattern| {
let re = regex::Regex::new(pattern).ok()?;
let html = re.captures(html)?.get(1)?.as_str();
let markdown = html_fragment_to_markdown(html);
(!markdown.is_empty()).then_some(markdown)
})
}
fn html_fragment_to_markdown(input: &str) -> String {
let fragment = html_unescape(input);
let markdown = quick_html2md::html_to_markdown(&format!("<div>{fragment}</div>"));
normalize_markdown_text(&markdown)
}
fn normalize_markdown_text(input: &str) -> String {
let mut out = String::new();
let mut blank_lines = 0;
let mut in_fence = false;
for raw_line in input.lines() {
let line = raw_line.trim_end();
let trimmed = line.trim();
if trimmed.starts_with("```") {
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str(trimmed);
out.push('\n');
in_fence = !in_fence;
blank_lines = 0;
continue;
}
if in_fence {
out.push_str(line);
out.push('\n');
continue;
}
if trimmed.is_empty() {
blank_lines += 1;
if blank_lines <= 1 && !out.trim().is_empty() && !out.ends_with("\n\n") {
if !out.ends_with('\n') {
out.push('\n');
}
out.push('\n');
}
continue;
}
blank_lines = 0;
if is_markdown_block_line(trimmed) {
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str(trimmed);
out.push('\n');
} else {
if !out.is_empty() && !out.ends_with(['\n', ' ']) {
out.push(' ');
}
out.push_str(trimmed);
}
}
out.trim().to_string()
}
fn is_markdown_block_line(line: &str) -> bool {
line.starts_with("#")
|| line.starts_with(">")
|| line.starts_with("- ")
|| line.starts_with("* ")
|| regex::Regex::new(r#"^\d+\.\s"#).unwrap().is_match(line)
}
fn strip_html_tags(input: &str) -> String {
let tags = regex::Regex::new(r#"(?s)<[^>]+>"#).unwrap();
html_unescape(&tags.replace_all(input, " "))
}
fn html_unescape(input: &str) -> String {
input
.replace("<", "<")
.replace(">", ">")
.replace("&", "&")
.replace(""", "\"")
.replace("'", "'")
.replace(" ", " ")
}
fn normalize_doc_text(input: &str) -> String {
input.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn extract_docs_types_from_bytecode(path: &Path) -> Result<Vec<serde_json::Value>> {
let output = ProcessCommand::new("jar")
.arg("tf")
.arg(path)
.output()
.with_context(|| "failed to run jar tf for docs extraction")?;
if !output.status.success() {
anyhow::bail!(
"jar tf failed for {}: {}",
path.display(),
String::from_utf8_lossy(&output.stderr)
);
}
let class_names = String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|line| line.ends_with(".class") && !line.contains('$'))
.map(|line| line.trim_end_matches(".class").replace('/', "."))
.collect::<Vec<_>>();
let mut types = Vec::new();
for class_name in class_names {
let output = ProcessCommand::new("javap")
.arg("-classpath")
.arg(path)
.arg("-protected")
.arg("-v")
.arg(&class_name)
.output()
.with_context(|| format!("failed to run javap for {class_name}"))?;
if output.status.success() {
if let Some(ty) = parse_javap_type(&String::from_utf8_lossy(&output.stdout)) {
types.push(ty);
}
}
}
Ok(types)
}
fn parse_javap_type(output: &str) -> Option<serde_json::Value> {
let signature_lines = output
.lines()
.map(str::trim)
.filter(|line| {
line.ends_with(';')
|| line.ends_with('{')
|| line.starts_with("public class ")
|| line.starts_with("protected class ")
|| line.starts_with("class ")
|| line.starts_with("public interface ")
|| line.starts_with("public enum ")
})
.filter(|line| {
!line.starts_with("descriptor:")
&& !line.starts_with("flags:")
&& !line.starts_with('#')
&& !line.starts_with("Classfile ")
&& !line.starts_with("Last modified ")
&& !line.starts_with("SHA-256 ")
&& !line.starts_with("Compiled from ")
})
.collect::<Vec<_>>();
let type_line = signature_lines.iter().find(|line| {
line.contains(" class ") || line.contains(" interface ") || line.contains(" enum ")
})?;
let header = type_line.trim_end_matches('{').trim();
let tokens = split_java_words(header);
let kind_index = tokens
.iter()
.position(|token| matches!(token.as_str(), "class" | "interface" | "enum"))?;
let kind = tokens[kind_index].clone();
let qualified_name = tokens.get(kind_index + 1)?.to_string();
let package = qualified_name
.rsplit_once('.')
.map(|(package, _)| package.to_string())
.unwrap_or_default();
let name = qualified_name
.rsplit_once('.')
.map(|(_, name)| name.to_string())
.unwrap_or_else(|| qualified_name.clone());
let mut builder = DocsTypeBuilder {
kind,
name: name.clone(),
qualified_name: qualified_name.clone(),
package: package.clone(),
visibility: parse_visibility(&tokens),
modifiers: parse_modifiers(&tokens[..kind_index]),
annotations: Vec::new(),
description: None,
examples: Vec::new(),
extends: None,
implements: Vec::new(),
fields: Vec::new(),
constructors: Vec::new(),
methods: Vec::new(),
};
let parameter_names = javap_parameter_names(output);
for line in signature_lines {
let line = line.trim_end_matches(';').trim();
if line == header || line.ends_with('{') {
continue;
}
if line.contains('(') && line.contains(')') {
if let Some(mut member) = parse_method_or_constructor(line, &package, &name, Vec::new())
{
if let DocsMember::Method(value) | DocsMember::Constructor(value) = &mut member {
if let Some(names) = value
.get("name")
.and_then(|value| value.as_str())
.and_then(|method_name| parameter_names.get(method_name))
{
apply_parameter_names(value, names);
}
}
builder.push_member(member);
}
} else if let Some(field) = parse_field(line, &package, &name, Vec::new()) {
builder.fields.push(field);
}
}
Some(builder.into_json())
}
fn javap_parameter_names(output: &str) -> std::collections::HashMap<String, Vec<String>> {
let lines = output.lines().collect::<Vec<_>>();
let mut names_by_method = std::collections::HashMap::new();
let mut current_method: Option<String> = None;
let mut index = 0;
while index < lines.len() {
let line = lines[index].trim();
if line.ends_with(';')
&& line.contains('(')
&& !line.starts_with("descriptor:")
&& !line.starts_with('#')
{
current_method = line
.split('(')
.next()
.and_then(|before| split_java_words(before).last().cloned());
} else if line == "MethodParameters:" {
if let Some(method) = current_method.clone() {
let mut names = Vec::new();
index += 2;
while index < lines.len() {
let candidate = lines[index].trim();
if candidate.is_empty() || candidate.ends_with(':') || candidate.contains(';') {
break;
}
if let Some(name) = candidate.split_whitespace().next() {
names.push(name.to_string());
}
index += 1;
}
names_by_method.insert(method, names);
}
}
index += 1;
}
names_by_method
}
fn apply_parameter_names(value: &mut serde_json::Value, names: &[String]) {
if let Some(params) = value
.get_mut("parameters")
.and_then(|value| value.as_array_mut())
{
for (param, name) in params.iter_mut().zip(names) {
param["name"] = serde_json::Value::String(name.clone());
}
}
}
fn split_java_words(input: &str) -> Vec<String> {
input
.split_whitespace()
.map(|token| token.trim_matches(',').to_string())
.filter(|token| !token.is_empty())
.collect()
}
fn split_java_commas(input: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut start = 0;
let mut angle_depth = 0_i32;
let mut paren_depth = 0_i32;
for (index, ch) in input.char_indices() {
match ch {
'<' => angle_depth += 1,
'>' => angle_depth = angle_depth.saturating_sub(1),
'(' => paren_depth += 1,
')' => paren_depth = paren_depth.saturating_sub(1),
',' if angle_depth == 0 && paren_depth == 0 => {
parts.push(input[start..index].trim());
start = index + ch.len_utf8();
}
_ => {}
}
}
parts.push(input[start..].trim());
parts.into_iter().filter(|part| !part.is_empty()).collect()
}
fn parse_visibility(tokens: &[String]) -> String {
if tokens.iter().any(|token| token == "public") {
"public".to_string()
} else if tokens.iter().any(|token| token == "protected") {
"protected".to_string()
} else if tokens.iter().any(|token| token == "private") {
"private".to_string()
} else {
"package".to_string()
}
}
fn parse_modifiers(tokens: &[String]) -> Vec<String> {
tokens
.iter()
.filter(|token| is_java_modifier(token))
.cloned()
.collect()
}
fn is_java_modifier(token: &str) -> bool {
matches!(
token,
"public"
| "protected"
| "private"
| "static"
| "final"
| "abstract"
| "default"
| "sealed"
| "non-sealed"
| "synchronized"
| "native"
| "strictfp"
)
}
fn qualify_name(package: &str, name: &str) -> String {
if package.is_empty() || name.contains('.') {
name.to_string()
} else {
format!("{package}.{name}")
}
}
fn qualify_type(package: &str, name: &str) -> String {
let name = normalize_java_type_spacing(name.trim().trim_end_matches(','));
if let Some(simple) = name.strip_prefix("java.lang.") {
return simple.to_string();
}
let base = name
.trim_end_matches("...")
.split(['<', '['])
.next()
.unwrap_or(&name)
.trim();
if name.is_empty()
|| name == "void"
|| is_type_variable(&name)
|| is_primitive_type(base)
|| is_java_lang_type(base)
|| is_common_jdk_simple_type(base)
|| is_unqualified_exception_or_error(base)
|| name.contains('.')
|| name.contains('<')
{
name.to_string()
} else {
qualify_name(package, &name)
}
}
fn normalize_java_type_spacing(input: &str) -> String {
let mut out = String::new();
let mut previous_was_space = false;
for ch in input.chars() {
match ch {
'<' | '>' | '[' | ']' => {
while out.ends_with(' ') {
out.pop();
}
out.push(ch);
previous_was_space = false;
}
',' => {
while out.ends_with(' ') {
out.pop();
}
out.push(ch);
out.push(' ');
previous_was_space = true;
}
ch if ch.is_whitespace() => {
if !out.is_empty()
&& !previous_was_space
&& !out.ends_with('<')
&& !out.ends_with('[')
{
out.push(' ');
previous_was_space = true;
}
}
_ => {
out.push(ch);
previous_was_space = false;
}
}
}
out.trim().to_string()
}
fn is_type_variable(name: &str) -> bool {
let name = name.trim();
name.len() == 1 && name.chars().all(|ch| ch.is_ascii_uppercase())
}
fn is_unqualified_exception_or_error(name: &str) -> bool {
!name.contains('.') && (name.ends_with("Exception") || name.ends_with("Error"))
}
fn is_common_jdk_simple_type(name: &str) -> bool {
matches!(
name,
"File"
| "InputStream"
| "OutputStream"
| "Reader"
| "Writer"
| "DataInput"
| "DataOutput"
| "IOException"
| "URL"
| "URI"
| "List"
| "Set"
| "Map"
| "Collection"
| "Iterable"
| "Iterator"
| "ConcurrentHashMap"
)
}
fn is_primitive_type(name: &str) -> bool {
matches!(
name,
"boolean" | "byte" | "char" | "short" | "int" | "long" | "float" | "double"
)
}
fn is_java_lang_type(name: &str) -> bool {
matches!(
name,
"String"
| "Object"
| "Class"
| "Integer"
| "Long"
| "Boolean"
| "Double"
| "Float"
| "Short"
| "Byte"
| "Character"
| "ClassLoader"
| "Throwable"
| "Exception"
| "RuntimeException"
| "IllegalArgumentException"
| "Deprecated"
| "Override"
| "SuppressWarnings"
| "FunctionalInterface"
)
}
fn count_char(input: &str, needle: char) -> i32 {
input.chars().filter(|ch| *ch == needle).count() as i32
}
fn filter_docs_types(types: Vec<serde_json::Value>, filters: &[String]) -> Vec<serde_json::Value> {
if filters.is_empty() {
return types;
}
types
.into_iter()
.filter(|ty| docs_type_matches_filters(ty, filters))
.collect()
}
fn docs_type_matches_filters(ty: &serde_json::Value, filters: &[String]) -> bool {
let name = ty
.get("name")
.and_then(|value| value.as_str())
.unwrap_or_default();
let qualified_name = ty
.get("qualifiedName")
.and_then(|value| value.as_str())
.unwrap_or_default();
filters.iter().any(|filter| {
let filter = filter.trim();
filter == name
|| filter == qualified_name
|| qualified_name.ends_with(&format!(".{filter}"))
|| simple_glob_match(filter, name)
|| simple_glob_match(filter, qualified_name)
})
}
fn filter_remote_docs_text(text: String, json: bool, type_filters: &[String]) -> Result<String> {
if !json || type_filters.is_empty() {
return Ok(text);
}
let mut value: serde_json::Value = serde_json::from_str(&text)?;
if let Some(types) = value
.get_mut("types")
.and_then(|value| value.as_array_mut())
{
let filtered = filter_docs_types(std::mem::take(types), type_filters);
*types = filtered;
}
Ok(format!("{}\n", serde_json::to_string_pretty(&value)?))
}
fn simple_glob_match(pattern: &str, value: &str) -> bool {
if pattern == "*" {
return true;
}
match (pattern.strip_prefix('*'), pattern.strip_suffix('*')) {
(Some(suffix), _) => value.ends_with(suffix),
(_, Some(prefix)) => value.starts_with(prefix),
_ => false,
}
}
fn fetch_remote_docs(cmd: &DocsCommand) -> Result<String> {
let repos = docs_repositories(&cmd.repos);
let coordinate = parse_docs_coordinate(&cmd.target, &repos)?;
let extension = if cmd.json { "json" } else { "md" };
let filename = format!(
"{}-{}-jbx-docs.{extension}",
coordinate.id, coordinate.version
);
let cache_root = cmd
.cache_dir
.clone()
.unwrap_or(default_cache_dir()?)
.join("docs");
let cache_path = cache_root
.join(coordinate.group.replace('.', "/"))
.join(&coordinate.id)
.join(&coordinate.version)
.join(&filename);
if cache_path.exists() {
let text = fs::read_to_string(&cache_path)
.with_context(|| format!("failed to read cached docs {}", cache_path.display()))?;
return filter_remote_docs_text(text, cmd.json, &cmd.types);
}
for repo in repos {
let url = docs_artifact_url(&repo, &coordinate, &filename);
match ureq::get(&url).call() {
Ok(response) => {
let text = response
.into_string()
.with_context(|| format!("failed to read docs sidecar from {url}"))?;
if let Some(parent) = cache_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&cache_path, &text)?;
return filter_remote_docs_text(text, cmd.json, &cmd.types);
}
Err(ureq::Error::Status(404, _)) => continue,
Err(_) => continue,
}
}
fetch_remote_javadoc_docs(cmd, &coordinate, &cache_root)
}
fn fetch_remote_javadoc_docs(
cmd: &DocsCommand,
coordinate: &DocsCoordinate,
cache_root: &Path,
) -> Result<String> {
let filename = format!("{}-{}-javadoc.jar", coordinate.id, coordinate.version);
let cache_path = cache_root
.join(coordinate.group.replace('.', "/"))
.join(&coordinate.id)
.join(&coordinate.version)
.join(&filename);
if !cache_path.exists() {
let mut found = false;
for repo in docs_repositories(&cmd.repos) {
let url = docs_artifact_url(&repo, coordinate, &filename);
match ureq::get(&url).call() {
Ok(response) => {
let mut bytes = Vec::new();
response
.into_reader()
.read_to_end(&mut bytes)
.with_context(|| format!("failed to read javadoc jar from {url}"))?;
if let Some(parent) = cache_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&cache_path, bytes)?;
found = true;
break;
}
Err(ureq::Error::Status(404, _)) => continue,
Err(_) => continue,
}
}
if !found {
anyhow::bail!(
"jbx docs sidecar or javadoc jar not found for {}:{}:{}",
coordinate.group,
coordinate.id,
coordinate.version
);
}
}
let types = filter_docs_types(
extract_docs_types_from_javadoc_jar(&cache_path)?,
&cmd.types,
);
if cmd.json {
Ok(format!(
"{}\n",
serde_json::to_string_pretty(&serde_json::json!({
"schema": "https://jbx.telegraphic.dev/schemas/jbx-docs/v1.json",
"artifact": {
"group": coordinate.group,
"id": coordinate.id,
"version": coordinate.version,
"coordinate": format!("{}:{}:{}", coordinate.group, coordinate.id, coordinate.version),
},
"types": types,
"generatedFrom": {
"source": "javadoc",
"jbxVersion": env!("CARGO_PKG_VERSION"),
}
}))?
))
} else {
render_docs_markdown(&coordinate.id, Some(coordinate), &[], &types)
}
}
fn docs_repositories(repo_args: &[String]) -> Vec<jbx::resolver::Repository> {
let mut repos = maven_tool::maven_repositories(repo_args);
repos.sort_by_key(|repo| if repo.id == "central" { 1 } else { 0 });
repos
}
fn docs_artifact_url(
repo: &jbx::resolver::Repository,
coordinate: &DocsCoordinate,
filename: &str,
) -> String {
format!(
"{}/{}/{}/{}/{}",
repo.url.trim_end_matches('/'),
coordinate.group.replace('.', "/"),
coordinate.id,
coordinate.version,
filename
)
}
fn publish_docs_outputs(
descriptor: &PublishDescriptor,
staged: &StagedPublishSources,
) -> Result<(String, String)> {
let coordinate = DocsCoordinate {
group: descriptor.coordinates.group.clone(),
id: descriptor.coordinates.id.clone(),
version: descriptor.coordinates.version.clone(),
};
let sources = staged
.all_sources
.iter()
.map(|source| docs_source_json(source, Some(&coordinate)))
.collect::<Result<Vec<_>>>()?;
let title = descriptor
.name
.as_deref()
.unwrap_or(&descriptor.coordinates.id);
let types = extract_docs_types_from_sources(&staged.all_sources)?;
let markdown = render_docs_markdown(title, Some(&coordinate), &sources, &types)?;
let json = serde_json::to_string_pretty(&serde_json::json!({
"schema": "https://jbx.telegraphic.dev/schemas/jbx-docs/v1.json",
"artifact": {
"group": coordinate.group,
"id": coordinate.id,
"version": coordinate.version,
"coordinate": format!("{}:{}:{}", coordinate.group, coordinate.id, coordinate.version),
},
"summary": descriptor.description,
"sources": sources,
"types": types,
"generatedFrom": {
"source": "jbx publish",
"jbxVersion": env!("CARGO_PKG_VERSION"),
}
}))?;
Ok((markdown, format!("{json}\n")))
}
fn print_cache_path(cache_dir: Option<PathBuf>) -> Result<()> {
let cache_dir = match cache_dir {
Some(path) => path,
None => default_cache_dir()?,
};
println!("{}", cache_dir.display());
Ok(())
}
fn apply_alias_to_run(mut options: RunOptions) -> Result<RunOptions> {
if let Some(alias) = alias_for_script(&options.script)? {
merge_alias_common(
&alias,
&mut options.script,
&mut options.extra_deps,
&mut options.extra_repos,
&mut options.extra_sources,
&mut options.extra_files,
&mut options.classpath,
&mut options.javac_options,
&mut options.runtime_options,
&mut options.java_agents,
&mut options.java_version,
&mut options.main_class,
);
options.script_args = prepend(alias.arguments, options.script_args);
}
Ok(options)
}
fn apply_alias_to_build(mut options: BuildOptions) -> Result<BuildOptions> {
if let Some(alias) = alias_for_script(&options.script)? {
merge_alias_common(
&alias,
&mut options.script,
&mut options.extra_deps,
&mut options.extra_repos,
&mut options.extra_sources,
&mut options.extra_files,
&mut options.classpath,
&mut options.javac_options,
&mut options.runtime_options,
&mut options.java_agents,
&mut options.java_version,
&mut options.main_class,
);
}
Ok(options)
}
fn apply_alias_to_export(mut options: ExportOptions) -> Result<ExportOptions> {
if let Some(alias) = alias_for_script(&options.script)? {
merge_alias_common(
&alias,
&mut options.script,
&mut options.extra_deps,
&mut options.extra_repos,
&mut options.extra_sources,
&mut options.extra_files,
&mut options.classpath,
&mut options.javac_options,
&mut options.runtime_options,
&mut options.java_agents,
&mut options.java_version,
&mut options.main_class,
);
}
Ok(options)
}
fn apply_alias_to_native_export(mut options: NativeExportOptions) -> Result<NativeExportOptions> {
if let Some(alias) = alias_for_script(&options.script)? {
merge_alias_common(
&alias,
&mut options.script,
&mut options.extra_deps,
&mut options.extra_repos,
&mut options.extra_sources,
&mut options.extra_files,
&mut options.classpath,
&mut options.javac_options,
&mut options.runtime_options,
&mut options.java_agents,
&mut options.java_version,
&mut options.main_class,
);
}
Ok(options)
}
fn alias_for_script(script: &Path) -> Result<Option<jbx::CatalogAlias>> {
let name = script.to_string_lossy().to_string();
if script.exists() || name.starts_with("http://") || name.starts_with("https://") {
return Ok(None);
}
resolve_catalog_alias(&name, &std::env::current_dir()?)
}
#[allow(clippy::too_many_arguments)]
fn merge_alias_common(
alias: &jbx::CatalogAlias,
script: &mut PathBuf,
extra_deps: &mut Vec<String>,
extra_repos: &mut Vec<String>,
extra_sources: &mut Vec<String>,
extra_files: &mut Vec<String>,
classpath: &mut Vec<PathBuf>,
javac_options: &mut Vec<String>,
runtime_options: &mut Vec<String>,
java_agents: &mut Vec<KeyValue>,
java_version: &mut Option<String>,
main_class: &mut Option<String>,
) {
*script = alias.script.clone();
*extra_deps = prepend(alias.deps.clone(), std::mem::take(extra_deps));
*extra_repos = prepend(alias.repos.clone(), std::mem::take(extra_repos));
*extra_sources = prepend(alias.sources.clone(), std::mem::take(extra_sources));
*extra_files = prepend(alias.files.clone(), std::mem::take(extra_files));
*classpath = prepend(alias.classpaths.clone(), std::mem::take(classpath));
*javac_options = prepend(alias.javac_options.clone(), std::mem::take(javac_options));
*runtime_options = prepend(
alias.runtime_options.clone(),
std::mem::take(runtime_options),
);
*java_agents = prepend(alias.java_agents.clone(), std::mem::take(java_agents));
if java_version.is_none() {
*java_version = alias.java_version.clone();
}
if main_class.is_none() {
*main_class = alias.main_class.clone();
}
}
fn prepend<T>(prefix: Vec<T>, existing: Vec<T>) -> Vec<T> {
prefix.into_iter().chain(existing).collect()
}
fn print_aliases(json: bool) -> Result<()> {
let aliases = catalog_aliases(&std::env::current_dir()?)?;
if json {
let payload = aliases
.iter()
.map(|alias| {
serde_json::json!({
"name": alias.name,
"scriptRef": alias.script_ref,
"script": alias.script.to_string_lossy(),
"description": alias.description,
"arguments": alias.arguments,
"dependencies": alias.deps,
"repositories": alias.repos,
"sources": alias.sources,
"files": alias.files,
"classpaths": alias.classpaths.iter().map(|path| path.to_string_lossy().to_string()).collect::<Vec<_>>(),
"compileOptions": alias.javac_options,
"runtimeOptions": alias.runtime_options,
"javaAgents": key_values_json(&alias.java_agents),
"javaVersion": alias.java_version,
"mainClass": alias.main_class,
})
})
.collect::<Vec<_>>();
println!("{}", serde_json::to_string_pretty(&payload)?);
} else {
for alias in aliases {
match alias.description {
Some(description) => {
println!("{}\t{}\t{}", alias.name, alias.script_ref, description)
}
None => println!("{}\t{}", alias.name, alias.script_ref),
}
}
}
Ok(())
}
fn print_catalogs(json: bool) -> Result<()> {
let catalogs = catalog_refs(&std::env::current_dir()?)?;
if json {
let payload = catalogs
.iter()
.map(|catalog| {
serde_json::json!({
"name": catalog.name,
"catalogRef": catalog.catalog_ref,
"catalog": catalog.catalog.to_string_lossy(),
"description": catalog.description,
"import": catalog.import_items,
})
})
.collect::<Vec<_>>();
println!("{}", serde_json::to_string_pretty(&payload)?);
} else {
for catalog in catalogs {
match catalog.description {
Some(description) => {
println!("{}\t{}\t{}", catalog.name, catalog.catalog_ref, description)
}
None => println!("{}\t{}", catalog.name, catalog.catalog_ref),
}
}
}
Ok(())
}
fn run_search(cmd: SearchCommand) -> Result<i32> {
let (query, use_gav_core) = maven_search_query(&cmd)?;
let endpoint = maven_search_endpoint();
let rows = maven_search_fetch_rows(cmd.limit).to_string();
let mut request = ureq::get(&endpoint)
.query("q", &query)
.query("rows", &rows)
.query("wt", "json")
.set("User-Agent", "jbx");
if use_gav_core {
request = request.query("core", "gav");
}
let response_body = request
.call()
.with_context(|| format!("failed to search Maven Central for {query}"))?
.into_string()
.context("failed to read Maven Central search response")?;
let response: serde_json::Value = serde_json::from_str(&response_body)
.context("failed to parse Maven Central search response")?;
let search_response = response
.get("response")
.cloned()
.unwrap_or(serde_json::Value::Null);
let docs = search_response
.get("docs")
.and_then(|value| value.as_array())
.cloned()
.unwrap_or_default();
let total_found_fallback = docs.len() as u64;
let mut artifacts = docs.iter().map(search_doc_json).collect::<Vec<_>>();
let exact_artifact_id = maven_search_exact_artifact_id(&cmd);
sort_search_artifacts_by_popularity(&mut artifacts, exact_artifact_id.as_deref());
artifacts.truncate(cmd.limit);
if cmd.json {
let payload = serde_json::json!({
"query": query,
"numFound": search_response.get("numFound").and_then(|value| value.as_u64()).unwrap_or(total_found_fallback),
"artifacts": artifacts,
});
println!("{}", serde_json::to_string_pretty(&payload)?);
} else {
print_search_table(&artifacts);
}
Ok(0)
}
fn maven_search_endpoint() -> String {
let base = std::env::var("JBX_MAVEN_SEARCH_URL")
.unwrap_or_else(|_| "https://search.maven.org".to_string());
if base.ends_with("/solrsearch/select") {
base
} else {
format!("{}/solrsearch/select", base.trim_end_matches('/'))
}
}
fn maven_search_fetch_rows(limit: usize) -> usize {
limit.max(100)
}
fn maven_search_query(cmd: &SearchCommand) -> Result<(String, bool)> {
let raw = cmd.query.join(" ");
let has_filters = cmd.group.is_some() || cmd.id.is_some() || cmd.version.is_some();
if has_filters {
let mut clauses = Vec::new();
if !raw.trim().is_empty() {
clauses.push(raw.trim().to_string());
}
if let Some(group) = cmd
.group
.as_deref()
.filter(|value| !value.trim().is_empty())
{
clauses.push(format!("g:{}", group.trim()));
}
if let Some(id) = cmd.id.as_deref().filter(|value| !value.trim().is_empty()) {
clauses.push(format!("a:{}", id.trim()));
}
if let Some(version) = cmd
.version
.as_deref()
.filter(|value| !value.trim().is_empty())
{
clauses.push(format!("v:{}", version.trim()));
}
if clauses.is_empty() {
anyhow::bail!("search requires a query or at least one filter");
}
let version_in_query = clauses.iter().any(|clause| clause.starts_with("v:"));
return Ok((clauses.join(" AND "), version_in_query));
}
if raw.trim().is_empty() {
anyhow::bail!("search requires a query or at least one filter");
}
let coord_parts = raw.split(':').collect::<Vec<_>>();
if (coord_parts.len() == 2 || coord_parts.len() == 3)
&& coord_parts.iter().all(|part| !part.trim().is_empty())
{
let mut query = format!("g:{} AND a:{}", coord_parts[0], coord_parts[1]);
if coord_parts.len() == 3 {
query.push_str(&format!(" AND v:{}", coord_parts[2]));
}
return Ok((query, coord_parts.len() == 3));
}
Ok((raw, false))
}
fn maven_search_exact_artifact_id(cmd: &SearchCommand) -> Option<String> {
if let Some(id) = cmd.id.as_deref().map(str::trim).filter(|id| !id.is_empty()) {
return Some(id.to_string());
}
let raw = cmd.query.join(" ");
let raw = raw.trim();
if raw.is_empty() || raw.split_whitespace().count() > 1 {
return None;
}
let coord_parts = raw.split(':').collect::<Vec<_>>();
if (coord_parts.len() == 2 || coord_parts.len() == 3)
&& coord_parts.iter().all(|part| !part.trim().is_empty())
{
return Some(coord_parts[1].trim().to_string());
}
if raw.contains(':') {
None
} else {
Some(raw.to_string())
}
}
fn sort_search_artifacts_by_popularity(
artifacts: &mut [serde_json::Value],
exact_artifact_id: Option<&str>,
) {
artifacts.sort_by(|left, right| {
let left_exact = search_artifact_id_matches(left, exact_artifact_id);
let right_exact = search_artifact_id_matches(right, exact_artifact_id);
right_exact
.cmp(&left_exact)
.then_with(|| {
let left_versions = left
.get("versionCount")
.and_then(|value| value.as_u64())
.unwrap_or(1);
let right_versions = right
.get("versionCount")
.and_then(|value| value.as_u64())
.unwrap_or(1);
right_versions.cmp(&left_versions)
})
.then_with(|| {
let left_timestamp = left
.get("timestamp")
.and_then(|value| value.as_i64())
.unwrap_or(0);
let right_timestamp = right
.get("timestamp")
.and_then(|value| value.as_i64())
.unwrap_or(0);
right_timestamp.cmp(&left_timestamp)
})
.then_with(|| search_artifact_name(left).cmp(&search_artifact_name(right)))
});
}
fn search_artifact_id_matches(
artifact: &serde_json::Value,
exact_artifact_id: Option<&str>,
) -> bool {
exact_artifact_id
.and_then(|id| {
artifact
.get("artifactId")
.or_else(|| artifact.get("a"))
.and_then(|value| value.as_str())
.map(|artifact_id| artifact_id.eq_ignore_ascii_case(id))
})
.unwrap_or(false)
}
fn print_search_table(artifacts: &[serde_json::Value]) {
if artifacts.is_empty() {
return;
}
let headers = ["ARTIFACT", "VERSION", "PACKAGING", "VERSIONS"];
let rows = artifacts
.iter()
.map(|artifact| {
[
search_artifact_name(artifact),
search_artifact_field(artifact, "version"),
search_artifact_field(artifact, "packaging"),
artifact
.get("versionCount")
.and_then(|value| value.as_u64())
.unwrap_or(1)
.to_string(),
]
})
.collect::<Vec<_>>();
let mut widths = [
headers[0].len(),
headers[1].len(),
headers[2].len(),
headers[3].len(),
];
for row in &rows {
for (idx, value) in row.iter().enumerate() {
widths[idx] = widths[idx].max(value.len());
}
}
println!(
"{:<w0$} {:<w1$} {:<w2$} {}",
headers[0],
headers[1],
headers[2],
headers[3],
w0 = widths[0],
w1 = widths[1],
w2 = widths[2]
);
for row in rows {
println!(
"{:<w0$} {:<w1$} {:<w2$} {}",
row[0],
row[1],
row[2],
row[3],
w0 = widths[0],
w1 = widths[1],
w2 = widths[2]
);
}
}
fn search_artifact_name(artifact: &serde_json::Value) -> String {
artifact
.get("artifact")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string()
}
fn search_artifact_field(artifact: &serde_json::Value, field: &str) -> String {
artifact
.get(field)
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string()
}
fn search_doc_json(doc: &serde_json::Value) -> serde_json::Value {
let group = doc.get("g").and_then(|value| value.as_str()).unwrap_or("");
let artifact = doc.get("a").and_then(|value| value.as_str()).unwrap_or("");
let version = doc
.get("v")
.or_else(|| doc.get("latestVersion"))
.and_then(|value| value.as_str())
.unwrap_or("");
let coordinate = if group.is_empty() || artifact.is_empty() || version.is_empty() {
doc.get("id")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string()
} else {
format!("{group}:{artifact}:{version}")
};
serde_json::json!({
"coordinate": coordinate,
"artifact": if group.is_empty() || artifact.is_empty() { doc.get("id").and_then(|value| value.as_str()).unwrap_or("").to_string() } else { format!("{group}:{artifact}") },
"groupId": group,
"artifactId": artifact,
"version": version,
"latestVersion": doc.get("latestVersion").and_then(|value| value.as_str()),
"packaging": doc.get("p").and_then(|value| value.as_str()).unwrap_or(""),
"versionCount": doc.get("versionCount").and_then(|value| value.as_u64()).unwrap_or(1),
"timestamp": doc.get("timestamp").and_then(|value| value.as_i64()),
"repositoryId": doc.get("repositoryId").and_then(|value| value.as_str()),
"classifiers": doc.get("ec").cloned().unwrap_or_else(|| serde_json::json!([])),
"raw": doc,
})
}
fn tools_payload(script: &std::path::Path, output: &jbx::BuildOutput) -> serde_json::Value {
let directives = &output.directives;
serde_json::json!({
"originalResource": script.to_string_lossy(),
"backingResource": script.to_string_lossy(),
"applicationClassesDir": output.classes_dir.to_string_lossy(),
"applicationJar": null,
"mainClass": &output.main_class,
"dependencies": &directives.deps,
"repositories": directives.repos.iter().map(|repo| repo_json(repo)).collect::<Vec<_>>(),
"resolvedDependencies": output.classpath.iter().map(|path| path.to_string_lossy().to_string()).collect::<Vec<_>>(),
"javaVersion": &directives.java_version,
"requestedJavaVersion": &directives.java_version,
"compileOptions": &directives.javac_options,
"runtimeOptions": &directives.runtime_options,
"nativeOptions": &directives.native_options,
"javaAgents": key_values_json(&directives.java_agents),
"manifestOptions": key_values_json(&directives.manifest_options),
"files": &directives.files,
"sources": &directives.sources,
"description": &directives.description,
"gav": &directives.gav,
"module": &directives.module,
"docs": docs_json(&directives.docs),
"enablePreview": directives.enable_preview,
"enableCds": directives.enable_cds,
"disableIntegrations": directives.disable_integrations,
})
}
fn format_cli_java_agent(agent: &KeyValue) -> String {
match &agent.value {
Some(value) => format!("-javaagent:{}={}", agent.key, value),
None => format!("-javaagent:{}", agent.key),
}
}
const DEFAULT_PALANTIR_JAVA_FORMAT_VERSION: &str = "2.91.0";
const TOOL_VERSION_CACHE_MAX_AGE_SECS: u64 = 7 * 24 * 60 * 60;
const PALANTIR_GROUP_PATH: &str = "com/palantir/javaformat";
const PALANTIR_MAIN_CLASS: &str = "com.palantir.javaformat.java.Main";
const COMPACT_WRAPPER_CLASS: &str = "__JuvFormatterWrapper";
const PALANTIR_GROUP_ID: &str = "com.palantir.javaformat";
const PALANTIR_ARTIFACT_ID: &str = "palantir-java-format";
const JBX_GRAPH_HELPER_COORDINATE: &str = "dev.telegraphic.jbx:jbx-graph:0.1.1";
const JBX_GRAPH_MAIN_CLASS: &str = "dev.telegraphic.jbx.graph.JbxGraph";
const DEFAULT_OPENREWRITE_VERSION: &str = "8.56.1";
const JBX_REWRITE_HELPER_COORDINATE: &str = "dev.telegraphic.jbx:jbx-rewrite:0.1.2";
const JBX_REWRITE_MAIN_CLASS: &str = "dev.telegraphic.jbx.rewrite.JbxRewrite";
const JBX_REWRITE_HELPER_SOURCE: &str = include_str!("rewrite_helper/JbxRewrite.java");
const SLF4J_VERSION: &str = "2.0.17";
#[derive(Debug, Clone)]
enum FormatterBackend {
Native(PathBuf),
Jar {
java: PathBuf,
classpath: Vec<PathBuf>,
},
}
fn run_fmt(cmd: FmtCommand) -> Result<i32> {
let files = collect_java_files(&cmd.paths)?;
if files.is_empty() {
return Ok(0);
}
let backend =
resolve_formatter_backend(cmd.cache_dir.as_deref(), cmd.formatter_version.as_deref())?;
let mut changed = Vec::new();
for file in files {
if format_one_file(&backend, &file, cmd.check)? {
changed.push(file);
}
}
if cmd.check && !changed.is_empty() {
for file in &changed {
eprintln!("would reformat {}", file.display());
}
return Ok(1);
}
Ok(0)
}
fn collect_java_files(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
for path in paths {
if path.is_file() {
if is_java_file(path) {
files.push(path.clone());
}
continue;
}
if path.is_dir() {
for entry in walkdir::WalkDir::new(path)
.into_iter()
.filter_entry(|entry| {
!entry.file_type().is_dir() || !is_ignored_fmt_dir(entry.path())
})
{
let entry = entry.with_context(|| format!("failed to read {}", path.display()))?;
let entry_path = entry.path();
if entry.file_type().is_file() && is_java_file(entry_path) {
files.push(entry_path.to_path_buf());
}
}
continue;
}
return Err(anyhow::anyhow!("fmt path not found: {}", path.display()));
}
files.sort();
files.dedup();
Ok(files)
}
fn is_java_file(path: &Path) -> bool {
path.extension().and_then(|ext| ext.to_str()) == Some("java")
}
fn is_ignored_fmt_dir(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| matches!(name, ".git" | "target" | "build" | ".gradle" | ".jbang"))
}
fn resolve_formatter_backend(
cache_dir: Option<&Path>,
version: Option<&str>,
) -> Result<FormatterBackend> {
if version.is_none() {
if let Ok(path) = which::which("palantir-java-format") {
return Ok(FormatterBackend::Native(path));
}
}
let version = match version {
Some(version) => version.to_string(),
None => latest_cached_tool_version(
cache_dir,
PALANTIR_GROUP_ID,
PALANTIR_ARTIFACT_ID,
&[jbx::resolver::Repository::central()],
)
.unwrap_or_else(|err| {
eprintln!(
"warning: could not determine latest Palantir Java Format version: {err:#}; using {DEFAULT_PALANTIR_JAVA_FORMAT_VERSION}"
);
DEFAULT_PALANTIR_JAVA_FORMAT_VERSION.to_string()
}),
};
if let Some(native) = cached_or_downloaded_native_formatter(cache_dir, &version)? {
return Ok(FormatterBackend::Native(native));
}
cached_or_downloaded_jar_formatter(cache_dir, &version)
}
fn cached_or_downloaded_native_formatter(
cache_dir: Option<&Path>,
version: &str,
) -> Result<Option<PathBuf>> {
let Some(classifier) = native_formatter_classifier() else {
return Ok(None);
};
let root = cache_root(cache_dir)?
.join("formatters")
.join("palantir-java-format")
.join(version);
let bin = root.join(format!("palantir-java-format-{classifier}"));
if bin.exists() {
return Ok(Some(bin));
}
fs::create_dir_all(&root)?;
let base = format!("https://repo1.maven.org/maven2/{PALANTIR_GROUP_PATH}/palantir-java-format-native/{version}");
let artifact = format!("palantir-java-format-native-{version}-nativeImage-{classifier}.bin");
let url = format!("{base}/{artifact}");
let sha_url = format!("{url}.sha256");
let bytes = match ureq::get(&url).call() {
Ok(response) => {
let mut reader = response.into_reader();
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes)?;
bytes
}
Err(_) => return Ok(None),
};
let sha_text = ureq::get(&sha_url)
.call()
.with_context(|| format!("failed to fetch checksum for {url}"))?
.into_string()
.with_context(|| format!("failed to read checksum for {url}"))?;
let expected = sha_text
.split_whitespace()
.next()
.ok_or_else(|| anyhow::anyhow!("empty checksum response for {url}"))?;
let actual = format!("{:x}", <sha2::Sha256 as sha2::Digest>::digest(&bytes));
if actual != expected {
return Err(anyhow::anyhow!("checksum mismatch for {url}"));
}
fs::write(&bin, bytes)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(&bin)?.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&bin, permissions)?;
}
Ok(Some(bin))
}
fn native_formatter_classifier() -> Option<&'static str> {
match (std::env::consts::OS, std::env::consts::ARCH) {
("linux", "x86_64") => Some("linux-glibc_x86-64"),
("linux", "aarch64") => Some("linux-glibc_aarch64"),
("macos", "aarch64") => Some("macos_aarch64"),
_ => None,
}
}
fn cached_or_downloaded_jar_formatter(
cache_dir: Option<&Path>,
version: &str,
) -> Result<FormatterBackend> {
let cache = cache_root(cache_dir)?.join("deps");
let coordinate = format!("com.palantir.javaformat:palantir-java-format:{version}");
let repos = vec![jbx::resolver::Repository::central()];
let classpath = jbx::resolver::resolve_classpath(&[coordinate], &repos, &cache)?;
let java = jbx::jdk::java_bin_path(&jbx::jdk::resolve_jdk(&None, true)?);
Ok(FormatterBackend::Jar { java, classpath })
}
fn cache_root(cache_dir: Option<&Path>) -> Result<PathBuf> {
Ok(match cache_dir {
Some(path) => path.to_path_buf(),
None => default_cache_dir()?,
})
}
struct RewriteBackend {
java: PathBuf,
classpath: Vec<PathBuf>,
}
fn run_rewrite(cmd: RewriteCommand) -> Result<i32> {
match cmd.command {
RewriteSubcommand::Apply(cmd) => run_rewrite_run(cmd, true),
RewriteSubcommand::Patch(cmd) => run_rewrite_run(cmd, false),
RewriteSubcommand::Modules(cmd) => run_rewrite_modules(cmd),
RewriteSubcommand::Recipes(cmd) => run_rewrite_recipes(cmd),
}
}
fn run_rewrite_run(cmd: RewriteRunCommand, apply: bool) -> Result<i32> {
let backend = resolve_rewrite_backend(
&cmd.modules,
&cmd.repos,
cmd.cache_dir.as_deref(),
&cmd.rewrite_version,
)?;
let mut args = Vec::new();
for recipe in &cmd.recipes {
args.push("--recipe".to_string());
args.push(rewrite_recipe_name(recipe).to_string());
}
for source in &cmd.sources {
args.push("--source".to_string());
args.push(source.to_string_lossy().to_string());
}
for option in &cmd.options {
args.push("--option".to_string());
args.push(normalize_rewrite_option(option, &cmd.recipes)?);
}
args.push("--report".to_string());
args.push(cmd.report.to_string_lossy().to_string());
args.push(if apply { "--apply" } else { "--dry-run" }.to_string());
if cmd.json {
args.push("--json".to_string());
}
if cmd.fail_on_changes {
args.push("--fail-on-changes".to_string());
}
if cmd.no_fail_on_invalid_recipes {
args.push("--no-fail-on-invalid-recipes".to_string());
}
run_rewrite_helper_and_forward(&backend, &args)
}
fn run_rewrite_modules(cmd: RewriteModulesCommand) -> Result<i32> {
let mut modules = search_rewrite_modules(&cmd)?;
if let Some(limit) = cmd.limit {
modules.truncate(limit);
}
if cmd.json {
println!(
"{}",
serde_json::to_string_pretty(&rewrite_modules_json(&modules))?
);
} else {
print_rewrite_module_table(&modules);
}
Ok(0)
}
fn run_rewrite_recipes(cmd: RewriteRecipesCommand) -> Result<i32> {
let modules = vec![cmd.module.clone()];
let backend = resolve_rewrite_backend(
&modules,
&cmd.repos,
cmd.cache_dir.as_deref(),
&cmd.rewrite_version,
)?;
let mut args = vec!["--discover".to_string()];
for (short, fqn) in rewrite_recipe_aliases() {
args.push("--alias".to_string());
args.push(format!("{short}={fqn}"));
}
if let Some(search) = &cmd.search {
args.push("--search".to_string());
args.push(search.clone());
}
if let Some(limit) = cmd.limit {
args.push("--limit".to_string());
args.push(limit.to_string());
}
if cmd.detail {
args.push("--detail".to_string());
}
if cmd.json {
args.push("--json".to_string());
}
run_rewrite_helper_and_forward(&backend, &args)
}
fn run_rewrite_helper_and_forward(backend: &RewriteBackend, args: &[String]) -> Result<i32> {
let output = run_rewrite_helper(backend, args)?;
print!("{}", String::from_utf8_lossy(&output.stdout));
if !output.stderr.is_empty() {
eprint!("{}", String::from_utf8_lossy(&output.stderr));
}
Ok(output.status.code().unwrap_or(1))
}
fn resolve_rewrite_backend(
modules: &[String],
repos: &[String],
cache_dir: Option<&Path>,
rewrite_version: &str,
) -> Result<RewriteBackend> {
let repos = maven_tool::maven_repositories(repos);
let cache = cache_root(cache_dir)?;
let deps_cache = cache.join("deps");
let java_home = jbx::jdk::resolve_jdk(&Some("21".to_string()), true)?;
let java_major = jbx::jdk::detect_jdk_major_version(&java_home).ok_or_else(|| {
anyhow::anyhow!(
"failed to detect rewrite helper JDK version from {}",
java_home.display()
)
})?;
let runtime_coordinates = rewrite_runtime_coordinates(modules, java_major, rewrite_version);
let helper_coordinate = rewrite_helper_coordinate();
let helper_override = std::env::var_os("JBX_REWRITE_HELPER_COORDINATE").is_some();
let mut published_coordinates = runtime_coordinates.clone();
published_coordinates.insert(helper_coordinate.clone());
let published_coordinate_vec = published_coordinates.into_iter().collect::<Vec<_>>();
match jbx::resolver::resolve_classpath(&published_coordinate_vec, &repos, &deps_cache) {
Ok(classpath) => {
let java = jbx::jdk::java_bin_path(&java_home);
Ok(RewriteBackend { java, classpath })
}
Err(error) if !helper_override && helper_coordinate == JBX_REWRITE_HELPER_COORDINATE => {
let fallback_coordinate_vec = runtime_coordinates.into_iter().collect::<Vec<_>>();
let mut classpath =
jbx::resolver::resolve_classpath(&fallback_coordinate_vec, &repos, &deps_cache)
.with_context(|| {
format!(
"failed to resolve OpenRewrite runtime dependencies after default helper coordinate {helper_coordinate} was unavailable: {error}"
)
})?;
let helper_classes = compile_rewrite_helper(&cache, &classpath, &java_home)?;
classpath.push(helper_classes);
let java = jbx::jdk::java_bin_path(&java_home);
Ok(RewriteBackend { java, classpath })
}
Err(error) => Err(error).with_context(|| {
format!("failed to resolve OpenRewrite helper coordinate {helper_coordinate}")
}),
}
}
fn rewrite_runtime_coordinates(
modules: &[String],
java_major: u32,
rewrite_version: &str,
) -> BTreeSet<String> {
let mut coordinates = BTreeSet::new();
coordinates.insert(format!("org.openrewrite:rewrite-java:{rewrite_version}"));
coordinates.insert(rewrite_java_runtime_coordinate(java_major, rewrite_version));
coordinates.insert(format!("org.slf4j:slf4j-api:{SLF4J_VERSION}"));
coordinates.insert(format!("org.slf4j:slf4j-nop:{SLF4J_VERSION}"));
for module in modules {
coordinates.insert(rewrite_module_coordinate(module, rewrite_version));
}
coordinates
}
fn rewrite_helper_coordinate() -> String {
std::env::var("JBX_REWRITE_HELPER_COORDINATE")
.unwrap_or_else(|_| JBX_REWRITE_HELPER_COORDINATE.to_string())
}
fn rewrite_java_runtime_coordinate(java_major: u32, version: &str) -> String {
let parser_major = match java_major {
0..=8 => 8,
9..=11 => 11,
12..=17 => 17,
_ => 21,
};
format!("org.openrewrite:rewrite-java-{parser_major}:{version}")
}
fn rewrite_module_coordinate(module: &str, version: &str) -> String {
if module.contains(':') {
module.to_string()
} else if rewrite_core_module_short_names().contains(&module) {
format!("org.openrewrite:rewrite-{module}:{version}")
} else {
format!("org.openrewrite.recipe:rewrite-{module}:{version}")
}
}
fn rewrite_core_module_short_names() -> &'static [&'static str] {
&[
"java",
"java-8",
"java-11",
"java-17",
"java-21",
"java-25",
"xml",
"yaml",
"properties",
"json",
"maven",
"gradle",
"groovy",
"kotlin",
"protobuf",
"hcl",
]
}
#[derive(Debug, Clone)]
struct RewriteModuleInfo {
short: String,
coordinate: String,
group_id: String,
artifact_id: String,
version: String,
version_count: u64,
}
fn search_rewrite_modules(cmd: &RewriteModulesCommand) -> Result<Vec<RewriteModuleInfo>> {
let groups = if cmd.groups.is_empty() {
vec![
"org.openrewrite.recipe".to_string(),
"org.openrewrite".to_string(),
]
} else {
cmd.groups.clone()
};
let mut modules = Vec::new();
for group in groups {
let query = rewrite_module_search_query(&group, cmd.search.as_deref());
let response = ureq::get(&maven_search_endpoint())
.query("q", &query)
.query(
"rows",
&maven_search_fetch_rows(cmd.limit.unwrap_or(20)).to_string(),
)
.query("wt", "json")
.set("User-Agent", "jbx")
.call()
.with_context(|| {
format!("failed to search Maven Central for OpenRewrite modules: {query}")
})?
.into_string()
.context("failed to read Maven Central module search response")?;
let response: serde_json::Value = serde_json::from_str(&response)
.context("failed to parse Maven Central module search response")?;
for doc in response
.get("response")
.and_then(|value| value.get("docs"))
.and_then(|value| value.as_array())
.into_iter()
.flatten()
{
if let Some(module) = rewrite_module_from_search_doc(
doc,
cmd.search.as_deref(),
cmd.rewrite_version.as_deref(),
) {
modules.push(module);
}
}
}
modules.sort_by(|left, right| {
left.short
.cmp(&right.short)
.then_with(|| left.coordinate.cmp(&right.coordinate))
});
modules.dedup_by(|left, right| left.coordinate == right.coordinate);
Ok(modules)
}
fn rewrite_module_search_query(group: &str, search: Option<&str>) -> String {
let group = solr_escape_term(group.trim());
match search.map(str::trim).filter(|value| !value.is_empty()) {
Some(search) => format!("g:{group} AND {}", solr_escape_term(search)),
None => format!("g:{group} AND a:rewrite\\-*"),
}
}
fn solr_escape_term(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"' | '~'
| '*' | '?' | ':' | '\\' | '/' | ' ' | '\t' | '\n' | '\r' => {
escaped.push('\\');
escaped.push(ch);
}
_ => escaped.push(ch),
}
}
escaped
}
fn rewrite_module_from_search_doc(
doc: &serde_json::Value,
search: Option<&str>,
requested_version: Option<&str>,
) -> Option<RewriteModuleInfo> {
let group = doc.get("g")?.as_str()?;
let artifact = doc.get("a")?.as_str()?;
if !artifact.starts_with("rewrite-") {
return None;
}
let search = search.map(str::trim).filter(|value| !value.is_empty());
if let Some(search) = search {
let needle = search.to_lowercase();
let haystack = format!("{group}:{artifact}").to_lowercase();
if !haystack.contains(&needle) {
return None;
}
}
let version = requested_version
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| {
doc.get("latestVersion")
.or_else(|| doc.get("v"))
.and_then(|value| value.as_str())
})
.unwrap_or("");
if version.is_empty() {
return None;
}
Some(RewriteModuleInfo {
short: artifact
.strip_prefix("rewrite-")
.unwrap_or(artifact)
.to_string(),
coordinate: format!("{group}:{artifact}:{version}"),
group_id: group.to_string(),
artifact_id: artifact.to_string(),
version: version.to_string(),
version_count: doc
.get("versionCount")
.and_then(|value| value.as_u64())
.unwrap_or(1),
})
}
fn rewrite_modules_json(modules: &[RewriteModuleInfo]) -> serde_json::Value {
serde_json::Value::Array(
modules
.iter()
.map(|module| {
serde_json::json!({
"short": module.short,
"coordinate": module.coordinate,
"groupId": module.group_id,
"artifactId": module.artifact_id,
"version": module.version,
"versionCount": module.version_count,
})
})
.collect(),
)
}
fn print_rewrite_module_table(modules: &[RewriteModuleInfo]) {
if modules.is_empty() {
return;
}
let headers = ["SHORT", "MODULE", "VERSION"];
let rows = modules
.iter()
.map(|module| {
[
module.short.clone(),
module.coordinate.clone(),
module.version.clone(),
]
})
.collect::<Vec<_>>();
let mut widths = [headers[0].len(), headers[1].len(), headers[2].len()];
for row in &rows {
for (idx, value) in row.iter().enumerate() {
widths[idx] = widths[idx].max(value.len());
}
}
println!(
"{:<w0$} {:<w1$} {}",
headers[0],
headers[1],
headers[2],
w0 = widths[0],
w1 = widths[1]
);
for row in rows {
println!(
"{:<w0$} {:<w1$} {}",
row[0],
row[1],
row[2],
w0 = widths[0],
w1 = widths[1]
);
}
}
fn rewrite_recipe_aliases() -> Vec<(&'static str, &'static str)> {
vec![
("auto-format", "org.openrewrite.java.format.AutoFormat"),
("format", "org.openrewrite.java.format.AutoFormat"),
("cleanup", "org.openrewrite.java.cleanup.Cleanup"),
(
"remove-unused-imports",
"org.openrewrite.java.RemoveUnusedImports",
),
("change-package", "org.openrewrite.java.ChangePackage"),
]
}
fn rewrite_recipe_name(recipe: &str) -> &str {
rewrite_recipe_aliases()
.into_iter()
.find_map(|(short, fqn)| (recipe == short).then_some(fqn))
.unwrap_or(recipe)
}
fn normalize_rewrite_option(option: &str, recipes: &[String]) -> Result<String> {
let Some((raw_key, value)) = option.split_once('=') else {
anyhow::bail!("rewrite --option must use key=value: {option}");
};
let key = if recipes.len() == 1
&& rewrite_recipe_name(&recipes[0]) == "org.openrewrite.java.ChangePackage"
{
match raw_key {
"old" => "oldPackageName",
"new" => "newPackageName",
other => other,
}
} else {
raw_key
};
Ok(format!("{key}={value}"))
}
fn compile_rewrite_helper(
cache: &Path,
dependency_classpath: &[PathBuf],
java_home: &Path,
) -> Result<PathBuf> {
let helper_root = cache.join("rewrite-helper");
let source_path = helper_root.join("src/dev/telegraphic/jbx/rewrite/JbxRewrite.java");
let classes_dir = helper_root.join("classes");
let stamp_path = helper_root.join("JbxRewrite.sha256");
let stamp = format!(
"{:x}",
<sha2::Sha256 as sha2::Digest>::digest(JBX_REWRITE_HELPER_SOURCE.as_bytes())
);
let current_stamp = fs::read_to_string(&stamp_path).unwrap_or_default();
if current_stamp.trim() == stamp
&& classes_dir
.join("dev/telegraphic/jbx/rewrite/JbxRewrite.class")
.exists()
{
return Ok(classes_dir);
}
fs::create_dir_all(source_path.parent().unwrap())?;
if classes_dir.exists() {
fs::remove_dir_all(&classes_dir)?;
}
fs::create_dir_all(&classes_dir)?;
fs::write(&source_path, JBX_REWRITE_HELPER_SOURCE)?;
let javac = jbx::jdk::javac_bin_path(java_home);
let classpath = std::env::join_paths(dependency_classpath)
.context("failed to build rewrite helper compile classpath")?;
let output = ProcessCommand::new(&javac)
.arg("--release")
.arg("17")
.arg("-proc:none")
.arg("-cp")
.arg(classpath)
.arg("-d")
.arg(&classes_dir)
.arg(&source_path)
.output()
.with_context(|| format!("failed to execute {}", javac.display()))?;
if !output.status.success() {
anyhow::bail!(
"failed to compile rewrite helper\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fs::write(stamp_path, stamp)?;
Ok(classes_dir)
}
fn run_rewrite_helper(backend: &RewriteBackend, args: &[String]) -> Result<std::process::Output> {
let mut command = ProcessCommand::new(&backend.java);
command
.arg("-Dorg.slf4j.simpleLogger.defaultLogLevel=warn")
.arg("-cp")
.arg(
std::env::join_paths(&backend.classpath)
.context("failed to build rewrite helper classpath")?,
)
.arg(JBX_REWRITE_MAIN_CLASS)
.args(args);
command
.output()
.with_context(|| format!("failed to execute {}", backend.java.display()))
}
struct GraphBackend {
java: PathBuf,
classpath: Vec<PathBuf>,
}
fn run_graph_dump(cmd: GraphDumpCommand) -> Result<i32> {
let backend = resolve_graph_backend(cmd.cache_dir.as_deref())?;
let args = vec!["dump".to_string(), cmd.script.to_string_lossy().to_string()];
let output = run_graph_helper(&backend, &args)?;
print!("{}", String::from_utf8_lossy(&output.stdout));
if !output.stderr.is_empty() {
eprint!("{}", String::from_utf8_lossy(&output.stderr));
}
Ok(output.status.code().unwrap_or(1))
}
fn run_graph_import(cmd: GraphImportCommand) -> Result<i32> {
let backend = resolve_graph_backend(cmd.cache_dir.as_deref())?;
let mut args = vec!["import".to_string(), cmd.json.to_string_lossy().to_string()];
if let Some(output) = cmd.output {
args.push("--output".to_string());
args.push(output.to_string_lossy().to_string());
}
let output = run_graph_helper(&backend, &args)?;
print!("{}", String::from_utf8_lossy(&output.stdout));
if !output.stderr.is_empty() {
eprint!("{}", String::from_utf8_lossy(&output.stderr));
}
Ok(output.status.code().unwrap_or(1))
}
fn resolve_graph_backend(cache_dir: Option<&Path>) -> Result<GraphBackend> {
let repos = vec![jbx::resolver::Repository::central()];
let cache = cache_root(cache_dir)?.join("deps");
let classpath = jbx::resolver::resolve_classpath(
&[JBX_GRAPH_HELPER_COORDINATE.to_string()],
&repos,
&cache,
)?;
let java = jbx::jdk::java_bin_path(&jbx::jdk::resolve_jdk(&None, true)?);
Ok(GraphBackend { java, classpath })
}
fn run_graph_helper(backend: &GraphBackend, args: &[String]) -> Result<std::process::Output> {
let mut command = ProcessCommand::new(&backend.java);
command
.arg("-Dorg.slf4j.simpleLogger.defaultLogLevel=warn")
.arg("-Dorg.slf4j.simpleLogger.showDateTime=true")
.arg("-cp")
.arg(
std::env::join_paths(&backend.classpath)
.context("failed to build graph helper classpath")?,
)
.arg(JBX_GRAPH_MAIN_CLASS)
.args(args);
command
.output()
.with_context(|| format!("failed to execute {}", backend.java.display()))
}
fn format_one_file(backend: &FormatterBackend, file: &Path, check: bool) -> Result<bool> {
let source =
fs::read_to_string(file).with_context(|| format!("failed to read {}", file.display()))?;
if is_compact_source(&source) {
let formatted = format_compact_source(backend, &source, file)?;
let changed = formatted != source;
if changed && !check {
fs::write(file, formatted)
.with_context(|| format!("failed to write {}", file.display()))?;
}
return Ok(changed);
}
if check {
let output = formatter_command(backend)
.arg("--dry-run")
.arg("--set-exit-if-changed")
.arg(file)
.output()
.with_context(|| format!("failed to execute formatter for {}", file.display()))?;
if output.status.success() {
return Ok(false);
}
if output.status.code() == Some(1) {
return Ok(true);
}
return Err(formatter_error(file, output));
}
let output = formatter_command(backend)
.arg("--replace")
.arg(file)
.output()
.with_context(|| format!("failed to execute formatter for {}", file.display()))?;
if !output.status.success() {
return Err(formatter_error(file, output));
}
let updated =
fs::read_to_string(file).with_context(|| format!("failed to read {}", file.display()))?;
Ok(updated != source)
}
fn formatter_command(backend: &FormatterBackend) -> ProcessCommand {
match backend {
FormatterBackend::Native(path) => ProcessCommand::new(path),
FormatterBackend::Jar { java, classpath } => {
let mut command = ProcessCommand::new(java);
command
.arg("--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED")
.arg("--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED")
.arg("--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED")
.arg("--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED")
.arg("--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED")
.arg("--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED")
.arg("-cp")
.arg(std::env::join_paths(classpath).unwrap_or_default())
.arg(PALANTIR_MAIN_CLASS);
command
}
}
}
fn formatter_error(file: &Path, output: std::process::Output) -> anyhow::Error {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
anyhow::anyhow!(
"formatter failed for {} with exit code {}\n{}{}",
file.display(),
output.status.code().unwrap_or(1),
stdout,
stderr
)
}
fn is_compact_source(source: &str) -> bool {
let mut brace_depth = 0usize;
for line in source.lines() {
let trimmed = line.trim_start();
let at_top_level = brace_depth == 0;
let starts_type = starts_with_java_type_declaration(trimmed);
if at_top_level && !starts_type && trimmed.starts_with("void main(") {
return true;
}
brace_depth = update_brace_depth(brace_depth, line);
}
false
}
fn update_brace_depth(mut depth: usize, line: &str) -> usize {
for ch in line.chars() {
match ch {
'{' => depth += 1,
'}' => depth = depth.saturating_sub(1),
_ => {}
}
}
depth
}
fn starts_with_java_type_declaration(trimmed: &str) -> bool {
const TYPE_DECLARATION_PREFIXES: &[&str] = &[
"class ",
"abstract class ",
"sealed class ",
"non-sealed class ",
"final class ",
"public class ",
"public abstract class ",
"public sealed class ",
"public non-sealed class ",
"public final class ",
"record ",
"public record ",
"interface ",
"public interface ",
"enum ",
"public enum ",
"@interface ",
"public @interface ",
];
TYPE_DECLARATION_PREFIXES
.iter()
.any(|prefix| trimmed.starts_with(prefix))
}
fn format_compact_source(backend: &FormatterBackend, source: &str, file: &Path) -> Result<String> {
let (prefix, body) = split_compact_prefix(source);
let indented_body = body
.lines()
.map(|line| format!(" {line}"))
.collect::<Vec<_>>()
.join("\n");
let wrapped = format!("{prefix}class {COMPACT_WRAPPER_CLASS} {{\n{indented_body}\n}}\n");
let output = formatter_command(backend)
.arg("-")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(wrapped.as_bytes())?;
}
child.wait_with_output()
})
.with_context(|| format!("failed to execute formatter for {}", file.display()))?;
if !output.status.success() {
return Err(formatter_error(file, output));
}
let formatted =
String::from_utf8(output.stdout).context("formatter emitted non-UTF-8 output")?;
unwrap_compact_source(&formatted)
}
fn split_compact_prefix(source: &str) -> (String, String) {
let mut prefix = String::new();
let mut body = String::new();
let mut in_prefix = true;
let mut in_block_comment = false;
for line in source.lines() {
let trimmed = line.trim_start();
if in_prefix {
let prefix_line = in_block_comment
|| trimmed.is_empty()
|| trimmed.starts_with("//")
|| trimmed.starts_with("#!")
|| trimmed.starts_with("import ")
|| trimmed.starts_with("/*");
if prefix_line {
prefix.push_str(line);
prefix.push('\n');
if in_block_comment || trimmed.starts_with("/*") {
in_block_comment = !trimmed.contains("*/");
}
continue;
}
in_prefix = false;
}
body.push_str(line);
body.push('\n');
}
(prefix, body.trim_end().to_string())
}
fn unwrap_compact_source(formatted: &str) -> Result<String> {
let lines = formatted.lines().collect::<Vec<_>>();
let wrapper_index = lines
.iter()
.position(|line| line.trim() == format!("class {COMPACT_WRAPPER_CLASS} {{"))
.ok_or_else(|| anyhow::anyhow!("formatter output did not contain compact wrapper"))?;
let wrapper_end = lines
.iter()
.enumerate()
.skip(wrapper_index + 1)
.filter_map(|(index, line)| (line.trim() == "}").then_some(index))
.next_back()
.ok_or_else(|| anyhow::anyhow!("formatter output did not contain compact wrapper end"))?;
let mut out = String::new();
for line in &lines[..wrapper_index] {
out.push_str(line);
out.push('\n');
}
let body_lines = &lines[wrapper_index + 1..wrapper_end];
let indent = body_lines
.iter()
.filter_map(|line| {
let trimmed = line.trim_start();
(!trimmed.is_empty()).then_some(line.len() - trimmed.len())
})
.min()
.unwrap_or(0);
for line in body_lines {
if line.len() >= indent {
out.push_str(&line[indent..]);
} else {
out.push_str(line);
}
out.push('\n');
}
Ok(out)
}
#[derive(Debug, Clone)]
struct PublishCoordinates {
group: String,
id: String,
version: String,
}
#[derive(Debug, Clone)]
struct PublishLicense {
name: String,
url: String,
}
#[derive(Debug, Clone)]
struct PublishDeveloper {
name: String,
email: Option<String>,
organization: Option<String>,
organization_url: Option<String>,
}
#[derive(Debug, Clone)]
struct PublishScm {
connection: String,
developer_connection: Option<String>,
url: String,
}
#[derive(Debug, Clone)]
struct PublishDescriptor {
script: PathBuf,
descriptor_dir: PathBuf,
coordinates: PublishCoordinates,
package_name: Option<String>,
name: Option<String>,
description: Option<String>,
url: Option<String>,
licenses: Vec<PublishLicense>,
developers: Vec<PublishDeveloper>,
scm: Option<PublishScm>,
java_version: Option<String>,
deps: Vec<String>,
runtime_deps: Vec<String>,
sources: Vec<String>,
auto_discover_sources: bool,
repos: Vec<String>,
}
fn run_publish(mut cmd: PublishCommand) -> Result<i32> {
if cmd.publish && cmd.dry_run {
anyhow::bail!("--dry-run and --publish are mutually exclusive; dry-run never uploads");
}
if !cmd.publish && !cmd.dry_run && cmd.serve.is_none() {
anyhow::bail!(
"publish requires --dry-run for local inspection, --publish for Maven Central upload, or --serve <port> for a local Maven repository server"
);
}
if cmd.publish && cmd.skip_signing {
anyhow::bail!("--publish requires signed artifacts; remove --skip-signing or use --dry-run for local inspection");
}
let descriptor = load_publish_descriptor(&cmd)?;
if let Some(port) = cmd.serve {
cmd.skip_signing = true;
let repository = prepare_publish_repository(&descriptor, &cmd)?;
write_maven_metadata(&repository, &descriptor, "maven-metadata.xml", true)?;
serve_maven_repository(&repository, port)?;
return Ok(0);
}
let bundle = prepare_publish_bundle(&descriptor, &cmd)?;
if !cmd.publish {
println!(
"prepared Maven Central dry run bundle for {}:{}:{} at {}",
descriptor.coordinates.group,
descriptor.coordinates.id,
descriptor.coordinates.version,
bundle.display()
);
return Ok(0);
}
let client = CentralClient::from_command(&cmd)?;
let deployment_name = format!(
"{}-{}",
descriptor.coordinates.id, descriptor.coordinates.version
);
let deployment_id = client.upload_bundle(&bundle, &deployment_name, cmd.publishing_type)?;
println!(
"uploaded Maven Central deployment {deployment_id} for {}:{}:{}",
descriptor.coordinates.group, descriptor.coordinates.id, descriptor.coordinates.version
);
if cmd.no_wait {
println!(
"deployment status polling skipped; check Maven Central Portal for {deployment_id}"
);
return Ok(0);
}
client.wait_for_publication(
&deployment_id,
cmd.publishing_type,
cmd.poll_interval,
cmd.max_wait_seconds,
)?;
Ok(0)
}
fn run_install(cmd: InstallCommand) -> Result<i32> {
let destination_arg = cmd.destination.clone();
let publish_cmd = PublishCommand {
script: cmd.script,
file: cmd.file,
version: cmd.version,
output: None,
target_dir: cmd.target_dir,
package_name: cmd.package_name,
cache_dir: cmd.cache_dir,
dry_run: false,
skip_signing: true,
gpg_key: None,
publish: false,
serve: None,
publishing_type: CentralPublishingType::Automatic,
central_url: None,
no_wait: false,
poll_interval: 5,
max_wait_seconds: 600,
};
let descriptor = load_publish_descriptor(&publish_cmd)?;
let repository = prepare_publish_repository(&descriptor, &publish_cmd)?;
let destination = cmd_destination_or_maven_local(destination_arg)?;
copy_dir_contents(&repository, &destination)?;
write_maven_metadata(&destination, &descriptor, "maven-metadata-local.xml", false)?;
let installed = destination
.join(descriptor.coordinates.group.replace('.', "/"))
.join(&descriptor.coordinates.id)
.join(&descriptor.coordinates.version);
println!(
"installed {}:{}:{} to {}",
descriptor.coordinates.group,
descriptor.coordinates.id,
descriptor.coordinates.version,
installed.display()
);
Ok(0)
}
fn cmd_destination_or_maven_local(destination: Option<PathBuf>) -> Result<PathBuf> {
if let Some(destination) = destination {
return Ok(destination);
}
dirs::home_dir()
.map(|home| home.join(".m2").join("repository"))
.ok_or_else(|| {
anyhow::anyhow!(
"could not determine home directory for Maven local repository; pass --destination"
)
})
}
fn copy_dir_contents(source: &Path, destination: &Path) -> Result<()> {
for entry in walkdir::WalkDir::new(source) {
let entry = entry?;
let relative = entry.path().strip_prefix(source)?;
if relative.as_os_str().is_empty() {
continue;
}
let target = destination.join(relative);
if entry.file_type().is_dir() {
fs::create_dir_all(&target)?;
} else if entry.file_type().is_file() {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(entry.path(), &target).with_context(|| {
format!(
"failed to copy {} to {}",
entry.path().display(),
target.display()
)
})?;
}
}
Ok(())
}
fn write_maven_metadata(
repository: &Path,
descriptor: &PublishDescriptor,
file_name: &str,
checksums: bool,
) -> Result<()> {
let artifact_dir = repository
.join(descriptor.coordinates.group.replace('.', "/"))
.join(&descriptor.coordinates.id);
fs::create_dir_all(&artifact_dir)?;
let metadata_path = artifact_dir.join(file_name);
let mut versions = metadata_path
.exists()
.then(|| fs::read_to_string(&metadata_path))
.transpose()?
.map(|text| maven_metadata_versions(&text))
.unwrap_or_default();
versions.insert(descriptor.coordinates.version.clone());
let metadata = render_maven_metadata(descriptor, &versions)?;
fs::write(&metadata_path, metadata)?;
if checksums {
write_checksums(&metadata_path)?;
}
Ok(())
}
fn maven_metadata_versions(text: &str) -> BTreeSet<String> {
let mut versions = BTreeSet::new();
let mut rest = text;
while let Some(start) = rest.find("<version>") {
rest = &rest[start + "<version>".len()..];
let Some(end) = rest.find("</version>") else {
break;
};
let version = rest[..end].trim();
if !version.is_empty() {
versions.insert(version.to_string());
}
rest = &rest[end + "</version>".len()..];
}
versions
}
fn render_maven_metadata(
descriptor: &PublishDescriptor,
versions: &BTreeSet<String>,
) -> Result<String> {
let version = &descriptor.coordinates.version;
let last_updated = maven_last_updated_timestamp()?;
let mut rendered_versions = String::new();
for version in versions {
rendered_versions.push_str(&format!(
"\n <version>{}</version>",
xml_escape(version)
));
}
Ok(format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<groupId>{}</groupId>
<artifactId>{}</artifactId>
<versioning>
<latest>{}</latest>
<release>{}</release>
<versions>{}
</versions>
<lastUpdated>{}</lastUpdated>
</versioning>
</metadata>
"#,
xml_escape(&descriptor.coordinates.group),
xml_escape(&descriptor.coordinates.id),
xml_escape(version),
xml_escape(version),
rendered_versions,
last_updated
))
}
fn maven_last_updated_timestamp() -> Result<String> {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system clock is before Unix epoch")?
.as_secs() as i64;
let days = seconds.div_euclid(86_400);
let seconds_of_day = seconds.rem_euclid(86_400);
let (year, month, day) = civil_from_days(days);
let hour = seconds_of_day / 3_600;
let minute = (seconds_of_day % 3_600) / 60;
let second = seconds_of_day % 60;
Ok(format!(
"{year:04}{month:02}{day:02}{hour:02}{minute:02}{second:02}"
))
}
fn civil_from_days(days_since_epoch: i64) -> (i64, u32, u32) {
let z = days_since_epoch + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = mp + if mp < 10 { 3 } else { -9 };
let year = y + if m <= 2 { 1 } else { 0 };
(year, m as u32, d as u32)
}
fn serve_maven_repository(repository: &Path, port: u16) -> Result<()> {
let listener = TcpListener::bind(("127.0.0.1", port))
.with_context(|| format!("failed to bind Maven repository server on port {port}"))?;
let address = listener.local_addr()?;
println!(
"serving Maven repository at http://{}/ from {}",
address,
repository.display()
);
std::io::stdout().flush()?;
for stream in listener.incoming() {
match stream {
Ok(stream) => {
if let Err(err) = handle_maven_repository_request(stream, repository) {
eprintln!("Maven repository request failed: {err}");
}
}
Err(err) => eprintln!("Maven repository connection failed: {err}"),
}
}
Ok(())
}
fn handle_maven_repository_request(mut stream: TcpStream, repository: &Path) -> Result<()> {
let mut reader = BufReader::new(stream.try_clone()?);
let mut request = String::new();
reader.read_line(&mut request)?;
let mut parts = request.split_whitespace();
let method = parts.next().unwrap_or_default();
let target = parts.next().unwrap_or("/");
if method != "GET" && method != "HEAD" {
write_http_response(&mut stream, 405, "Method Not Allowed", b"")?;
return Ok(());
}
let Some(path) = maven_repository_request_path(repository, target) else {
write_http_response(&mut stream, 404, "Not Found", b"")?;
return Ok(());
};
if !path.is_file() {
write_http_response(&mut stream, 404, "Not Found", b"")?;
return Ok(());
}
let body = if method == "HEAD" {
Vec::new()
} else {
fs::read(&path)?
};
write_http_response(&mut stream, 200, "OK", &body)?;
Ok(())
}
fn maven_repository_request_path(repository: &Path, target: &str) -> Option<PathBuf> {
let path = target.split('?').next().unwrap_or(target);
let path = path.trim_start_matches('/');
if path.is_empty() {
return None;
}
let mut relative = PathBuf::new();
for segment in path.split('/') {
if segment.is_empty() || segment == "." || segment == ".." || segment.contains('\\') {
return None;
}
relative.push(segment);
}
Some(repository.join(relative))
}
fn write_http_response(
stream: &mut TcpStream,
status: u16,
reason: &str,
body: &[u8],
) -> Result<()> {
write!(
stream,
"HTTP/1.1 {status} {reason}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)?;
stream.write_all(body)?;
Ok(())
}
struct CentralClient {
base_url: String,
authorization: String,
}
impl CentralClient {
fn from_command(cmd: &PublishCommand) -> Result<Self> {
let base_url = cmd
.central_url
.clone()
.or_else(|| std::env::var("CENTRAL_PORTAL_URL").ok())
.unwrap_or_else(|| "https://central.sonatype.com".to_string());
let token = central_bearer_token(cmd)?;
Ok(Self {
base_url: base_url.trim_end_matches('/').to_string(),
authorization: format!("Bearer {token}"),
})
}
fn upload_bundle(
&self,
bundle: &Path,
deployment_name: &str,
publishing_type: CentralPublishingType,
) -> Result<String> {
let filename = bundle
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("central-bundle.zip");
let boundary = multipart_boundary();
let body = central_multipart_body(&boundary, filename, &fs::read(bundle)?)?;
let url = format!(
"{}/api/v1/publisher/upload?name={}&publishingType={}",
self.base_url,
url_encode(deployment_name),
publishing_type.as_query_value()
);
let response = ureq::post(&url)
.set("Authorization", &self.authorization)
.set(
"Content-Type",
&format!("multipart/form-data; boundary={boundary}"),
)
.send_bytes(&body);
let text = central_response_text(response, "upload deployment bundle")?;
let deployment_id = text.trim();
if deployment_id.is_empty() {
anyhow::bail!("Maven Central upload succeeded but returned an empty deployment id");
}
Ok(deployment_id.to_string())
}
fn deployment_status(&self, deployment_id: &str) -> Result<serde_json::Value> {
let url = format!(
"{}/api/v1/publisher/status?id={}",
self.base_url,
url_encode(deployment_id)
);
let text = central_response_text(
ureq::post(&url)
.set("Authorization", &self.authorization)
.call(),
"read deployment status",
)?;
serde_json::from_str(&text)
.with_context(|| format!("invalid Maven Central status response: {text}"))
}
fn publish_deployment(&self, deployment_id: &str) -> Result<()> {
let url = format!(
"{}/api/v1/publisher/deployment/{}",
self.base_url,
url_encode(deployment_id)
);
central_response_text(
ureq::post(&url)
.set("Authorization", &self.authorization)
.call(),
"publish validated deployment",
)?;
Ok(())
}
fn wait_for_publication(
&self,
deployment_id: &str,
publishing_type: CentralPublishingType,
poll_interval: u64,
max_wait_seconds: u64,
) -> Result<()> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(max_wait_seconds);
let mut manual_publish_started = publishing_type == CentralPublishingType::Automatic;
loop {
let status = self.deployment_status(deployment_id)?;
let state = status
.get("deploymentState")
.and_then(|value| value.as_str())
.unwrap_or("UNKNOWN");
println!("Maven Central deployment {deployment_id}: {state}");
match state {
"PUBLISHED" => {
if let Some(purls) = status.get("purls").and_then(|value| value.as_array()) {
for purl in purls.iter().filter_map(|value| value.as_str()) {
println!("published {purl}");
}
}
return Ok(());
}
"FAILED" => {
anyhow::bail!(
"Maven Central deployment {deployment_id} failed: {}",
status
);
}
"VALIDATED" if !manual_publish_started => {
self.publish_deployment(deployment_id)?;
manual_publish_started = true;
}
"PENDING" | "VALIDATING" | "VALIDATED" | "PUBLISHING" => {}
_ => anyhow::bail!(
"unknown Maven Central deployment state for {deployment_id}: {state}"
),
}
if std::time::Instant::now() >= deadline {
anyhow::bail!("timed out waiting for Maven Central deployment {deployment_id} after {max_wait_seconds}s");
}
if poll_interval > 0 {
std::thread::sleep(std::time::Duration::from_secs(poll_interval));
}
}
}
}
fn central_bearer_token(_cmd: &PublishCommand) -> Result<String> {
if let Some(token) = first_env(&[
"CENTRAL_PORTAL_TOKEN",
"CENTRAL_TOKEN",
"MAVEN_CENTRAL_TOKEN",
"SONATYPE_TOKEN",
]) {
return Ok(token);
}
let username = first_env(&[
"CENTRAL_TOKEN_USERNAME",
"CENTRAL_PORTAL_USERNAME",
"CENTRAL_USERNAME",
"MAVEN_CENTRAL_USERNAME",
"SONATYPE_USERNAME",
]);
let password = first_env(&[
"CENTRAL_TOKEN_PASSWORD",
"CENTRAL_PORTAL_PASSWORD",
"CENTRAL_PASSWORD",
"MAVEN_CENTRAL_PASSWORD",
"SONATYPE_PASSWORD",
]);
match (username, password) {
(Some(username), Some(password)) => Ok(base64::engine::general_purpose::STANDARD
.encode(format!("{username}:{password}"))),
_ => anyhow::bail!(
"Maven Central publishing requires CENTRAL_PORTAL_TOKEN or CENTRAL_TOKEN_USERNAME/CENTRAL_TOKEN_PASSWORD"
),
}
}
fn first_env(names: &[&str]) -> Option<String> {
names.iter().find_map(|name| std::env::var(name).ok())
}
fn central_response_text(
response: std::result::Result<ureq::Response, ureq::Error>,
operation: &str,
) -> Result<String> {
match response {
Ok(response) => response
.into_string()
.with_context(|| format!("failed to read Maven Central response for {operation}")),
Err(ureq::Error::Status(code, response)) => {
let body = response.into_string().unwrap_or_default();
anyhow::bail!("Maven Central {operation} failed with HTTP {code}: {body}");
}
Err(err) => Err(anyhow::anyhow!("Maven Central {operation} failed: {err}")),
}
}
fn multipart_boundary() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or_default();
format!("jbx-central-{}-{nanos}", std::process::id())
}
fn central_multipart_body(boundary: &str, filename: &str, bundle: &[u8]) -> Result<Vec<u8>> {
let mut body = Vec::new();
write!(body, "--{boundary}\r\n")?;
write!(
body,
"Content-Disposition: form-data; name=\"bundle\"; filename=\"{}\"\r\n",
filename.replace('\"', "%22")
)?;
write!(body, "Content-Type: application/octet-stream\r\n\r\n")?;
body.extend_from_slice(bundle);
write!(body, "\r\n--{boundary}--\r\n")?;
Ok(body)
}
fn url_encode(value: &str) -> String {
value
.bytes()
.flat_map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
vec![byte as char]
}
_ => format!("%{byte:02X}").chars().collect(),
})
.collect()
}
fn load_publish_descriptor(cmd: &PublishCommand) -> Result<PublishDescriptor> {
let descriptor_path = match &cmd.file {
Some(path) => Some(path.clone()),
None => {
let candidate = PathBuf::from("jbx.json");
candidate.exists().then_some(candidate)
}
};
let mut script = cmd.script.clone();
let mut descriptor_dir = PathBuf::from(".");
let mut coordinates = None;
let mut package_name = None;
let mut name = None;
let mut description = None;
let mut url = None;
let mut licenses = Vec::new();
let mut developers = Vec::new();
let mut scm = None;
let mut java_version = None;
let mut deps = Vec::new();
let mut runtime_deps = Vec::new();
let mut sources = Vec::new();
let mut descriptor_sources_present = false;
let mut repos = Vec::new();
if let Some(path) = descriptor_path {
let text = fs::read_to_string(&path)
.with_context(|| format!("failed to read descriptor {}", path.display()))?;
let json: serde_json::Value = serde_json::from_str(&text)
.with_context(|| format!("failed to parse descriptor {}", path.display()))?;
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
descriptor_dir = base_dir.to_path_buf();
if script.is_none() {
if let Some(main) = json.get("main").and_then(|value| value.as_str()) {
script = Some(resolve_publish_main_path(base_dir, main));
}
}
if json.get("group").is_some() || json.get("id").is_some() || json.get("version").is_some()
{
coordinates = Some(parse_descriptor_coordinates(&json)?);
}
package_name = json
.get("package")
.and_then(|value| value.as_str())
.map(ToOwned::to_owned);
name = json
.get("name")
.and_then(|value| value.as_str())
.map(ToOwned::to_owned);
description = json
.get("description")
.and_then(|value| value.as_str())
.map(ToOwned::to_owned);
url = json
.get("url")
.and_then(|value| value.as_str())
.map(ToOwned::to_owned);
licenses = parse_descriptor_licenses(&json)?;
developers = parse_descriptor_developers(&json)?;
scm = parse_descriptor_scm(&json)?;
java_version = json
.get("java")
.and_then(|value| value.as_str())
.map(ToOwned::to_owned);
deps = string_array(&json, "dependencies")?;
runtime_deps = string_array(&json, "runtimeDependencies")?;
descriptor_sources_present = json.get("sources").is_some();
sources = string_array(&json, "sources")?;
repos = string_array(&json, "repositories")?;
}
let script =
script.ok_or_else(|| anyhow::anyhow!("publish requires a script or jbx.json main"))?;
if !script.exists() {
anyhow::bail!(
"publish main source not found: {}{}",
script.display(),
publish_main_hint(&script)
);
}
let directives = parsed_directives(&script)
.with_context(|| format!("failed to read publish main source {}", script.display()))?;
if coordinates.is_none() {
if let Some(raw) = directives.gav.as_deref() {
coordinates = Some(parse_gav_directive(raw)?);
}
}
if description.is_none() {
description = directives.description.clone();
}
let github = infer_github_publish_metadata();
if url.is_none() {
url = github.as_ref().and_then(|metadata| metadata.url.clone());
}
if licenses.is_empty() {
licenses = github
.as_ref()
.and_then(|metadata| metadata.license.clone())
.into_iter()
.collect();
}
if developers.is_empty() {
developers = github
.as_ref()
.and_then(|metadata| metadata.developer.clone())
.into_iter()
.collect();
}
if scm.is_none() {
scm = github.and_then(|metadata| metadata.scm);
}
if java_version.is_none() {
java_version = directives.java_version.clone();
}
if deps.is_empty() {
deps = directives.deps.clone();
}
if runtime_deps.is_empty() {
runtime_deps = directives.runtime_deps.clone();
}
if sources.is_empty() {
sources = directives.sources.clone();
}
if repos.is_empty() {
repos = directives.repos.clone();
}
let mut coordinates = coordinates
.ok_or_else(|| anyhow::anyhow!("publish requires group, id, and version metadata"))?;
if let Some(version) = &cmd.version {
coordinates.version = version.clone();
}
if let Some(package_name_override) = &cmd.package_name {
package_name = Some(package_name_override.clone());
}
validate_group(&coordinates.group)?;
validate_path_safe_coordinate_part(&coordinates.id, "id")?;
validate_path_safe_coordinate_part(&coordinates.version, "version")?;
if let Some(package_name) = package_name.as_deref() {
validate_package_name(package_name)?;
}
let name = name.or_else(|| Some(format!("{}:{}", coordinates.group, coordinates.id)));
if coordinates.version.ends_with("-SNAPSHOT") {
anyhow::bail!("Maven Central does not accept -SNAPSHOT versions");
}
if description.is_none() {
description = Some(format!("{} published with jbx", coordinates.id));
}
if cmd.publish || cmd.dry_run {
require_publish_metadata("url", url.as_deref())?;
if licenses.is_empty() {
anyhow::bail!("publish requires at least one license for Maven Central metadata");
}
if developers.is_empty() {
anyhow::bail!("publish requires at least one developer for Maven Central metadata");
}
if scm.is_none() {
anyhow::bail!("publish requires scm metadata for Maven Central");
}
}
Ok(PublishDescriptor {
script,
descriptor_dir,
coordinates,
package_name,
name,
description,
url,
licenses,
developers,
scm,
java_version,
deps,
runtime_deps,
sources,
auto_discover_sources: !descriptor_sources_present,
repos,
})
}
fn resolve_publish_main_path(base_dir: &Path, main: &str) -> PathBuf {
let raw = Path::new(main);
let exact = if raw.is_absolute() {
raw.to_path_buf()
} else {
base_dir.join(raw)
};
if exact.exists() {
return exact;
}
if let Some(candidate) = resolve_publish_main_fqn(base_dir, main) {
return candidate;
}
if raw.extension().is_some() {
return exact;
}
for extension in ["java", "jsh", "jav"] {
let candidate = exact.with_extension(extension);
if candidate.exists() {
return candidate;
}
}
exact
}
fn resolve_publish_main_fqn(base_dir: &Path, main: &str) -> Option<PathBuf> {
if !is_java_fqn(main) {
return None;
}
let (package_name, class_name) = main.rsplit_once('.')?;
let package_declaration = format!("package {package_name};");
let class_declaration = format!("class {class_name}");
let public_class_declaration = format!("public class {class_name}");
for entry in walkdir::WalkDir::new(base_dir)
.into_iter()
.filter_map(Result::ok)
{
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
if path.extension().and_then(|extension| extension.to_str()) != Some("java") {
continue;
}
if path.file_stem().and_then(|stem| stem.to_str()) != Some(class_name) {
continue;
}
let Ok(source) = fs::read_to_string(path) else {
continue;
};
if source.contains(&package_declaration)
&& (source.contains(&public_class_declaration) || source.contains(&class_declaration))
{
return Some(path.to_path_buf());
}
}
None
}
fn is_java_fqn(value: &str) -> bool {
value.split('.').filter(|part| !part.is_empty()).count() >= 2
&& value.split('.').all(is_java_identifier)
}
fn publish_main_hint(path: &Path) -> String {
if path.extension().is_some() {
String::new()
} else {
format!(
" (also checked {}.java, {}.jsh, {}.jav, and Java FQN matches under the descriptor directory)",
path.display(),
path.display(),
path.display()
)
}
}
fn parse_descriptor_coordinates(json: &serde_json::Value) -> Result<PublishCoordinates> {
let field = |name: &str| -> Result<String> {
json.get(name)
.and_then(|value| value.as_str())
.map(ToOwned::to_owned)
.ok_or_else(|| anyhow::anyhow!("{name} is required"))
};
Ok(PublishCoordinates {
group: field("group")?,
id: field("id")?,
version: field("version")?,
})
}
fn parse_gav_directive(raw: &str) -> Result<PublishCoordinates> {
let parts = raw.split(':').collect::<Vec<_>>();
if parts.len() != 3 {
anyhow::bail!("//GAV must have group:artifact:version");
}
Ok(PublishCoordinates {
group: parts[0].to_string(),
id: parts[1].to_string(),
version: parts[2].to_string(),
})
}
fn string_array(json: &serde_json::Value, name: &str) -> Result<Vec<String>> {
let Some(value) = json.get(name) else {
return Ok(Vec::new());
};
let array = value
.as_array()
.ok_or_else(|| anyhow::anyhow!("{name} must be an array of strings"))?;
array
.iter()
.map(|value| {
value
.as_str()
.map(ToOwned::to_owned)
.ok_or_else(|| anyhow::anyhow!("{name} must be an array of strings"))
})
.collect()
}
fn required_object_string(object: &serde_json::Value, name: &str) -> Result<String> {
object
.get(name)
.and_then(|value| value.as_str())
.map(ToOwned::to_owned)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!("{name} is required"))
}
fn optional_object_string(object: &serde_json::Value, name: &str) -> Option<String> {
object
.get(name)
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn parse_descriptor_licenses(json: &serde_json::Value) -> Result<Vec<PublishLicense>> {
let Some(value) = json.get("licenses") else {
return Ok(Vec::new());
};
let array = value
.as_array()
.ok_or_else(|| anyhow::anyhow!("licenses must be an array of objects"))?;
array
.iter()
.map(|license| {
Ok(PublishLicense {
name: required_object_string(license, "name")?,
url: required_object_string(license, "url")?,
})
})
.collect()
}
fn parse_descriptor_developers(json: &serde_json::Value) -> Result<Vec<PublishDeveloper>> {
let Some(value) = json.get("developers") else {
return Ok(Vec::new());
};
let array = value
.as_array()
.ok_or_else(|| anyhow::anyhow!("developers must be an array of objects"))?;
array
.iter()
.map(|developer| {
Ok(PublishDeveloper {
name: required_object_string(developer, "name")?,
email: optional_object_string(developer, "email"),
organization: optional_object_string(developer, "organization"),
organization_url: optional_object_string(developer, "organizationUrl"),
})
})
.collect()
}
fn parse_descriptor_scm(json: &serde_json::Value) -> Result<Option<PublishScm>> {
let Some(value) = json.get("scm") else {
return Ok(None);
};
Ok(Some(PublishScm {
connection: required_object_string(value, "connection")?,
developer_connection: optional_object_string(value, "developerConnection"),
url: required_object_string(value, "url")?,
}))
}
fn require_publish_metadata(name: &str, value: Option<&str>) -> Result<()> {
if value
.map(str::trim)
.filter(|value| !value.is_empty())
.is_none()
{
anyhow::bail!("publish requires {name} for Maven Central metadata");
}
Ok(())
}
#[derive(Debug, Default)]
struct InferredPublishMetadata {
url: Option<String>,
license: Option<PublishLicense>,
developer: Option<PublishDeveloper>,
scm: Option<PublishScm>,
}
fn infer_github_publish_metadata() -> Option<InferredPublishMetadata> {
let remote = ProcessCommand::new("git")
.args(["remote", "get-url", "origin"])
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|output| String::from_utf8(output.stdout).ok())?;
let repo = github_repo_slug(remote.trim())?;
let mut metadata = InferredPublishMetadata {
url: Some(format!("https://github.com/{repo}")),
scm: Some(PublishScm {
connection: format!("scm:git:https://github.com/{repo}.git"),
developer_connection: Some(format!("scm:git:ssh://git@github.com/{repo}.git")),
url: format!("https://github.com/{repo}"),
}),
..InferredPublishMetadata::default()
};
if let Some(json) = gh_repo_view(&repo) {
metadata.url = json
.get("url")
.and_then(|value| value.as_str())
.map(ToOwned::to_owned)
.or(metadata.url);
metadata.license = json.get("licenseInfo").and_then(github_license_from_json);
metadata.developer = json.get("owner").and_then(github_developer_from_owner);
}
Some(metadata)
}
fn github_repo_slug(remote: &str) -> Option<String> {
let without_suffix = remote.strip_suffix(".git").unwrap_or(remote);
if let Some(rest) = without_suffix.strip_prefix("git@github.com:") {
return Some(rest.to_string());
}
without_suffix
.strip_prefix("https://github.com/")
.map(ToOwned::to_owned)
}
fn gh_repo_view(repo: &str) -> Option<serde_json::Value> {
let output = ProcessCommand::new("gh")
.args(["repo", "view", repo, "--json", "url,licenseInfo,owner"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
serde_json::from_slice(&output.stdout).ok()
}
fn github_license_from_json(value: &serde_json::Value) -> Option<PublishLicense> {
let name = value
.get("name")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())?;
let spdx = value
.get("spdxId")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty());
Some(PublishLicense {
name: name.to_string(),
url: spdx
.map(|spdx| format!("https://spdx.org/licenses/{spdx}.html"))
.unwrap_or_else(|| "https://opensource.org/licenses".to_string()),
})
}
fn github_developer_from_owner(value: &serde_json::Value) -> Option<PublishDeveloper> {
let login = value
.get("login")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())?;
Some(PublishDeveloper {
name: login.to_string(),
email: None,
organization: None,
organization_url: Some(format!("https://github.com/{login}")),
})
}
fn validate_group(value: &str) -> Result<()> {
validate_coordinate_part(value, "group")?;
if value
.split('.')
.any(|segment| segment.is_empty() || segment == "." || segment == "..")
{
anyhow::bail!("invalid group: {value}");
}
Ok(())
}
fn validate_path_safe_coordinate_part(value: &str, name: &str) -> Result<()> {
validate_coordinate_part(value, name)?;
if value == "." || value == ".." {
anyhow::bail!("invalid {name}: {value}");
}
Ok(())
}
fn validate_coordinate_part(value: &str, name: &str) -> Result<()> {
if value.is_empty()
|| value
.chars()
.any(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')))
{
anyhow::bail!("invalid {name}: {value}");
}
Ok(())
}
fn validate_package_name(value: &str) -> Result<()> {
if value
.split('.')
.any(|part| part.is_empty() || !is_java_identifier(part))
{
anyhow::bail!("invalid package name: {value}");
}
Ok(())
}
fn is_java_identifier(value: &str) -> bool {
let mut chars = value.chars();
let Some(first) = chars.next() else {
return false;
};
(first == '_' || first == '$' || first.is_ascii_alphabetic())
&& chars.all(|c| c == '_' || c == '$' || c.is_ascii_alphanumeric())
}
fn prepare_publish_bundle(descriptor: &PublishDescriptor, cmd: &PublishCommand) -> Result<PathBuf> {
let repo_dir = prepare_publish_repository(descriptor, cmd)?;
let target_dir = cmd
.target_dir
.clone()
.unwrap_or_else(|| PathBuf::from("target/jbx-publish"));
let prefix = format!(
"{}-{}",
descriptor.coordinates.id, descriptor.coordinates.version
);
let bundle = cmd
.output
.clone()
.unwrap_or_else(|| target_dir.join(format!("{prefix}-central-bundle.zip")));
if let Some(parent) = bundle.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
}
zip_directory(&repo_dir, &bundle)?;
Ok(bundle)
}
fn prepare_publish_repository(
descriptor: &PublishDescriptor,
cmd: &PublishCommand,
) -> Result<PathBuf> {
let target_dir = cmd
.target_dir
.clone()
.unwrap_or_else(|| PathBuf::from("target/jbx-publish"));
let staging_dir = target_dir.join("staging");
let repo_dir = target_dir.join("repository");
if staging_dir.exists() {
fs::remove_dir_all(&staging_dir)?;
}
if repo_dir.exists() {
fs::remove_dir_all(&repo_dir)?;
}
fs::create_dir_all(&staging_dir)?;
fs::create_dir_all(&repo_dir)?;
let staged = stage_publish_sources(descriptor, &staging_dir)?;
let build = build_java(BuildOptions {
script: staged.script.clone(),
extra_deps: descriptor.deps.clone(),
extra_repos: descriptor.repos.clone(),
extra_sources: staged.extra_sources.clone(),
extra_files: Vec::new(),
classpath: Vec::new(),
javac_options: Vec::new(),
runtime_options: Vec::new(),
java_agents: Vec::new(),
java_version: descriptor.java_version.clone(),
main_class: None,
cache_dir: cmd.cache_dir.clone(),
trust_remote: false,
})?;
let base_rel = PathBuf::from(descriptor.coordinates.group.replace('.', "/"))
.join(&descriptor.coordinates.id)
.join(&descriptor.coordinates.version);
let artifact_dir = repo_dir.join(&base_rel);
fs::create_dir_all(&artifact_dir)?;
let prefix = format!(
"{}-{}",
descriptor.coordinates.id, descriptor.coordinates.version
);
let jar = artifact_dir.join(format!("{prefix}.jar"));
let staged_main_source = fs::read_to_string(&staged.script).with_context(|| {
format!(
"failed to read staged main source {}",
staged.script.display()
)
})?;
let manifest_main_class = if build.directives.main_class.is_some()
|| publish_source_has_main_entrypoint(&staged_main_source)
{
build.main_class.as_deref()
} else {
None
};
write_classes_jar_with_manifest(&build.classes_dir, &jar, manifest_main_class)?;
let sources_jar = artifact_dir.join(format!("{prefix}-sources.jar"));
write_directory_jar(&staging_dir, &sources_jar)?;
let javadoc_jar = artifact_dir.join(format!("{prefix}-javadoc.jar"));
write_javadoc_jar(
descriptor,
&staged.all_sources,
&build.classpath,
&target_dir,
&javadoc_jar,
)?;
let pom = artifact_dir.join(format!("{prefix}.pom"));
fs::write(&pom, render_pom(descriptor)?)?;
let (docs_markdown, docs_json) = publish_docs_outputs(descriptor, &staged)?;
let docs_md = artifact_dir.join(format!("{prefix}-jbx-docs.md"));
fs::write(&docs_md, docs_markdown)?;
let docs_json_path = artifact_dir.join(format!("{prefix}-jbx-docs.json"));
fs::write(&docs_json_path, docs_json)?;
for file in [
&jar,
&sources_jar,
&javadoc_jar,
&pom,
&docs_md,
&docs_json_path,
] {
write_checksums(file)?;
if !cmd.skip_signing {
write_gpg_signature(file, cmd.gpg_key.as_deref())?;
}
}
Ok(repo_dir)
}
struct StagedPublishSources {
script: PathBuf,
extra_sources: Vec<String>,
all_sources: Vec<PathBuf>,
}
fn stage_publish_sources(
descriptor: &PublishDescriptor,
staging_dir: &Path,
) -> Result<StagedPublishSources> {
let script = stage_publish_source_file(&descriptor.script, descriptor, staging_dir)?;
let mut extra_sources = Vec::new();
let mut all_sources = vec![script.clone()];
let mut source_paths = descriptor
.sources
.iter()
.map(|source| resolve_descriptor_relative_path(&descriptor.descriptor_dir, source))
.collect::<Vec<_>>();
if descriptor.auto_discover_sources {
source_paths.extend(discover_publish_source_files(
&descriptor.descriptor_dir,
&descriptor.script,
)?);
}
source_paths.sort();
source_paths.dedup();
let script_canonical = descriptor.script.canonicalize().ok();
for source_path in source_paths {
if script_canonical
.as_ref()
.is_some_and(|script| source_path.canonicalize().ok().as_ref() == Some(script))
{
continue;
}
let staged = stage_publish_source_file(&source_path, descriptor, staging_dir)
.with_context(|| format!("failed to stage source {}", source_path.display()))?;
let absolute = staged.to_string_lossy().to_string();
extra_sources.push(absolute);
all_sources.push(staged);
}
Ok(StagedPublishSources {
script,
extra_sources,
all_sources,
})
}
fn discover_publish_source_files(base_dir: &Path, main_source: &Path) -> Result<Vec<PathBuf>> {
if !base_dir.is_dir() {
return Ok(Vec::new());
}
let main_canonical = main_source.canonicalize().ok();
let mut files = Vec::new();
for entry in walkdir::WalkDir::new(base_dir)
.into_iter()
.filter_entry(|entry| {
!entry.file_type().is_dir() || !is_ignored_publish_source_dir(entry.path(), base_dir)
})
{
let entry = entry.with_context(|| format!("failed to scan {}", base_dir.display()))?;
let path = entry.path();
if !entry.file_type().is_file() || !is_java_file(path) {
continue;
}
if main_canonical
.as_ref()
.is_some_and(|main| path.canonicalize().ok().as_ref() == Some(main))
{
continue;
}
files.push(path.to_path_buf());
}
files.sort();
files.dedup();
Ok(files)
}
fn is_ignored_publish_source_dir(path: &Path, base_dir: &Path) -> bool {
if path == base_dir {
return false;
}
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
name.starts_with('.') || matches!(name, "target" | "build" | "out" | "classes")
})
}
fn resolve_descriptor_relative_path(base_dir: &Path, path: &str) -> PathBuf {
let path = Path::new(path);
if path.is_absolute() {
path.to_path_buf()
} else {
base_dir.join(path)
}
}
fn stage_publish_source_file(
source_path: &Path,
descriptor: &PublishDescriptor,
staging_dir: &Path,
) -> Result<PathBuf> {
let source = fs::read_to_string(source_path)
.with_context(|| format!("failed to read {}", source_path.display()))?;
let file_name = source_path
.file_name()
.ok_or_else(|| anyhow::anyhow!("invalid source path: {}", source_path.display()))?;
if let Some(package_name) = package_name_in_source(&source) {
let package_dir = staging_dir.join(package_name.replace('.', "/"));
fs::create_dir_all(&package_dir)?;
let target = package_dir.join(file_name);
fs::write(&target, source)?;
return Ok(target);
}
let package_name = descriptor.package_name.clone().unwrap_or_else(|| {
format!(
"{}.{}",
descriptor.coordinates.group,
descriptor.coordinates.id.replace('-', "")
)
});
if looks_like_compact_source(&source) {
let target = staging_dir.join(file_name);
fs::write(&target, source)?;
return Ok(target);
}
validate_package_name(&package_name)?;
let package_dir = staging_dir.join(package_name.replace('.', "/"));
fs::create_dir_all(&package_dir)?;
let target = package_dir.join(file_name);
fs::write(&target, format!("package {package_name};\n\n{source}"))?;
Ok(target)
}
fn package_name_in_source(source: &str) -> Option<String> {
let package_re = regex::Regex::new(
r"(?m)^\s*package\s+([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*;",
)
.expect("valid package regex");
package_re
.captures(source)
.and_then(|captures| captures.get(1))
.map(|package| package.as_str().to_string())
}
fn looks_like_compact_source(source: &str) -> bool {
let has_type_declaration = source.contains(" class ")
|| source.contains(" public class ")
|| source.contains(" record ")
|| source.contains(" interface ")
|| source.contains(" enum ");
!has_type_declaration && source.contains("void main(")
}
fn publish_source_has_main_entrypoint(source: &str) -> bool {
let main_re = regex::Regex::new(
r"(?m)\b(?:public\s+)?(?:static\s+)?void\s+main\s*\(\s*(?:String\s*(?:\[\]\s*)?\w*|String\s*\.\.\.\s*\w*)?\s*\)",
)
.expect("valid main method regex");
main_re.is_match(source)
}
fn render_pom(descriptor: &PublishDescriptor) -> Result<String> {
let name = descriptor
.name
.as_deref()
.unwrap_or(&descriptor.coordinates.id);
let description = descriptor
.description
.as_deref()
.map(|description| format!("\n <description>{}</description>", xml_escape(description)))
.unwrap_or_default();
let url = descriptor
.url
.as_deref()
.map(|url| format!("\n <url>{}</url>", xml_escape(url)))
.unwrap_or_default();
let dependencies = render_pom_dependencies(&descriptor.deps, &descriptor.runtime_deps)?;
let licenses = render_pom_licenses(&descriptor.licenses);
let developers = render_pom_developers(&descriptor.developers);
let scm = descriptor
.scm
.as_ref()
.map(render_pom_scm)
.unwrap_or_default();
Ok(format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>{}</groupId>
<artifactId>{}</artifactId>
<version>{}</version>
<packaging>jar</packaging>
<name>{}</name>{}{}{}{}{}{}
</project>
"#,
xml_escape(&descriptor.coordinates.group),
xml_escape(&descriptor.coordinates.id),
xml_escape(&descriptor.coordinates.version),
xml_escape(name),
description,
url,
licenses,
developers,
scm,
dependencies
))
}
fn render_pom_licenses(licenses: &[PublishLicense]) -> String {
if licenses.is_empty() {
return String::new();
}
let mut out = String::from("\n <licenses>");
for license in licenses {
out.push_str("\n <license>");
out.push_str(&format!(
"\n <name>{}</name>",
xml_escape(&license.name)
));
out.push_str(&format!("\n <url>{}</url>", xml_escape(&license.url)));
out.push_str("\n </license>");
}
out.push_str("\n </licenses>");
out
}
fn render_pom_developers(developers: &[PublishDeveloper]) -> String {
if developers.is_empty() {
return String::new();
}
let mut out = String::from("\n <developers>");
for developer in developers {
out.push_str("\n <developer>");
out.push_str(&format!(
"\n <name>{}</name>",
xml_escape(&developer.name)
));
if let Some(email) = developer.email.as_deref() {
out.push_str(&format!("\n <email>{}</email>", xml_escape(email)));
}
if let Some(organization) = developer.organization.as_deref() {
out.push_str(&format!(
"\n <organization>{}</organization>",
xml_escape(organization)
));
}
if let Some(organization_url) = developer.organization_url.as_deref() {
out.push_str(&format!(
"\n <organizationUrl>{}</organizationUrl>",
xml_escape(organization_url)
));
}
out.push_str("\n </developer>");
}
out.push_str("\n </developers>");
out
}
fn render_pom_scm(scm: &PublishScm) -> String {
let mut out = String::from("\n <scm>");
out.push_str(&format!(
"\n <connection>{}</connection>",
xml_escape(&scm.connection)
));
if let Some(developer_connection) = scm.developer_connection.as_deref() {
out.push_str(&format!(
"\n <developerConnection>{}</developerConnection>",
xml_escape(developer_connection)
));
}
out.push_str(&format!("\n <url>{}</url>", xml_escape(&scm.url)));
out.push_str("\n </scm>");
out
}
fn render_pom_dependencies(deps: &[String], runtime_deps: &[String]) -> Result<String> {
let parsed = deps
.iter()
.filter_map(|dep| {
jbx::resolver::parse_coordinate(dep)
.ok()
.map(|dep| (dep, None))
})
.chain(runtime_deps.iter().filter_map(|dep| {
jbx::resolver::parse_coordinate(dep)
.ok()
.map(|dep| (dep, Some("runtime")))
}))
.collect::<Vec<_>>();
if parsed.is_empty() {
return Ok(String::new());
}
let mut out = String::from("\n <dependencies>");
for (dep, scope) in parsed {
out.push_str("\n <dependency>");
out.push_str(&format!(
"\n <groupId>{}</groupId>",
xml_escape(&dep.module.org)
));
out.push_str(&format!(
"\n <artifactId>{}</artifactId>",
xml_escape(&dep.module.name)
));
out.push_str(&format!(
"\n <version>{}</version>",
xml_escape(&dep.version)
));
if let Some(classifier) = dep.classifier.as_deref() {
out.push_str(&format!(
"\n <classifier>{}</classifier>",
xml_escape(classifier)
));
}
if let Some(scope) = scope {
out.push_str(&format!("\n <scope>{}</scope>", xml_escape(scope)));
}
out.push_str("\n </dependency>");
}
out.push_str("\n </dependencies>");
Ok(out)
}
fn xml_escape(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn write_directory_jar(source_dir: &Path, jar: &Path) -> Result<()> {
let file = fs::File::create(jar)?;
let mut zip = zip::ZipWriter::new(file);
let options =
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
write_directory_entries(&mut zip, source_dir, options)?;
zip.finish()?;
Ok(())
}
fn write_classes_jar_with_manifest(
source_dir: &Path,
jar: &Path,
main_class: Option<&str>,
) -> Result<()> {
let file = fs::File::create(jar)?;
let mut zip = zip::ZipWriter::new(file);
let options =
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
zip.start_file("META-INF/MANIFEST.MF", options)?;
zip.write_all(render_publish_manifest(main_class).as_bytes())?;
write_directory_entries(&mut zip, source_dir, options)?;
zip.finish()?;
Ok(())
}
fn render_publish_manifest(main_class: Option<&str>) -> String {
match main_class {
Some(main_class) => format!("Manifest-Version: 1.0\nMain-Class: {main_class}\n\n"),
None => "Manifest-Version: 1.0\n\n".to_string(),
}
}
fn write_directory_entries(
zip: &mut zip::ZipWriter<fs::File>,
source_dir: &Path,
options: zip::write::SimpleFileOptions,
) -> Result<()> {
for entry in walkdir::WalkDir::new(source_dir) {
let entry = entry?;
if !entry.file_type().is_file() {
continue;
}
let rel = entry
.path()
.strip_prefix(source_dir)?
.to_string_lossy()
.replace('\\', "/");
zip.start_file(rel, options)?;
zip.write_all(&fs::read(entry.path())?)?;
}
Ok(())
}
fn publish_join_classpath(paths: &[PathBuf]) -> String {
let sep = if cfg!(windows) { ";" } else { ":" };
paths
.iter()
.map(|path| path.to_string_lossy())
.collect::<Vec<_>>()
.join(sep)
}
fn write_javadoc_jar(
descriptor: &PublishDescriptor,
sources: &[PathBuf],
classpath: &[PathBuf],
target_dir: &Path,
jar: &Path,
) -> Result<()> {
let javadoc_dir = target_dir.join("javadoc");
if javadoc_dir.exists() {
fs::remove_dir_all(&javadoc_dir)?;
}
fs::create_dir_all(&javadoc_dir)?;
let jdk_root = jbx::jdk::resolve_jdk(&descriptor.java_version, true)?;
let javadoc = jbx::jdk::javadoc_bin_path(&jdk_root);
let mut cmd = ProcessCommand::new(&javadoc);
cmd.arg("-quiet")
.arg("-d")
.arg(&javadoc_dir)
.arg("-sourcepath")
.arg(target_dir.join("staging"));
if !classpath.is_empty() {
cmd.arg("-classpath").arg(publish_join_classpath(classpath));
}
cmd.args(sources);
let status = cmd
.status()
.with_context(|| format!("failed to execute {}", javadoc.display()))?;
if !status.success() {
anyhow::bail!(
"javadoc failed with exit code {}",
status.code().unwrap_or(1)
);
}
write_directory_jar(&javadoc_dir, jar)
}
fn write_checksums(path: &Path) -> Result<()> {
let bytes = fs::read(path)?;
let md5 = {
use md5::Digest;
let mut hasher = md5::Md5::new();
hasher.update(&bytes);
format!("{:x}", hasher.finalize())
};
let sha1 = {
use sha1::Digest;
let mut hasher = sha1::Sha1::new();
hasher.update(&bytes);
format!("{:x}", hasher.finalize())
};
let sha256 = {
use sha2::Digest;
let mut hasher = sha2::Sha256::new();
hasher.update(&bytes);
format!("{:x}", hasher.finalize())
};
let sha512 = {
use sha2::Digest;
let mut hasher = sha2::Sha512::new();
hasher.update(&bytes);
format!("{:x}", hasher.finalize())
};
fs::write(
path.with_extension(format!("{}md5", extension_with_dot(path))),
md5,
)?;
fs::write(
path.with_extension(format!("{}sha1", extension_with_dot(path))),
sha1,
)?;
fs::write(
path.with_extension(format!("{}sha256", extension_with_dot(path))),
sha256,
)?;
fs::write(
path.with_extension(format!("{}sha512", extension_with_dot(path))),
sha512,
)?;
Ok(())
}
fn write_gpg_signature(path: &Path, gpg_key: Option<&str>) -> Result<()> {
let signature = path.with_extension(format!("{}asc", extension_with_dot(path)));
let mut cmd = ProcessCommand::new("gpg");
cmd.arg("--batch")
.arg("--yes")
.arg("--armor")
.arg("--detach-sign")
.arg("--output")
.arg(&signature);
if let Some(key) = gpg_key {
cmd.arg("--local-user").arg(key);
}
cmd.arg(path);
let output = cmd
.output()
.with_context(|| "failed to execute gpg for Maven Central signatures")?;
if !output.status.success() {
anyhow::bail!(
"gpg signing failed for {}; configure a signing key, pass --gpg-key, or use --skip-signing for unsigned dry-run inspection: {}",
path.display(),
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
fn extension_with_dot(path: &Path) -> String {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| format!("{ext}."))
.unwrap_or_default()
}
fn zip_directory(source_dir: &Path, output: &Path) -> Result<()> {
let file = fs::File::create(output)?;
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for entry in walkdir::WalkDir::new(source_dir) {
let entry = entry?;
if !entry.file_type().is_file() {
continue;
}
let rel = entry
.path()
.strip_prefix(source_dir)?
.to_string_lossy()
.replace('\\', "/");
zip.start_file(rel, options)?;
zip.write_all(&fs::read(entry.path())?)?;
}
zip.finish()?;
Ok(())
}
const DEFAULT_JUNIT_PLATFORM_VERSION: &str = "6.1.0";
#[derive(Debug)]
struct CheckInputs {
directives: jbx::Directives,
declared_sources: Vec<PathBuf>,
}
fn collect_check_directives(files: &[PathBuf]) -> Result<CheckInputs> {
let mut directives = jbx::Directives::default();
let mut declared_sources = Vec::new();
let mut visited = BTreeSet::new();
let mut queue: Vec<PathBuf> = files.to_vec();
while let Some(file) = queue.pop() {
let canonical_file = file
.canonicalize()
.with_context(|| format!("failed to resolve Java source {}", file.display()))?;
if !visited.insert(canonical_file.clone()) {
continue;
}
let source = fs::read_to_string(&canonical_file)
.with_context(|| format!("failed to read Java source {}", canonical_file.display()))?;
let parsed = jbx::parse_directives(&source);
let base_dir = canonical_file.parent().unwrap_or_else(|| Path::new("."));
for source in &parsed.sources {
let source_path = base_dir.join(source);
declared_sources.push(source_path.clone());
queue.push(source_path);
}
directives.deps.extend(parsed.deps);
directives.repos.extend(parsed.repos);
directives.sources.extend(parsed.sources);
directives.javac_options.extend(parsed.javac_options);
if directives.java_version.is_none() {
directives.java_version = parsed.java_version;
}
directives.enable_preview |= parsed.enable_preview;
}
declared_sources.sort();
declared_sources.dedup();
Ok(CheckInputs {
directives,
declared_sources,
})
}
fn has_source_or_release_option(options: &[String]) -> bool {
options.iter().any(|option| {
matches!(
option.as_str(),
"--source" | "-source" | "--release" | "-release"
) || option.starts_with("--source=")
|| option.starts_with("--release=")
})
}
fn run_check(cmd: CheckCommand) -> Result<i32> {
let mut files = collect_java_files(&cmd.paths)?;
if files.is_empty() {
if cmd.json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"ok": true,
"files": [],
"diagnostics": [],
"errorProne": !cmd.no_error_prone,
}))?
);
}
return Ok(0);
}
let inputs = collect_check_directives(&files)?;
let mut directives = inputs.directives;
directives.deps.extend(split_cli_words(&cmd.deps));
directives.repos.extend(split_cli_words(&cmd.repos));
directives.javac_options.extend(cmd.javac_options);
if cmd.java_version.is_some() {
directives.java_version = cmd.java_version;
}
let jdk_root = jbx::jdk::resolve_jdk(&directives.java_version, true)?;
let java = jbx::jdk::java_bin_path(&jdk_root);
let root = cache_root(cmd.cache_dir.as_deref())?.join("check");
let binary_deps = directives.deps;
files.extend(inputs.declared_sources);
files.sort();
files.dedup();
let repos = maven_tool::maven_repositories(&directives.repos);
let cache_dir = cache_root(cmd.cache_dir.as_deref())?.join("deps");
let mut classpath = cmd.classpath;
if !binary_deps.is_empty() {
classpath.extend(jbx::resolver::resolve_classpath(
&binary_deps,
&repos,
&cache_dir,
)?);
}
let mut compiler_options = vec!["-Xlint:all".to_string(), "-proc:none".to_string()];
let classes_dir = root.join("classes");
if classes_dir.exists() {
fs::remove_dir_all(&classes_dir)?;
}
fs::create_dir_all(&classes_dir)?;
compiler_options.push("-d".to_string());
compiler_options.push(classes_dir.to_string_lossy().to_string());
if !classpath.is_empty() {
compiler_options.push("-classpath".to_string());
compiler_options.push(
std::env::join_paths(&classpath)?
.to_string_lossy()
.to_string(),
);
}
compiler_options.extend(directives.javac_options);
if directives.enable_preview {
if !compiler_options
.iter()
.any(|option| option == "--enable-preview")
{
compiler_options.push("--enable-preview".to_string());
}
if !has_source_or_release_option(&compiler_options) {
let release_version =
jbx::jdk::detect_jdk_major_version(&jdk_root).with_context(|| {
format!("could not determine JDK version at {}", jdk_root.display())
})?;
compiler_options.push("--release".to_string());
compiler_options.push(release_version.to_string());
}
}
if cmd.warnings_as_errors {
compiler_options.push("-Werror".to_string());
}
let mut wrapper_classpath = jbx::resolver::resolve_classpath(
&[JBX_CHECK_COMPILER_COORDINATE.to_string()],
&repos,
&cache_dir,
)?;
if wrapper_classpath.is_empty() {
anyhow::bail!("no JARs resolved for {JBX_CHECK_COMPILER_COORDINATE}");
}
if !cmd.no_error_prone {
let error_prone_coordinate = format!(
"{ERROR_PRONE_GROUP_ID}:{ERROR_PRONE_ARTIFACT_ID}:{}",
cmd.error_prone_version
);
let error_prone_cp =
jbx::resolver::resolve_classpath(&[error_prone_coordinate], &repos, &cache_dir)?;
wrapper_classpath.extend(error_prone_cp);
compiler_options.push("-XDcompilePolicy=simple".to_string());
compiler_options.push("--should-stop=ifError=FLOW".to_string());
compiler_options.push("-Xplugin:ErrorProne -Xep:DefaultPackage:OFF".to_string());
}
let output = check_java_command(&java, &wrapper_classpath, &compiler_options, &files)?
.output()
.with_context(|| format!("failed to execute {}", java.display()))?;
let stdout = String::from_utf8_lossy(&output.stdout);
if cmd.json {
print!("{stdout}");
if !output.stderr.is_empty() {
eprint!("{}", String::from_utf8_lossy(&output.stderr));
}
return Ok(output.status.code().unwrap_or(1));
}
let payload: serde_json::Value = serde_json::from_slice(&output.stdout)
.with_context(|| format!("invalid jbx check wrapper output: {stdout}"))?;
print_check_human(&payload)?;
if !output.stderr.is_empty() {
eprint!("{}", String::from_utf8_lossy(&output.stderr));
}
Ok(output.status.code().unwrap_or(1))
}
fn check_java_command<'a>(
java: &'a Path,
wrapper_classpath: &'a [PathBuf],
compiler_options: &'a [String],
files: &'a [PathBuf],
) -> Result<ProcessCommand> {
let mut command = ProcessCommand::new(java);
command.args(error_prone_jdk_flags());
command.arg("-cp").arg(
std::env::join_paths(wrapper_classpath)
.context("failed to build jbx check compiler wrapper classpath")?,
);
command.arg(JBX_CHECK_COMPILER_MAIN_CLASS);
command.args(compiler_options);
command.arg("--");
command.args(files);
Ok(command)
}
fn error_prone_jdk_flags() -> [&'static str; 10] {
[
"--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
"--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
"--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
"--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED",
"--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED",
"--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED",
"--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
"--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
"--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
"--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
]
}
fn print_check_human(payload: &serde_json::Value) -> Result<()> {
let diagnostics = payload
.get("diagnostics")
.and_then(|value| value.as_array())
.ok_or_else(|| anyhow::anyhow!("check output did not contain diagnostics array"))?;
for diagnostic in diagnostics {
let kind = diagnostic
.get("kind")
.and_then(|value| value.as_str())
.unwrap_or("UNKNOWN")
.to_ascii_lowercase();
let file = diagnostic
.get("file")
.and_then(|value| value.as_str())
.unwrap_or("<compiler>");
let line = diagnostic
.get("line")
.and_then(|value| value.as_i64())
.unwrap_or(-1);
let column = diagnostic
.get("column")
.and_then(|value| value.as_i64())
.unwrap_or(-1);
let message = diagnostic
.get("message")
.and_then(|value| value.as_str())
.unwrap_or("");
if line > 0 && column > 0 {
println!("{file}:{line}:{column}: {kind}: {message}");
} else {
println!("{file}: {kind}: {message}");
}
}
if diagnostics.is_empty() {
println!("check passed");
}
Ok(())
}
const ERROR_PRONE_GROUP_ID: &str = "com.google.errorprone";
const ERROR_PRONE_ARTIFACT_ID: &str = "error_prone_core";
const DEFAULT_ERROR_PRONE_VERSION: &str = "2.39.0";
const JBX_CHECK_COMPILER_COORDINATE: &str = "dev.telegraphic.jbx:jbx-check:0.1.1";
const JBX_CHECK_COMPILER_MAIN_CLASS: &str = "dev.telegraphic.jbx.check.JbxCheckCompiler";
const JUNIT_GROUP_ID: &str = "org.junit.platform";
const JUNIT_ARTIFACT_ID: &str = "junit-platform-console-standalone";
const JACOCO_VERSION: &str = "0.8.13";
const JACOCO_AGENT_PREFIX: &str = "org.jacoco.agent-";
const JACOCO_CLI_PREFIX: &str = "org.jacoco.cli-";
const JACOCO_COVERAGE_FILE: &str = "target/jacoco.exec";
const JACOCO_REPORT_DIR: &str = "target/site/jacoco";
const JACOCO_XML_REPORT: &str = "target/site/jacoco/jacoco.xml";
const JACOCO_HTML_REPORT: &str = "target/site/jacoco/index.html";
fn run_tests(cmd: TestCommand) -> Result<i32> {
let junit_version = match cmd.junit_version.clone() {
Some(version) => version,
None => latest_cached_tool_version(
cmd.cache_dir.as_deref(),
JUNIT_GROUP_ID,
JUNIT_ARTIFACT_ID,
&[jbx::resolver::Repository::central()],
)
.unwrap_or_else(|err| {
eprintln!(
"warning: could not determine latest JUnit Platform Console Standalone version: {err:#}; using {DEFAULT_JUNIT_PLATFORM_VERSION}"
);
DEFAULT_JUNIT_PLATFORM_VERSION.to_string()
}),
};
let launcher_coordinate =
format!("org.junit.platform:junit-platform-console-standalone:{junit_version}");
let (script, inferred_directory_sources) = expand_test_target(&cmd.script)?;
let mut extra_sources = split_cli_words(&cmd.sources);
extra_sources.extend(inferred_directory_sources);
extra_sources.extend(infer_test_companion_sources(&script));
dedupe_strings(&mut extra_sources);
let mut directive_files = vec![script.clone()];
let base_dir = script
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
directive_files.extend(extra_sources.iter().map(|source| base_dir.join(source)));
let source_directives = collect_check_directives(&directive_files)?.directives;
let mut deps = source_directives.deps;
deps.extend(split_cli_words(&cmd.deps));
deps.push(launcher_coordinate);
if cmd.coverage {
let jacoco_version = cmd.jacoco_version.as_deref().unwrap_or(JACOCO_VERSION);
deps.push(format!(
"org.jacoco:org.jacoco.agent:runtime:{jacoco_version}"
));
deps.push(format!("org.jacoco:org.jacoco.cli:nodeps:{jacoco_version}"));
}
let mut repos = source_directives.repos;
repos.extend(split_cli_words(&cmd.repos));
let mut javac_options = source_directives.javac_options;
javac_options.extend(cmd.javac_options);
let java_version = cmd.java_version.or(source_directives.java_version);
let build = build_java(BuildOptions {
script,
extra_deps: deps,
extra_repos: repos,
extra_sources,
extra_files: split_cli_words(&cmd.files),
classpath: cmd.classpath,
javac_options,
runtime_options: Vec::new(),
java_agents: split_cli_key_values(&cmd.java_agents),
java_version,
main_class: None,
cache_dir: cmd.cache_dir,
trust_remote: cmd.trust,
})?;
let launcher = build
.classpath
.iter()
.find(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("junit-platform-console-standalone-"))
})
.cloned()
.ok_or_else(|| anyhow::anyhow!("could not resolve junit-platform-console-standalone"))?;
let jacoco_agent = if cmd.coverage {
Some(
build
.classpath
.iter()
.find(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
name.starts_with(JACOCO_AGENT_PREFIX) && name.ends_with("-runtime.jar")
})
})
.cloned()
.ok_or_else(|| anyhow::anyhow!("could not resolve JaCoCo agent"))?,
)
} else {
None
};
let jacoco_cli = if cmd.coverage {
Some(
build
.classpath
.iter()
.find(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
name.starts_with(JACOCO_CLI_PREFIX) && name.ends_with("-nodeps.jar")
})
})
.cloned()
.ok_or_else(|| anyhow::anyhow!("could not resolve JaCoCo CLI"))?,
)
} else {
None
};
let reports_dir = junit_reports_dir()?;
fs::create_dir_all(&reports_dir)?;
let mut runtime_cp = vec![build.classes_dir.clone()];
runtime_cp.extend(build.classpath.clone());
let jdk_root = jbx::jdk::resolve_jdk(&build.directives.java_version, true)?;
let java = jbx::jdk::java_bin_path(&jdk_root).display().to_string();
let mut java_cmd = ProcessCommand::new(&java);
if let Some(agent) = &jacoco_agent {
let coverage_path = PathBuf::from(JACOCO_COVERAGE_FILE);
if let Some(parent) = coverage_path.parent() {
fs::create_dir_all(parent)?;
}
java_cmd.arg(format!(
"-javaagent:{}=destfile={},append=false",
agent.display(),
coverage_path.display()
));
}
for agent in &build.directives.java_agents {
java_cmd.arg(format_cli_java_agent(agent));
}
java_cmd.args(&build.directives.runtime_options);
java_cmd.args(&cmd.runtime_options);
java_cmd
.arg("-jar")
.arg(&launcher)
.arg("execute")
.arg("--class-path")
.arg(std::env::join_paths(&runtime_cp)?)
.arg("--scan-class-path")
.arg("--reports-dir")
.arg(&reports_dir);
if cmd.json || cmd.xml {
java_cmd.arg("--details=none").arg("--disable-banner");
}
java_cmd.args(&cmd.args);
let output = java_cmd
.output()
.with_context(|| format!("failed to execute {java}"))?;
let code = output.status.code().unwrap_or(1);
let xml = read_junit_xml_reports(&reports_dir)?;
let _ = fs::remove_dir_all(&reports_dir);
let coverage = if let Some(cli) = &jacoco_cli {
Some(write_jacoco_report(
&java,
cli,
&build.classes_dir,
&base_dir,
)?)
} else {
None
};
if (cmd.json || cmd.xml) && !output.status.success() {
eprint!("{}", String::from_utf8_lossy(&output.stderr));
}
if cmd.json {
let mut payload = junit_xml_to_json(&xml)?;
if let Some(coverage) = &coverage {
payload["coverage"] = coverage.clone();
}
println!("{}", serde_json::to_string_pretty(&payload)?);
} else if cmd.xml {
print!("{xml}");
} else {
print!("{}", String::from_utf8_lossy(&output.stdout));
eprint!("{}", String::from_utf8_lossy(&output.stderr));
if coverage.is_some() {
println!("Coverage data: {JACOCO_COVERAGE_FILE}");
println!("Coverage report: {JACOCO_HTML_REPORT}");
println!("Coverage XML: {JACOCO_XML_REPORT}");
}
}
Ok(code)
}
fn write_jacoco_report(
java: &str,
cli: &Path,
classes_dir: &Path,
source_dir: &Path,
) -> Result<serde_json::Value> {
let coverage_file = Path::new(JACOCO_COVERAGE_FILE);
let report_dir = Path::new(JACOCO_REPORT_DIR);
let xml_report = Path::new(JACOCO_XML_REPORT);
fs::create_dir_all(report_dir)?;
let output = ProcessCommand::new(java)
.arg("-jar")
.arg(cli)
.arg("report")
.arg(coverage_file)
.arg("--classfiles")
.arg(classes_dir)
.arg("--sourcefiles")
.arg(source_dir)
.arg("--html")
.arg(report_dir)
.arg("--xml")
.arg(xml_report)
.output()
.with_context(|| format!("failed to execute JaCoCo CLI with {java}"))?;
if !output.status.success() {
anyhow::bail!(
"JaCoCo report generation failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
let mut coverage = serde_json::json!({
"execFile": JACOCO_COVERAGE_FILE,
"htmlReport": JACOCO_HTML_REPORT,
"xmlReport": JACOCO_XML_REPORT,
});
let jacoco_xml = fs::read_to_string(xml_report)?;
coverage["counters"] = jacoco_counters_to_json(&jacoco_xml)?;
coverage["jacocoXml"] = xml_to_json_tree(&jacoco_xml)?;
Ok(coverage)
}
fn jacoco_counters_to_json(xml: &str) -> Result<serde_json::Value> {
let counter_re = regex::Regex::new(
r#"<counter\s+type=\"([^\"]+)\"\s+missed=\"(\d+)\"\s+covered=\"(\d+)\"\s*/>"#,
)?;
let mut counters = serde_json::Map::new();
for captures in counter_re.captures_iter(xml) {
let counter_type = captures.get(1).map(|m| m.as_str()).unwrap_or_default();
let missed = captures
.get(2)
.and_then(|m| m.as_str().parse::<u64>().ok())
.unwrap_or(0);
let covered = captures
.get(3)
.and_then(|m| m.as_str().parse::<u64>().ok())
.unwrap_or(0);
counters.insert(
counter_type.to_string(),
serde_json::json!({
"missed": missed,
"covered": covered,
"total": missed + covered,
}),
);
}
Ok(serde_json::Value::Object(counters))
}
#[derive(Debug)]
struct XmlJsonNode {
name: String,
attributes: serde_json::Map<String, serde_json::Value>,
children: Vec<serde_json::Value>,
text: String,
}
impl XmlJsonNode {
fn new(name: String, attributes: serde_json::Map<String, serde_json::Value>) -> Self {
Self {
name,
attributes,
children: Vec::new(),
text: String::new(),
}
}
fn into_value(self) -> serde_json::Value {
let mut object = serde_json::Map::new();
object.insert("name".to_string(), serde_json::Value::String(self.name));
if !self.attributes.is_empty() {
object.insert(
"attributes".to_string(),
serde_json::Value::Object(self.attributes),
);
}
let text = self.text.trim();
if !text.is_empty() {
object.insert(
"text".to_string(),
serde_json::Value::String(text.to_string()),
);
}
if !self.children.is_empty() {
object.insert(
"children".to_string(),
serde_json::Value::Array(self.children),
);
}
serde_json::Value::Object(object)
}
}
fn xml_to_json_tree(xml: &str) -> Result<serde_json::Value> {
use quick_xml::events::{BytesStart, Event};
use quick_xml::Reader;
fn node_from_start(start: &BytesStart<'_>) -> XmlJsonNode {
let name = String::from_utf8_lossy(start.name().as_ref()).to_string();
let mut attributes = serde_json::Map::new();
for attr in start.attributes().flatten() {
let key = String::from_utf8_lossy(attr.key.as_ref()).to_string();
let value = String::from_utf8_lossy(attr.value.as_ref()).to_string();
attributes.insert(key, serde_json::Value::String(value));
}
XmlJsonNode::new(name, attributes)
}
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut stack: Vec<XmlJsonNode> = Vec::new();
let mut root = None;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(start)) => stack.push(node_from_start(&start)),
Ok(Event::Empty(start)) => {
let node = node_from_start(&start).into_value();
if let Some(parent) = stack.last_mut() {
parent.children.push(node);
} else {
root = Some(node);
}
}
Ok(Event::Text(text)) => {
if let Some(current) = stack.last_mut() {
let value = String::from_utf8_lossy(text.as_ref());
if !value.trim().is_empty() {
current.text.push_str(&value);
}
}
}
Ok(Event::CData(text)) => {
if let Some(current) = stack.last_mut() {
let value = String::from_utf8_lossy(text.as_ref());
if !value.trim().is_empty() {
current.text.push_str(&value);
}
}
}
Ok(Event::End(_)) => {
let node = stack
.pop()
.ok_or_else(|| anyhow::anyhow!("unexpected closing XML element"))?
.into_value();
if let Some(parent) = stack.last_mut() {
parent.children.push(node);
} else {
root = Some(node);
}
}
Ok(Event::Eof) => break,
Err(err) => anyhow::bail!("failed to parse XML report: {err}"),
_ => {}
}
buf.clear();
}
root.ok_or_else(|| anyhow::anyhow!("XML report did not contain a root element"))
}
fn junit_reports_dir() -> Result<PathBuf> {
let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
Ok(std::env::temp_dir().join(format!("jbx-junit-{}-{nanos}", std::process::id())))
}
fn read_junit_xml_reports(reports_dir: &Path) -> Result<String> {
let mut reports = fs::read_dir(reports_dir)?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|ext| ext == "xml"))
.collect::<Vec<_>>();
reports.sort();
if reports.is_empty() {
return Ok(String::new());
}
if reports.len() == 1 {
return Ok(fs::read_to_string(&reports[0])?);
}
let mut xml = String::from("<testsuites>\n");
for report in reports {
let report_xml = fs::read_to_string(report)?;
xml.push_str(strip_xml_declaration(report_xml.trim_start()));
xml.push('\n');
}
xml.push_str("</testsuites>\n");
Ok(xml)
}
fn strip_xml_declaration(xml: &str) -> &str {
let Some(rest) = xml.strip_prefix("<?xml") else {
return xml;
};
rest.find("?>")
.map(|end| rest[end + 2..].trim_start())
.unwrap_or(xml)
}
fn junit_xml_to_json(xml: &str) -> Result<serde_json::Value> {
let suite_re = regex::Regex::new(r#"<testsuite\b([^>]*)>"#)?;
let case_re = regex::Regex::new(r#"(?s)<testcase\b([^>]*?)(?:/>|>(.*?)</testcase>)"#)?;
let mut tests = 0_u64;
let mut failures = 0_u64;
let mut errors = 0_u64;
let mut skipped = 0_u64;
let mut test_cases = Vec::new();
for captures in suite_re.captures_iter(xml) {
let attrs = captures.get(1).map(|m| m.as_str()).unwrap_or_default();
tests += xml_attr(attrs, "tests")
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(0);
failures += xml_attr(attrs, "failures")
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(0);
errors += xml_attr(attrs, "errors")
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(0);
skipped += xml_attr(attrs, "skipped")
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(0);
}
for captures in case_re.captures_iter(xml) {
let attrs = captures.get(1).map(|m| m.as_str()).unwrap_or_default();
let body = captures.get(2).map(|m| m.as_str()).unwrap_or_default();
let status = if body.contains("<failure") {
"failed"
} else if body.contains("<error") {
"errored"
} else if body.contains("<skipped") {
"skipped"
} else {
"passed"
};
test_cases.push(serde_json::json!({
"className": xml_attr(attrs, "classname").unwrap_or_default(),
"name": xml_attr(attrs, "name").unwrap_or_default(),
"time": xml_attr(attrs, "time").unwrap_or_default(),
"status": status,
}));
}
Ok(serde_json::json!({
"tests": tests,
"failures": failures,
"errors": errors,
"skipped": skipped,
"testCases": test_cases,
}))
}
fn xml_attr(attrs: &str, name: &str) -> Option<String> {
let mut rest = attrs.trim_start();
while !rest.is_empty() {
let eq = rest.find('=')?;
let attr_name = rest[..eq].trim();
let after_eq = rest[eq + 1..].trim_start();
let quote = after_eq.chars().next()?;
let (value, next) = if quote == '"' || quote == '\'' {
let value_start = quote.len_utf8();
match after_eq[value_start..].find(quote) {
Some(end) => {
let value_end = value_start + end;
(
&after_eq[value_start..value_end],
&after_eq[value_end + quote.len_utf8()..],
)
}
None => return None,
}
} else {
let end = after_eq.find(char::is_whitespace).unwrap_or(after_eq.len());
(&after_eq[..end], &after_eq[end..])
};
if attr_name == name {
return Some(value.to_string());
}
rest = next.trim_start();
}
None
}
fn latest_cached_tool_version(
cache_dir: Option<&Path>,
group_id: &str,
artifact_id: &str,
repos: &[jbx::resolver::Repository],
) -> Result<String> {
let metadata_dir = cache_root(cache_dir)?.join("metadata");
let cache_file = metadata_dir.join(format!("{group_id}.{artifact_id}.version"));
if let Ok(metadata) = fs::metadata(&cache_file) {
if let Ok(modified) = metadata.modified() {
if SystemTime::now()
.duration_since(modified)
.map(|age| age.as_secs() < TOOL_VERSION_CACHE_MAX_AGE_SECS)
.unwrap_or(false)
{
let cached = fs::read_to_string(&cache_file)?.trim().to_string();
if !cached.is_empty() {
return Ok(cached);
}
}
}
}
let version = latest_tool_version(group_id, artifact_id, repos)?;
fs::create_dir_all(&metadata_dir)?;
fs::write(&cache_file, format!("{version}\n"))?;
Ok(version)
}
fn latest_tool_version(
group_id: &str,
artifact_id: &str,
repos: &[jbx::resolver::Repository],
) -> Result<String> {
jbx::resolver::resolve_latest_version(
&jbx::resolver::Module {
org: group_id.to_string(),
name: artifact_id.to_string(),
},
repos,
)
}
fn expand_test_target(path: &Path) -> Result<(PathBuf, Vec<String>)> {
if !path.is_dir() {
return Ok((path.to_path_buf(), Vec::new()));
}
let mut java_files = fs::read_dir(path)
.with_context(|| format!("failed to read test directory {}", path.display()))?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|entry_path| entry_path.extension().is_some_and(|ext| ext == "java"))
.collect::<Vec<_>>();
java_files.sort();
let script = java_files
.iter()
.find(|entry_path| is_test_source(entry_path))
.or_else(|| java_files.first())
.cloned()
.ok_or_else(|| anyhow::anyhow!("no Java source files found in {}", path.display()))?;
let extra_sources = java_files
.into_iter()
.filter(|entry_path| entry_path != &script)
.filter_map(|entry_path| {
entry_path
.strip_prefix(path)
.ok()
.map(|entry_path| entry_path.to_string_lossy().to_string())
})
.collect();
Ok((script, extra_sources))
}
fn is_test_source(path: &Path) -> bool {
path.file_stem()
.and_then(|stem| stem.to_str())
.is_some_and(|stem| {
stem.ends_with("Test") || stem.ends_with("Tests") || stem.ends_with("IT")
})
}
fn infer_test_companion_sources(script: &Path) -> Vec<String> {
let Some(parent) = script.parent() else {
return Vec::new();
};
let Some(stem) = script.file_stem().and_then(|stem| stem.to_str()) else {
return Vec::new();
};
let candidates = [
stem.strip_suffix("Tests"),
stem.strip_suffix("Test"),
stem.strip_suffix("IT"),
];
candidates
.into_iter()
.flatten()
.filter(|name| !name.is_empty())
.map(|name| parent.join(format!("{name}.java")))
.filter(|path| path.exists() && path != script)
.filter_map(|path| {
path.strip_prefix(parent)
.ok()
.map(|path| path.to_string_lossy().to_string())
})
.collect()
}
fn dedupe_strings(values: &mut Vec<String>) {
let mut seen = std::collections::HashSet::new();
values.retain(|value| seen.insert(value.clone()));
}
fn should_run_as_maven_tool_shorthand(script: &Path) -> bool {
if script.exists() {
return false;
}
let value = script.to_string_lossy();
if value.starts_with("http://") || value.starts_with("https://") {
return false;
}
let parts: Vec<&str> = value.split(':').collect();
matches!(parts.len(), 2..=4) && parts.iter().all(|part| !part.is_empty())
}
fn normalized_cli_args() -> Vec<OsString> {
normalize_cli_args(std::env::args_os().collect())
}
fn normalize_cli_args(args: Vec<OsString>) -> Vec<OsString> {
if args.len() <= 2 || args.iter().any(|arg| arg == "--") {
return args;
}
if args.get(1).is_some_and(|arg| arg == "run") {
return insert_separator_after_run_script(args);
}
insert_separator_after_top_level_target(args)
}
fn option_takes_value(arg: &str, value_options: &[&str]) -> bool {
value_options
.iter()
.any(|option| arg == *option || arg.starts_with(&format!("{option}=")))
}
fn insert_separator_after_top_level_target(args: Vec<OsString>) -> Vec<OsString> {
const SUBCOMMANDS: &[&str] = &[
"run", "build", "publish", "install", "docs", "check", "init", "cache", "trust", "info",
"doctor", "app", "alias", "catalog", "export", "template", "resolve", "fetch", "search",
"test", "fmt", "graph", "rewrite", "skill", "jdk", "help",
];
const TOP_LEVEL_VALUE_OPTIONS: &[&str] =
&["--repo", "--repos", "--cache-dir", "--main", "--progress"];
let mut skip_value = false;
for i in 1..args.len() {
let value = args[i].to_string_lossy();
if skip_value {
skip_value = false;
continue;
}
if option_takes_value(&value, TOP_LEVEL_VALUE_OPTIONS) {
skip_value = !value.contains('=');
continue;
}
if value.starts_with('-') {
continue;
}
if SUBCOMMANDS.contains(&value.as_ref()) {
return args;
}
let target = Path::new(value.as_ref());
let allow_post_target_options = should_run_as_maven_tool_shorthand(target);
return insert_separator_after_target(
args,
i,
allow_post_target_options,
TOP_LEVEL_VALUE_OPTIONS,
);
}
args
}
fn insert_separator_after_run_script(args: Vec<OsString>) -> Vec<OsString> {
const RUN_VALUE_OPTIONS: &[&str] = &[
"--deps",
"--repo",
"--repos",
"--source",
"--sources",
"--files",
"--file",
"--class-path",
"--cp",
"--javac-option",
"--compile-option",
"--runtime-option",
"--java-option",
"--java",
"--javaagent",
"--main",
"--cache-dir",
"--progress",
];
let mut skip_value = false;
for i in 2..args.len() {
let value = args[i].to_string_lossy();
if skip_value {
skip_value = false;
continue;
}
if value == "--trust" {
continue;
}
if option_takes_value(&value, RUN_VALUE_OPTIONS) {
skip_value = !value.contains('=');
continue;
}
if value.starts_with('-') {
return args;
}
return insert_separator_after_target(args, i, false, &[]);
}
args
}
fn insert_separator_after_target(
args: Vec<OsString>,
target_index: usize,
allow_post_target_options: bool,
post_target_value_options: &[&str],
) -> Vec<OsString> {
if target_index + 1 >= args.len() {
return args;
}
let mut skip_value = false;
let mut has_program_option = false;
for value in args
.iter()
.skip(target_index + 1)
.map(|arg| arg.to_string_lossy())
{
if skip_value {
skip_value = false;
continue;
}
if allow_post_target_options && option_takes_value(&value, post_target_value_options) {
skip_value = !value.contains('=');
continue;
}
if value.starts_with('-') {
has_program_option = true;
break;
}
}
if !has_program_option {
return args;
}
let mut out = Vec::with_capacity(args.len() + 1);
for (i, arg) in args.into_iter().enumerate() {
out.push(arg);
if i == target_index {
out.push(OsString::from("--"));
}
}
out
}
struct SkillEntry {
name: &'static str,
description: &'static str,
page: &'static str,
}
const BUNDLED_SKILLS: &[SkillEntry] = &[
SkillEntry {
name: "jbx",
description: "Use jbx for Java development and automation when Java or Maven libraries can solve the task: run scripts, fetch dependencies, test, format, check, document, publish, and launch Java tools.",
page: include_str!("../website/content/pages/docs/commands/top-level.md"),
},
SkillEntry {
name: "jbx-run",
description: "Compile and run one Java source file or launch a Maven executable artifact, including Java 25 compact scripts, with JBang-style directives and CLI overrides.",
page: include_str!("../website/content/pages/docs/commands/run.md"),
},
SkillEntry {
name: "jbx-build",
description: "Compile a script into the jbx cache without running it.",
page: include_str!("../website/content/pages/docs/commands/build.md"),
},
SkillEntry {
name: "jbx-check",
description: "Check Java source with structured diagnostics.",
page: include_str!("../website/content/pages/docs/commands/check.md"),
},
SkillEntry {
name: "jbx-test",
description: "Run JUnit tests with optional JaCoCo coverage.",
page: include_str!("../website/content/pages/docs/commands/test.md"),
},
SkillEntry {
name: "jbx-docs",
description: "Generate Markdown or JSON documentation from local sources or Maven artifacts.",
page: include_str!("../website/content/pages/docs/commands/docs.md"),
},
SkillEntry {
name: "jbx-doctor",
description: "Diagnose JDKs, Maven, caches, trust, dependencies, and optional native/publish tools.",
page: include_str!("../website/content/pages/docs/commands/doctor.md"),
},
SkillEntry {
name: "jbx-rewrite",
description: "Preview/apply OpenRewrite recipes and discover modules or recipes.",
page: include_str!("../website/content/pages/docs/commands/rewrite.md"),
},
SkillEntry {
name: "jbx-search",
description: "Search Maven Central artifacts by text or coordinates.",
page: include_str!("../website/content/pages/docs/commands/search.md"),
},
SkillEntry {
name: "jbx-resolve",
description: "Resolve Maven coordinates to dependency coordinates or classpaths.",
page: include_str!("../website/content/pages/docs/commands/resolve.md"),
},
SkillEntry {
name: "jbx-fetch",
description: "Download artifacts and print classpath or dependency coordinates.",
page: include_str!("../website/content/pages/docs/commands/fetch.md"),
},
SkillEntry {
name: "jbx-info",
description: "Print parsed directives and derived metadata from Java scripts.",
page: include_str!("../website/content/pages/docs/commands/info.md"),
},
SkillEntry {
name: "jbx-cache",
description: "Inspect or clear compiled-script cache paths and entries.",
page: include_str!("../website/content/pages/docs/commands/cache.md"),
},
SkillEntry {
name: "jbx-trust",
description: "Pin, list, remove, or clear trusted hashes for remote scripts.",
page: include_str!("../website/content/pages/docs/commands/trust.md"),
},
SkillEntry {
name: "jbx-app",
description: "Install, list, or uninstall Java scripts as PATH commands.",
page: include_str!("../website/content/pages/docs/commands/app.md"),
},
SkillEntry {
name: "jbx-alias",
description: "Add, remove, and list aliases from `jbang-catalog.json`.",
page: include_str!("../website/content/pages/docs/commands/alias.md"),
},
SkillEntry {
name: "jbx-catalog",
description: "Add and list external catalogs in `jbang-catalog.json`.",
page: include_str!("../website/content/pages/docs/commands/catalog.md"),
},
SkillEntry {
name: "jbx-template",
description: "List built-in and imported templates for `jbx init`.",
page: include_str!("../website/content/pages/docs/commands/template.md"),
},
SkillEntry {
name: "jbx-init",
description: "Create Java 25+ scripts from built-in or imported templates.",
page: include_str!("../website/content/pages/docs/commands/init.md"),
},
SkillEntry {
name: "jbx-export",
description: "Export local, portable, or native runnable artifacts.",
page: include_str!("../website/content/pages/docs/commands/export.md"),
},
SkillEntry {
name: "jbx-publish",
description: "Build Maven-ready bundles, local served repositories, or Portal uploads.",
page: include_str!("../website/content/pages/docs/commands/publish.md"),
},
SkillEntry {
name: "jbx-install",
description: "Install the current project into a Maven repository layout.",
page: include_str!("../website/content/pages/docs/commands/install.md"),
},
SkillEntry {
name: "jbx-fmt",
description: "Format Java files with Palantir Java Format.",
page: include_str!("../website/content/pages/docs/commands/fmt.md"),
},
SkillEntry {
name: "jbx-graph",
description: "Dump JavaParser native AST JSON or import it back to Java source.",
page: include_str!("../website/content/pages/docs/commands/graph.md"),
},
SkillEntry {
name: "jbx-skill",
description: "List and print version-matched bundled agent skills.",
page: include_str!("../website/content/pages/docs/commands/skill.md"),
},
SkillEntry {
name: "jbx-jdk",
description: "List, install, and locate JDKs used by jbx.",
page: include_str!("../website/content/pages/docs/commands/jdk.md"),
},
];
fn skill_content_from_page(skill: &SkillEntry) -> String {
let body = strip_markdown_frontmatter(skill.page);
let body = strip_markdown_section(body, "Skill");
let body = strip_skill_bootstrap(&body);
format!(
"---\nname: {}\ndescription: {}\n---\n\n{}",
skill.name,
yaml_double_quote(skill.description),
body.trim_end()
) + "\n"
}
fn yaml_double_quote(value: &str) -> String {
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}
fn strip_markdown_frontmatter(markdown: &str) -> &str {
markdown
.strip_prefix("---\n")
.and_then(|rest| rest.split_once("---"))
.map(|(_, body)| body.trim_start())
.unwrap_or(markdown)
}
fn strip_markdown_section(markdown: &str, heading: &str) -> String {
let target = format!("## {heading}");
let mut output = Vec::new();
let mut skipping = false;
for line in markdown.lines() {
if line == target {
skipping = true;
continue;
}
if skipping && line.starts_with("## ") {
skipping = false;
}
if !skipping {
output.push(line);
}
}
output.join("\n")
}
fn strip_skill_bootstrap(markdown: &str) -> String {
let mut lines = Vec::new();
let mut skip_empty_after_removed = false;
for line in markdown.lines() {
if line.contains("jbx skill get") {
skip_empty_after_removed = true;
continue;
}
if skip_empty_after_removed && line.trim().is_empty() {
skip_empty_after_removed = false;
continue;
}
skip_empty_after_removed = false;
let line = line
.replacen("2. Run the command", "1. Run the command", 1)
.replacen("3. Prefer JSON", "2. Prefer JSON", 1)
.replacen("4. Verify", "3. Verify", 1);
lines.push(line);
}
let mut text = lines.join("\n");
while text.contains("\n\n\n") {
text = text.replace("\n\n\n", "\n\n");
}
text
}
fn run_skill(cmd: SkillCommand) -> Result<i32> {
match cmd.command {
SkillSubcommand::List(cmd) => {
if cmd.json {
let skills: Vec<_> = BUNDLED_SKILLS
.iter()
.map(|skill| {
serde_json::json!({
"name": skill.name,
"description": skill.description,
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({ "skills": skills }))?
);
} else {
for skill in BUNDLED_SKILLS {
println!("{}\t{}", skill.name, skill.description);
}
}
Ok(0)
}
SkillSubcommand::Get(cmd) => {
let name = cmd.name.as_deref().unwrap_or("jbx");
let skill = BUNDLED_SKILLS
.iter()
.find(|skill| skill.name == name)
.with_context(|| format!("unknown bundled skill: {name}"))?;
let content = skill_content_from_page(skill);
print!("{content}");
if !content.ends_with('\n') {
println!();
}
Ok(0)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DoctorStatus {
Ok,
Warn,
Fail,
Skipped,
}
impl DoctorStatus {
fn as_str(self) -> &'static str {
match self {
DoctorStatus::Ok => "ok",
DoctorStatus::Warn => "warn",
DoctorStatus::Fail => "fail",
DoctorStatus::Skipped => "skipped",
}
}
}
#[derive(Debug, Clone)]
struct DoctorCheck {
name: &'static str,
status: DoctorStatus,
summary: String,
detail: Option<String>,
}
impl DoctorCheck {
fn ok(name: &'static str, summary: impl Into<String>) -> Self {
Self {
name,
status: DoctorStatus::Ok,
summary: summary.into(),
detail: None,
}
}
fn warn(name: &'static str, summary: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
name,
status: DoctorStatus::Warn,
summary: summary.into(),
detail: Some(detail.into()),
}
}
fn fail(name: &'static str, summary: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
name,
status: DoctorStatus::Fail,
summary: summary.into(),
detail: Some(detail.into()),
}
}
fn skipped(name: &'static str, summary: impl Into<String>) -> Self {
Self {
name,
status: DoctorStatus::Skipped,
summary: summary.into(),
detail: None,
}
}
fn json(&self) -> serde_json::Value {
serde_json::json!({
"name": self.name,
"status": self.status.as_str(),
"summary": self.summary,
"detail": self.detail,
})
}
}
#[derive(Debug, Default)]
struct DoctorContext {
target: Option<String>,
directives: Option<jbx::Directives>,
target_error: Option<String>,
target_is_remote: bool,
}
fn run_doctor(cmd: DoctorCommand) -> Result<i32> {
let cache_dir = cmd.cache_dir.clone().unwrap_or(default_cache_dir()?);
let context = doctor_context(cmd.target.as_deref());
let mut checks = vec![
check_jdk(&context),
check_maven_central(),
check_cache_writable(&cache_dir),
check_gpg(cmd.publish || context.directives.as_ref().is_some_and(|d| d.gav.is_some())),
check_native_image(cmd.native),
check_formatter(),
check_trust(&context, cmd.cache_dir.as_deref()),
check_dependency_resolution(&context, &cmd.repos, &cache_dir),
];
checks.extend(check_dependency_versions(&context));
checks.push(check_jbx_update());
if let Some(error) = context.target_error.as_ref() {
checks.push(DoctorCheck::fail(
"script",
"target script could not be read",
error.clone(),
));
}
if cmd.json {
let payload = serde_json::json!({
"status": doctor_overall_status(&checks).as_str(),
"target": context.target,
"checks": checks.iter().map(DoctorCheck::json).collect::<Vec<_>>(),
});
println!("{}", serde_json::to_string_pretty(&payload)?);
} else {
for check in &checks {
println!(
"{:<7} {} - {}",
check.status.as_str(),
check.name,
check.summary
);
if let Some(detail) = &check.detail {
println!(" {detail}");
}
}
}
Ok(
if checks
.iter()
.any(|check| check.status == DoctorStatus::Fail)
{
1
} else {
0
},
)
}
fn doctor_context(target: Option<&str>) -> DoctorContext {
let Some(target) = target else {
return DoctorContext::default();
};
let target_is_remote = is_probably_remote_url(target);
let mut context = DoctorContext {
target: Some(target.to_string()),
target_is_remote,
..DoctorContext::default()
};
if target_is_remote {
return context;
}
match fs::read_to_string(target) {
Ok(source) => context.directives = Some(parse_directives(&source)),
Err(error) => context.target_error = Some(error.to_string()),
}
context
}
fn check_jdk(context: &DoctorContext) -> DoctorCheck {
let requested = context
.directives
.as_ref()
.and_then(|directives| directives.java_version.as_deref())
.unwrap_or("25");
match jbx::jdk::parse_java_version_directive(requested)
.and_then(|version| jbx::jdk::find_jdk(version, false).map(|root| (version, root)))
{
Ok((version, root)) => DoctorCheck::ok(
"jdk",
format!("JDK {version} selected at {}", root.display()),
),
Err(error) => DoctorCheck::fail(
"jdk",
format!("requested JDK {requested} is not available locally"),
format!("{error}; jbx can try to provision it when a command enables auto-install"),
),
}
}
fn check_maven_central() -> DoctorCheck {
let endpoint = maven_search_endpoint();
match ureq::get(&endpoint)
.query(
"q",
"g:org.junit.platform AND a:junit-platform-console-standalone",
)
.query("rows", "1")
.query("wt", "json")
.set("User-Agent", "jbx")
.call()
{
Ok(response) if (200..300).contains(&response.status()) => {
DoctorCheck::ok("maven-central", format!("reachable via {endpoint}"))
}
Ok(response) => DoctorCheck::fail(
"maven-central",
format!("unexpected HTTP {} from {endpoint}", response.status()),
response.status_text().to_string(),
),
Err(error) => DoctorCheck::fail(
"maven-central",
format!("could not reach {endpoint}"),
error.to_string(),
),
}
}
fn check_cache_writable(cache_dir: &Path) -> DoctorCheck {
let result = (|| -> std::io::Result<()> {
fs::create_dir_all(cache_dir)?;
for attempt in 0..16 {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let probe = cache_dir.join(format!(
".jbx-doctor-write-test-{}-{nonce}-{attempt}",
std::process::id()
));
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&probe)
{
Ok(mut file) => {
file.write_all(b"ok")?;
drop(file);
fs::remove_file(&probe)?;
return Ok(());
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
}
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"could not allocate unique cache write probe path",
))
})();
match result {
Ok(()) => DoctorCheck::ok("cache", format!("writable at {}", cache_dir.display())),
Err(error) => DoctorCheck::fail(
"cache",
format!("not writable at {}", cache_dir.display()),
error.to_string(),
),
}
}
fn check_gpg(required: bool) -> DoctorCheck {
if !required {
return DoctorCheck::skipped("gpg", "not needed unless publishing");
}
match command_version("gpg") {
Some(version) => DoctorCheck::ok("gpg", first_line(&version)),
None => DoctorCheck::fail(
"gpg",
"required for publishing but not available",
"install gpg or use publish dry-run/skip-signing paths where appropriate",
),
}
}
fn check_native_image(required: bool) -> DoctorCheck {
if !required {
return DoctorCheck::skipped(
"native-image",
"not needed unless native export is requested",
);
}
match command_version("native-image") {
Some(version) => DoctorCheck::ok("native-image", first_line(&version)),
None => DoctorCheck::fail(
"native-image",
"required for native export but not available",
"install GraalVM native-image or pass --native-image to export native",
),
}
}
fn check_formatter() -> DoctorCheck {
if command_exists("google-java-format") {
return DoctorCheck::ok("formatter", "google-java-format found on PATH");
}
if command_exists("palantir-java-format") {
return DoctorCheck::ok("formatter", "palantir-java-format found on PATH");
}
DoctorCheck::ok(
"formatter",
format!("PATH formatter not found; jbx fmt can resolve Palantir Java Format {DEFAULT_PALANTIR_JAVA_FORMAT_VERSION} from Maven Central"),
)
}
fn check_trust(context: &DoctorContext, cache_dir: Option<&Path>) -> DoctorCheck {
if !context.target_is_remote {
return DoctorCheck::skipped("trust", "target is not a remote script");
}
let Some(target) = context.target.as_deref() else {
return DoctorCheck::skipped("trust", "no target script provided");
};
match trust_entries(cache_dir) {
Ok(entries) if entries.iter().any(|(url, _)| url == target) => {
DoctorCheck::ok("trust", "remote script has a trusted content hash")
}
Ok(_) => DoctorCheck::warn(
"trust",
"remote script is not trusted yet",
"run with --trust or use `jbx trust add <url>` after reviewing the script",
),
Err(error) => DoctorCheck::fail("trust", "could not read trust store", error.to_string()),
}
}
fn check_dependency_resolution(
context: &DoctorContext,
repos: &[String],
cache_dir: &Path,
) -> DoctorCheck {
let Some(directives) = context.directives.as_ref() else {
return DoctorCheck::skipped(
"dependency-resolution",
"no local script dependencies to resolve",
);
};
if directives.deps.is_empty() {
return DoctorCheck::ok("dependency-resolution", "script declares no dependencies");
}
let mut all_repos = directives.repos.clone();
all_repos.extend(repos.iter().cloned());
let repositories = maven_tool::maven_repositories(&all_repos);
match jbx::resolver::resolve(&directives.deps, &repositories, &cache_dir.join("deps")) {
Ok(artifacts) => DoctorCheck::ok(
"dependency-resolution",
format!(
"resolved {} artifact(s) from {} declared dependency/dependencies",
artifacts.len(),
directives.deps.len()
),
),
Err(error) => DoctorCheck::fail(
"dependency-resolution",
"dependency resolution failed",
error.to_string(),
),
}
}
fn check_dependency_versions(context: &DoctorContext) -> Vec<DoctorCheck> {
let Some(directives) = context.directives.as_ref() else {
return Vec::new();
};
directives
.deps
.iter()
.filter_map(|coordinate| dependency_version_check(coordinate))
.collect()
}
fn dependency_version_check(coordinate: &str) -> Option<DoctorCheck> {
let dependency = match jbx::resolver::parse_coordinate(coordinate) {
Ok(dependency) if dependency.version.is_empty() => return None,
Ok(dependency) => dependency,
Err(error) => {
return Some(DoctorCheck::fail(
"dependency-version",
format!("invalid dependency coordinate: {coordinate}"),
error.to_string(),
));
}
};
let group = dependency.module.org.as_str();
let artifact = dependency.module.name.as_str();
let version = dependency.version.as_str();
latest_maven_version(group, artifact).map(|latest| {
if latest == version {
DoctorCheck::ok(
"dependency-version",
format!("{group}:{artifact} is current at {version}"),
)
} else {
DoctorCheck::warn(
"dependency-version",
format!("{group}:{artifact} declares {version}; latest appears to be {latest}"),
"not every project should chase latest, but this is useful drift signal"
.to_string(),
)
}
})
}
fn check_jbx_update() -> DoctorCheck {
let current = env!("CARGO_PKG_VERSION");
if current == "0.0.0" {
return DoctorCheck::skipped(
"jbx-update",
"source build uses placeholder version 0.0.0; release builds derive version from tags",
);
}
match latest_github_release_version() {
Some(latest) if latest.trim_start_matches('v') == current => {
DoctorCheck::ok("jbx-update", format!("jbx {current} is current"))
}
Some(latest) => DoctorCheck::warn(
"jbx-update",
format!("jbx {current} is older than {latest}"),
"upgrade from the latest GitHub release",
),
None => DoctorCheck::warn(
"jbx-update",
"could not check latest jbx release",
"GitHub releases API was unavailable or returned no release",
),
}
}
fn latest_maven_version(group: &str, artifact: &str) -> Option<String> {
let response = ureq::get(&maven_search_endpoint())
.query("q", &format!("g:{group} AND a:{artifact}"))
.query("rows", "1")
.query("wt", "json")
.set("User-Agent", "jbx")
.call()
.ok()?;
if !(200..300).contains(&response.status()) {
return None;
}
let body = response.into_string().ok()?;
let json: serde_json::Value = serde_json::from_str(&body).ok()?;
json.get("response")?
.get("docs")?
.as_array()?
.first()?
.get("latestVersion")?
.as_str()
.map(ToOwned::to_owned)
}
fn latest_github_release_version() -> Option<String> {
let response = ureq::get("https://api.github.com/repos/telegraphic-dev/jbx/releases/latest")
.set("User-Agent", "jbx")
.call()
.ok()?;
if !(200..300).contains(&response.status()) {
return None;
}
let body = response.into_string().ok()?;
let json: serde_json::Value = serde_json::from_str(&body).ok()?;
json.get("tag_name")?.as_str().map(ToOwned::to_owned)
}
fn command_exists(name: &str) -> bool {
which::which(name).is_ok()
}
fn command_version(name: &str) -> Option<String> {
let mut child = ProcessCommand::new(name)
.arg("--version")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.ok()?;
let deadline = Instant::now() + Duration::from_secs(2);
loop {
if child.try_wait().ok()?.is_some() {
let output = child.wait_with_output().ok()?;
if !output.status.success() {
return None;
}
let mut text = String::from_utf8_lossy(&output.stdout).to_string();
if text.trim().is_empty() {
text = String::from_utf8_lossy(&output.stderr).to_string();
}
return Some(text);
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return None;
}
thread::sleep(Duration::from_millis(25));
}
}
fn first_line(text: &str) -> String {
text.lines().next().unwrap_or(text).trim().to_string()
}
fn doctor_overall_status(checks: &[DoctorCheck]) -> DoctorStatus {
if checks
.iter()
.any(|check| check.status == DoctorStatus::Fail)
{
DoctorStatus::Fail
} else if checks
.iter()
.any(|check| check.status == DoctorStatus::Warn)
{
DoctorStatus::Warn
} else {
DoctorStatus::Ok
}
}
fn is_probably_remote_url(value: &str) -> bool {
value.starts_with("http://") || value.starts_with("https://")
}
fn main() -> Result<()> {
let cli = Cli::parse_from(normalized_cli_args());
let progress = progress_options(cli.quiet, cli.verbose, cli.progress);
let code = match cli.command {
Some(Commands::Run(cmd)) => {
let progress = progress_options(
cli.quiet || cmd.quiet,
cli.verbose || cmd.verbose,
if cmd.progress == ProgressModeArg::Auto {
cli.progress
} else {
cmd.progress
},
);
if should_run_as_maven_tool_shorthand(&cmd.script) {
maven_tool::run(maven_tool::MavenToolOptions {
coordinate: cmd.script.to_string_lossy().into_owned(),
repos: split_cli_words(&cmd.repos),
cache_dir: cmd.cache_dir,
main_class: cmd.main_class,
args: cmd.args,
progress,
})?
} else {
run_java(apply_alias_to_run(RunOptions {
script: cmd.script,
script_args: cmd.args,
extra_deps: split_cli_words(&cmd.deps),
extra_repos: split_cli_words(&cmd.repos),
extra_sources: split_cli_words(&cmd.sources),
extra_files: split_cli_words(&cmd.files),
classpath: cmd.classpath,
javac_options: cmd.javac_options,
runtime_options: cmd.runtime_options,
java_agents: split_cli_key_values(&cmd.java_agents),
java_version: cmd.java_version,
main_class: cmd.main_class,
cache_dir: cmd.cache_dir,
trust_remote: cmd.trust,
progress,
})?)?
}
}
Some(Commands::Build(cmd)) => {
build_java(apply_alias_to_build(BuildOptions {
script: cmd.script,
extra_deps: split_cli_words(&cmd.deps),
extra_repos: split_cli_words(&cmd.repos),
extra_sources: split_cli_words(&cmd.sources),
extra_files: split_cli_words(&cmd.files),
classpath: cmd.classpath,
javac_options: cmd.javac_options,
runtime_options: cmd.runtime_options,
java_agents: split_cli_key_values(&cmd.java_agents),
java_version: cmd.java_version,
main_class: cmd.main_class,
cache_dir: cmd.cache_dir,
trust_remote: cmd.trust,
})?)?;
0
}
Some(Commands::Init(cmd)) => {
init_script(InitOptions {
script: cmd.script,
deps: cmd
.deps
.iter()
.flat_map(|dep| split_directive_words(dep))
.collect(),
java_version: cmd.java_version,
template: cmd.template,
force: cmd.force,
})?;
0
}
Some(Commands::Cache(cmd)) => match cmd.command {
CacheSubcommand::Clear(clear) => {
clear_cache(clear.cache_dir.as_deref())?;
0
}
CacheSubcommand::Path(path) => {
print_cache_path(path.cache_dir)?;
0
}
CacheSubcommand::List(list) => {
let entries = cache_entries(list.cache_dir.as_deref())?;
if list.json {
let json = entries
.iter()
.map(|entry| {
serde_json::json!({
"script": entry.script.to_string_lossy(),
"classesDir": entry.classes_dir.to_string_lossy(),
"cacheDir": entry.cache_dir.to_string_lossy(),
})
})
.collect::<Vec<_>>();
println!("{}", serde_json::to_string_pretty(&json)?);
} else {
for entry in entries {
println!(
"{}\t{}\t{}",
entry.script.display(),
entry.classes_dir.display(),
entry.cache_dir.display()
);
}
}
0
}
},
Some(Commands::Trust(cmd)) => match cmd.command {
TrustSubcommand::Add(cmd) => {
let hash = trust_add(&cmd.url, cmd.cache_dir.as_deref())?;
println!("{}\t{}", cmd.url, hash);
0
}
TrustSubcommand::Remove(cmd) => {
trust_remove(&cmd.url, cmd.cache_dir.as_deref())?;
0
}
TrustSubcommand::List(cmd) => {
for (url, hash) in trust_entries(cmd.cache_dir.as_deref())? {
println!("{url}\t{hash}");
}
0
}
TrustSubcommand::Clear(cmd) => {
trust_clear(cmd.cache_dir.as_deref())?;
0
}
},
Some(Commands::Doctor(cmd)) => run_doctor(cmd)?,
Some(Commands::Info(cmd)) => match cmd.command {
InfoSubcommand::Classpath(cmd) => {
let output = build_java(BuildOptions {
script: cmd.script,
extra_deps: split_cli_words(&cmd.deps),
extra_repos: split_cli_words(&cmd.repos),
extra_sources: split_cli_words(&cmd.sources),
extra_files: split_cli_words(&cmd.files),
classpath: cmd.classpath,
javac_options: cmd.javac_options,
runtime_options: cmd.runtime_options,
java_agents: split_cli_key_values(&cmd.java_agents),
java_version: cmd.java_version,
main_class: cmd.main_class,
cache_dir: cmd.cache_dir,
trust_remote: false,
})?;
let mut entries = output.classpath;
if !cmd.deps_only {
entries.insert(0, output.classes_dir);
}
println!("{}", std::env::join_paths(entries)?.to_string_lossy());
0
}
InfoSubcommand::Tools(cmd) => {
let script = std::fs::canonicalize(&cmd.script)?;
let output = build_java(BuildOptions {
script: script.clone(),
extra_deps: split_cli_words(&cmd.deps),
extra_repos: split_cli_words(&cmd.repos),
extra_sources: split_cli_words(&cmd.sources),
extra_files: split_cli_words(&cmd.files),
classpath: cmd.classpath,
javac_options: cmd.javac_options,
runtime_options: cmd.runtime_options,
java_agents: split_cli_key_values(&cmd.java_agents),
java_version: cmd.java_version,
main_class: cmd.main_class,
cache_dir: cmd.cache_dir,
trust_remote: false,
})?;
let payload = tools_payload(&script, &output);
if let Some(field) = cmd.select {
let Some(value) = payload.get(&field) else {
anyhow::bail!("Cannot return value of unknown field: {field}");
};
if value.is_null() {
anyhow::bail!("field {field} is null");
}
if let Some(text) = value.as_str() {
println!("{text}");
} else {
println!("{}", serde_json::to_string_pretty(value)?);
}
} else {
println!("{}", serde_json::to_string_pretty(&payload)?);
}
0
}
InfoSubcommand::Docs(cmd) => {
let source = std::fs::read_to_string(&cmd.script)?;
let directives = jbx::parse_directives(&source);
if let Some(description) = directives.description {
println!("{description}");
}
for doc in directives.docs {
let (id, target) = match doc.value {
Some(value) => (doc.key, value),
None => ("main".to_string(), doc.key),
};
println!("{id}:");
println!(" {target}");
}
0
}
InfoSubcommand::Cache(cmd) => {
print_cache_path(cmd.cache_dir)?;
0
}
InfoSubcommand::Main(cmd) => {
let source = fs::read_to_string(&cmd.script)?;
let main = jbx::parse_directives(&source)
.main_class
.or_else(|| jbx::infer_main_class_from_source(&cmd.script, &source));
print_required(main.as_deref(), "could not infer main class; add //MAIN")?;
0
}
InfoSubcommand::Java(cmd) => {
let directives = parsed_directives(&cmd.script)?;
print_required(
directives.java_version.as_deref(),
"no //JAVA directive found",
)?;
0
}
InfoSubcommand::Description(cmd) => {
let directives = parsed_directives(&cmd.script)?;
print_required(
directives.description.as_deref(),
"no //DESCRIPTION directive found",
)?;
0
}
InfoSubcommand::Gav(cmd) => {
let directives = parsed_directives(&cmd.script)?;
print_required(directives.gav.as_deref(), "no //GAV directive found")?;
0
}
InfoSubcommand::Module(cmd) => {
let directives = parsed_directives(&cmd.script)?;
print_required(directives.module.as_deref(), "no //MODULE directive found")?;
0
}
InfoSubcommand::Deps(cmd) => {
let directives = parsed_directives(&cmd.script)?;
print_lines(&directives.deps);
0
}
InfoSubcommand::Repos(cmd) => {
let directives = parsed_directives(&cmd.script)?;
print_lines(&directives.repos);
0
}
InfoSubcommand::Sources(cmd) => {
let directives = parsed_directives(&cmd.script)?;
print_lines(&directives.sources);
0
}
InfoSubcommand::Files(cmd) => {
let directives = parsed_directives(&cmd.script)?;
print_lines(&directives.files);
0
}
InfoSubcommand::CompileOptions(cmd) => {
let directives = parsed_directives(&cmd.script)?;
print_lines(&directives.javac_options);
0
}
InfoSubcommand::RuntimeOptions(cmd) => {
let directives = parsed_directives(&cmd.script)?;
print_lines(&directives.runtime_options);
0
}
InfoSubcommand::NativeOptions(cmd) => {
let directives = parsed_directives(&cmd.script)?;
print_lines(&directives.native_options);
0
}
InfoSubcommand::Javaagents(cmd) => {
let directives = parsed_directives(&cmd.script)?;
print_key_values(&directives.java_agents);
0
}
InfoSubcommand::Manifest(cmd) => {
let directives = parsed_directives(&cmd.script)?;
print_key_values(&directives.manifest_options);
0
}
InfoSubcommand::Directives(cmd) => {
let source = std::fs::read_to_string(&cmd.script)?;
println!("{:#?}", jbx::parse_directives(&source));
0
}
},
Some(Commands::App(cmd)) => match cmd.command {
AppSubcommand::Install(cmd) => {
let wrapper = app_install(AppInstallOptions {
script: cmd.script,
name: cmd.name,
force: cmd.force,
})?;
println!("Command installed: {}", wrapper.display());
let bin_dir = app_bin_dir()?;
eprintln!(
"Add {} to your PATH to use installed commands.",
bin_dir.display()
);
0
}
AppSubcommand::Uninstall(cmd) => {
let removed = app_uninstall(&cmd.name)?;
if removed {
println!("Command uninstalled: {}", cmd.name);
} else {
println!("Command '{}' not found.", cmd.name);
}
0
}
AppSubcommand::List(_) => {
let entries = app_list()?;
if entries.is_empty() {
println!("No commands installed.");
} else {
for entry in &entries {
println!("{}\t{}", entry.name, entry.target);
}
}
0
}
},
Some(Commands::Alias(cmd)) => match cmd.command {
AliasSubcommand::Add(cmd) => {
let catalog = alias_add(
AliasAddOptions {
script_ref: cmd.script,
name: cmd.name,
description: cmd.description,
arguments: cmd.args,
deps: split_cli_words(&cmd.deps),
repos: split_cli_words(&cmd.repos),
sources: split_cli_words(&cmd.sources),
files: split_cli_words(&cmd.files),
classpaths: cmd.classpath,
javac_options: cmd.javac_options,
runtime_options: cmd.runtime_options,
java_agents: split_cli_key_values(&cmd.java_agents),
docs: split_cli_key_values(&cmd.docs),
java_version: cmd.java_version,
main_class: cmd.main_class,
force: cmd.force,
catalog_file: cmd.catalog.file,
global: cmd.catalog.global,
},
&std::env::current_dir()?,
)?;
println!("Alias added to {}", catalog.display());
0
}
AliasSubcommand::Remove(cmd) => {
let removed = alias_remove(
AliasRemoveOptions {
name: cmd.name.clone(),
catalog_file: cmd.catalog.file,
global: cmd.catalog.global,
},
&std::env::current_dir()?,
)?;
if removed {
println!("Alias removed: {}", cmd.name);
} else {
println!("Alias '{}' not found.", cmd.name);
}
0
}
AliasSubcommand::List(cmd) => {
print_aliases(cmd.json)?;
0
}
},
Some(Commands::Catalog(cmd)) => match cmd.command {
CatalogSubcommand::Add(cmd) => {
let catalog = catalog_add(
CatalogAddOptions {
name: cmd.name,
catalog_ref: cmd.catalog_ref,
description: cmd.description,
import_items: cmd.import_items,
force: cmd.force,
catalog_file: cmd.catalog.file,
global: cmd.catalog.global,
},
&std::env::current_dir()?,
)?;
println!("Catalog added to {}", catalog.display());
0
}
CatalogSubcommand::List(cmd) => {
print_catalogs(cmd.json)?;
0
}
},
Some(Commands::Export(cmd)) => match cmd.command {
ExportSubcommand::Local(cmd) => {
let output = export_jar(apply_alias_to_export(export_options(
cmd,
ExportKind::Local,
))?)?;
println!("Exported to {}", output.display());
0
}
ExportSubcommand::Portable(cmd) => {
let output = export_jar(apply_alias_to_export(export_options(
cmd,
ExportKind::Portable,
))?)?;
println!("Exported to {}", output.display());
0
}
ExportSubcommand::Native(cmd) => {
let output =
export_native(apply_alias_to_native_export(native_export_options(cmd))?)?;
println!("Exported to {}", output.display());
0
}
},
Some(Commands::Template(cmd)) => match cmd.command {
TemplateSubcommand::List(cmd) => {
let templates = catalog_templates(&std::env::current_dir()?)?;
if cmd.json {
let payload = templates
.iter()
.map(|template| {
serde_json::json!({
"name": template.name,
"description": template.description,
})
})
.collect::<Vec<_>>();
println!("{}", serde_json::to_string_pretty(&payload)?);
} else {
for template in templates {
let description = template.description.unwrap_or_default();
println!("{}\t{}", template.name, description);
}
}
0
}
},
Some(Commands::Resolve(cmd)) => {
let cache_dir = match cmd.cache_dir {
Some(path) => path,
None => default_cache_dir()?.join("deps"),
};
let repos = maven_tool::maven_repositories(&cmd.repos);
if cmd.classpath {
let paths = jbx::resolver::resolve_classpath(&cmd.coordinates, &repos, &cache_dir)?;
println!("{}", std::env::join_paths(paths)?.to_string_lossy());
} else {
let artifacts = jbx::resolver::resolve(&cmd.coordinates, &repos, &cache_dir)?;
for artifact in &artifacts {
println!("{artifact}");
}
}
0
}
Some(Commands::Fetch(cmd)) => {
let cache_dir = match cmd.cache_dir {
Some(path) => path,
None => default_cache_dir()?.join("deps"),
};
let repos = maven_tool::maven_repositories(&cmd.repos);
if cmd.deps_only {
let artifacts = jbx::resolver::resolve(&cmd.coordinates, &repos, &cache_dir)?;
for artifact in &artifacts {
println!("{artifact}");
}
} else {
let paths = jbx::resolver::resolve_classpath(&cmd.coordinates, &repos, &cache_dir)?;
println!("{}", std::env::join_paths(paths)?.to_string_lossy());
}
0
}
Some(Commands::Search(cmd)) => run_search(cmd)?,
Some(Commands::Publish(cmd)) => run_publish(cmd)?,
Some(Commands::Install(cmd)) => run_install(cmd)?,
Some(Commands::Docs(cmd)) => run_docs(cmd)?,
Some(Commands::Check(cmd)) => run_check(cmd)?,
Some(Commands::Test(cmd)) => run_tests(cmd)?,
Some(Commands::Fmt(cmd)) => run_fmt(cmd)?,
Some(Commands::Graph(cmd)) => match cmd.command {
GraphSubcommand::Dump(cmd) => run_graph_dump(cmd)?,
GraphSubcommand::Import(cmd) => run_graph_import(cmd)?,
},
Some(Commands::Rewrite(cmd)) => run_rewrite(cmd)?,
Some(Commands::Skill(cmd)) => run_skill(cmd)?,
Some(Commands::Jdk(cmd)) => match cmd.command {
JdkSubcommand::List(_) => {
let jdks = jbx::jdk::list_jdks()?;
if jdks.is_empty() {
println!("No JDKs found.");
} else {
for (major, root) in &jdks {
println!("{major}\t{major}.x\t{}", root.display());
}
}
0
}
JdkSubcommand::Install(cmd) => {
let version = jbx::jdk::parse_java_version_directive(&cmd.version)?;
let jdk_root = jbx::jdk::install_jdk(version)?;
println!("JDK {} installed to {}", version, jdk_root.display());
0
}
JdkSubcommand::Home(cmd) => {
let version = jbx::jdk::parse_java_version_directive(&cmd.version)?;
let jdk_root = jbx::jdk::find_jdk(version, false)?;
println!("{}", jdk_root.display());
0
}
},
None => {
let Some(script) = cli.script else {
eprintln!("No script or Maven coordinate specified. Try: jbx run Hello.java");
std::process::exit(2);
};
if should_run_as_maven_tool_shorthand(&script) {
maven_tool::run(maven_tool::MavenToolOptions {
coordinate: script.to_string_lossy().into_owned(),
repos: cli.repos,
cache_dir: cli.cache_dir,
main_class: cli.main_class,
args: cli.args,
progress,
})?
} else {
run_java(apply_alias_to_run(RunOptions {
script,
script_args: cli.args,
extra_deps: Vec::new(),
extra_repos: Vec::new(),
extra_sources: Vec::new(),
extra_files: Vec::new(),
classpath: Vec::new(),
javac_options: Vec::new(),
runtime_options: Vec::new(),
java_agents: Vec::new(),
java_version: None,
main_class: None,
cache_dir: None,
trust_remote: false,
progress,
})?)?
}
}
};
std::process::exit(code);
}
#[cfg(test)]
mod test_command_unit_tests {
use super::*;
#[test]
fn xml_attr_preserves_quoted_values_with_spaces() {
let attrs = r#"classname="ExampleTest" name="[1] display name with spaces" time="0.001""#;
assert_eq!(
xml_attr(attrs, "name").as_deref(),
Some("[1] display name with spaces")
);
assert_eq!(xml_attr(attrs, "classname").as_deref(), Some("ExampleTest"));
}
#[test]
fn javadoc_type_description_is_extracted_and_rendered() {
let html = r#"
<div class="description">
<pre>public class <span class="typeNameLabel">Example</span></pre>
<div class="block"><p>Example reads and writes JSON.
It supports <code>tree</code> values.</p></div>
</div>
<div class="member-signature">public void run()</div>
"#;
let ty = parse_javadoc_type_page("com/example/Example.html", html).unwrap();
assert_eq!(
ty["description"],
"Example reads and writes JSON. It supports `tree` values."
);
let markdown = render_docs_markdown("example", None, &[], &[ty]).unwrap();
assert!(
markdown.contains("Example reads and writes JSON. It supports `tree` values."),
"{markdown}"
);
}
#[test]
fn javadoc_full_signatures_parse_fields_constructors_methods_and_parameters() {
let html = r#"
<div class="description">
<pre>public class <span class="typeNameLabel">Example</span></pre>
<div class="block">Useful example.
<pre>Example example = new Example("demo", Map.of());
String value = example.readValue("{}", String.class);</pre>
</div>
</div>
<ul class="blockList">
<li class="blockList">
<h4>COUNT</h4>
<pre>public static final int COUNT</pre>
</li>
<li class="blockList">
<h4>Example</h4>
<pre>public Example( String name,
java.util.Map<String, Object> options)
throws IOException</pre>
</li>
<li class="blockList">
<h4>readValue</h4>
<pre>public final <T> T readValue( String content,
Class <T> valueType,
String... features)
throws IOException,
IllegalArgumentException</pre>
</li>
</ul>
"#;
let ty = parse_javadoc_type_page("com/example/Example.html", html).unwrap();
assert_eq!(ty["fields"][0]["name"], "COUNT");
assert_eq!(ty["fields"][0]["type"], "int");
assert!(ty["description"].as_str().unwrap().contains("```"));
assert!(ty["description"]
.as_str()
.unwrap()
.contains("example.readValue"));
assert_eq!(ty["examples"].as_array().unwrap().len(), 1);
assert!(ty["examples"][0]
.as_str()
.unwrap()
.contains("example.readValue"));
assert_eq!(ty["constructors"][0]["name"], "Example");
assert_eq!(ty["constructors"][0]["parameters"][0]["name"], "name");
assert_eq!(ty["constructors"][0]["parameters"][0]["type"], "String");
assert_eq!(ty["constructors"][0]["parameters"][1]["name"], "options");
assert_eq!(
ty["constructors"][0]["parameters"][1]["type"],
"java.util.Map<String, Object>"
);
assert_eq!(ty["constructors"][0]["throws"][0], "IOException");
assert_eq!(ty["methods"][0]["name"], "readValue");
assert_eq!(ty["methods"][0]["modifiers"][0], "public");
assert_eq!(ty["methods"][0]["modifiers"][1], "final");
assert_eq!(ty["methods"][0]["returnType"], "T");
assert_eq!(ty["methods"][0]["parameters"][0]["name"], "content");
assert_eq!(ty["methods"][0]["parameters"][1]["name"], "valueType");
assert_eq!(ty["methods"][0]["parameters"][1]["type"], "Class<T>");
assert_eq!(ty["methods"][0]["parameters"][2]["name"], "features");
assert_eq!(ty["methods"][0]["parameters"][2]["type"], "String...");
assert_eq!(ty["methods"][0]["throws"][1], "IllegalArgumentException");
}
#[test]
fn markdown_renders_type_members_for_agent_context() {
let ty = serde_json::json!({
"kind": "class",
"name": "Example",
"qualifiedName": "com.example.Example",
"description": "Useful example.",
"examples": ["Example example = new Example(\"demo\");\nString value = example.readValue(\"{}\");"],
"fields": [{"name": "COUNT", "type": "int", "modifiers": ["public", "static", "final"]}],
"constructors": [{"name": "Example", "parameters": [{"name": "name", "type": "String"}], "throws": ["IOException"]}],
"methods": [{"name": "readValue", "returnType": "T", "parameters": [{"name": "content", "type": "String"}], "throws": ["IOException"]}]
});
let markdown = render_docs_markdown("example", None, &[], &[ty]).unwrap();
assert!(markdown.contains("### Examples"), "{markdown}");
assert!(
markdown.contains("String value = example.readValue"),
"{markdown}"
);
assert!(markdown.contains("### Fields"), "{markdown}");
assert!(
markdown.contains("- `public static final int COUNT`"),
"{markdown}"
);
assert!(markdown.contains("### Constructors"), "{markdown}");
assert!(
markdown.contains("- `Example(String name) throws IOException`"),
"{markdown}"
);
assert!(markdown.contains("### Methods"), "{markdown}");
assert!(
markdown.contains("- `T readValue(String content) throws IOException`"),
"{markdown}"
);
}
#[test]
fn rewrite_module_coordinate_keeps_versioned_java_modules_in_core_group() {
assert_eq!(
rewrite_module_coordinate("java-8", "8.60.0"),
"org.openrewrite:rewrite-java-8:8.60.0"
);
assert_eq!(
rewrite_module_coordinate("java-11", "8.60.0"),
"org.openrewrite:rewrite-java-11:8.60.0"
);
assert_eq!(
rewrite_module_coordinate("java-17", "8.60.0"),
"org.openrewrite:rewrite-java-17:8.60.0"
);
assert_eq!(
rewrite_module_coordinate("spring", "6.4.0"),
"org.openrewrite.recipe:rewrite-spring:6.4.0"
);
}
#[test]
fn rewrite_module_search_query_escapes_user_input() {
assert_eq!(
rewrite_module_search_query("org.openrewrite.recipe", Some("yaml")),
"g:org.openrewrite.recipe AND yaml"
);
assert_eq!(
rewrite_module_search_query("bad group:thing", Some("OR a:*")),
"g:bad\\ group\\:thing AND OR\\ a\\:\\*"
);
}
#[test]
fn rewrite_runtime_parser_matches_selected_jdk_major() {
assert_eq!(
rewrite_java_runtime_coordinate(8, "8.56.1"),
"org.openrewrite:rewrite-java-8:8.56.1"
);
assert_eq!(
rewrite_java_runtime_coordinate(11, "8.56.1"),
"org.openrewrite:rewrite-java-11:8.56.1"
);
assert_eq!(
rewrite_java_runtime_coordinate(17, "8.56.1"),
"org.openrewrite:rewrite-java-17:8.56.1"
);
assert_eq!(
rewrite_java_runtime_coordinate(21, "8.56.1"),
"org.openrewrite:rewrite-java-21:8.56.1"
);
assert_eq!(
rewrite_java_runtime_coordinate(25, "8.56.1"),
"org.openrewrite:rewrite-java-21:8.56.1"
);
}
#[test]
fn junit_default_version_uses_resolver_latest_metadata() {
let (repo, handle) = metadata_repo(
"org/junit/platform/junit-platform-console-standalone/maven-metadata.xml",
"6.2.0",
);
let version = latest_tool_version(
"org.junit.platform",
"junit-platform-console-standalone",
&[repo],
)
.unwrap();
handle.join().unwrap();
assert_eq!(version, "6.2.0");
}
#[test]
fn palantir_default_version_uses_resolver_latest_metadata() {
let (repo, handle) = metadata_repo(
"com/palantir/javaformat/palantir-java-format/maven-metadata.xml",
"2.92.0",
);
let version =
latest_tool_version("com.palantir.javaformat", "palantir-java-format", &[repo])
.unwrap();
handle.join().unwrap();
assert_eq!(version, "2.92.0");
}
#[test]
fn tool_latest_version_is_cached_temporarily() {
let tmp = tempfile::tempdir().unwrap();
let (repo, handle) = metadata_repo(
"org/junit/platform/junit-platform-console-standalone/maven-metadata.xml",
"6.3.0",
);
let first = latest_cached_tool_version(
Some(tmp.path()),
"org.junit.platform",
"junit-platform-console-standalone",
std::slice::from_ref(&repo),
)
.unwrap();
handle.join().unwrap();
let second = latest_cached_tool_version(
Some(tmp.path()),
"org.junit.platform",
"junit-platform-console-standalone",
&[jbx::resolver::Repository {
id: "offline".to_string(),
url: "http://127.0.0.1:9".to_string(),
}],
)
.unwrap();
assert_eq!(first, "6.3.0");
assert_eq!(second, "6.3.0");
}
fn metadata_repo(
expected_path: &'static str,
release: &'static str,
) -> (jbx::resolver::Repository, std::thread::JoinHandle<()>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0_u8; 2048];
let read = std::io::Read::read(&mut stream, &mut request).unwrap();
let request = String::from_utf8_lossy(&request[..read]);
assert!(
request.starts_with(&format!("GET /{expected_path} ")),
"{request}"
);
let body = format!(
r#"<metadata><versioning><latest>{release}</latest><release>{release}</release><versions><version>{release}</version></versions></versioning></metadata>"#
);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/xml\r\nConnection: close\r\n\r\n{}",
body.len(), body
);
std::io::Write::write_all(&mut stream, response.as_bytes()).unwrap();
});
(
jbx::resolver::Repository {
id: "test".to_string(),
url: format!("http://{addr}"),
},
handle,
)
}
}