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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .config/jp/tools/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ mod fs;
mod git;
mod github;
mod plan;
mod swift;
mod ticket;
mod unix;
mod util;
Expand All @@ -26,6 +27,7 @@ pub async fn run(ctx: Context, t: Tool) -> util::ToolResult {
s if s.starts_with("debug_jp_") => debug_jp::run(ctx, t).await,
s if s.starts_with("web_") => web::run(ctx, t).await,
s if s.starts_with("git_") => git::run(ctx, t).await,
s if s.starts_with("swift_") => swift::run(ctx, t).await,
s if s.starts_with("unix_") => unix::run(ctx, t),
s if s.starts_with("ticket_") => ticket::run(ctx, t),
"plan" => plan::run(ctx, t),
Expand Down
175 changes: 175 additions & 0 deletions .config/jp/tools/src/swift.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
//! Tools for the macOS app in `apps/macos`.
//!
//! These mirror the `cargo_*` tools: each shells out to a toolchain binary from
//! the repository root and reports diagnostics rather than raw build logs.
//!
//! Every tool that builds brings its inputs up to date first, through
//! [`prepare`], so a fresh checkout needs no setup step.

use jp_tool::Context;

use crate::{
Tool,
util::{
ToolResult,
runner::{ProcessOutput, ProcessRunner},
truncate, unknown_tool,
},
};

mod check;
mod format;
mod report;
mod test;
mod test_ui;

use check::swift_check;
use format::swift_format;
use test::swift_test;
use test_ui::swift_test_ui;

/// Cap for compiler diagnostics embedded in a tool result.
///
/// `xcodebuild` repeats the failing command line in full for every error, so
/// the tail of a broken build is almost entirely noise.
const MAX_DIAGNOSTIC_BYTES: usize = 32_000;

/// The generated Xcode project, relative to the repository root.
const PROJECT_PATH: &str = "apps/macos/JP.xcodeproj";

/// The `XcodeGen` manifest the project is generated from.
const PROJECT_SPEC: &str = "apps/macos/project.yml";

/// The directory the generated project is written into.
const PROJECT_DIR: &str = "apps/macos";

/// The scheme covering the app and its test bundle.
const SCHEME: &str = "JP";

/// Swift sources formatted and linted by these tools.
const SOURCE_PATHS: &[&str] = &[
"apps/macos/Sources",
"apps/macos/Tests",
"apps/macos/UITests",
"apps/macos/Tools/jpdrive/Sources",
"apps/macos/Tools/jpdrive/Tests",
];

pub async fn run(ctx: Context, t: Tool) -> ToolResult {
match t.name.trim_start_matches("swift_") {
"check" => swift_check(&ctx, t.opt("configuration")?).await,
"test" => swift_test(&ctx, t.opt("testname")?, t.opt("target")?).await,
"test_ui" => swift_test_ui(&ctx, t.opt("tests")?).await,
// `check` is a call parameter, not a tool config option, so it is read
// with `opt` rather than `option_or`.
"format" => swift_format(&ctx, t.opt("check")?.unwrap_or(false)).await,
_ => unknown_tool(t),
}
}

/// Build the generated inputs an Xcode build depends on.
///
/// Returns a failure message, or `None` when both steps succeeded.
///
/// Two things are generated rather than committed: the static library with its
/// C header, and the Xcode project.
/// Both steps are idempotent and cheap when already up to date.
///
/// The header cannot be left to the project's own build phase.
/// Xcode scans the bridging header while planning the build, before any script
/// phase runs, so a missing header fails the scan rather than triggering the
/// phase that would have produced it.
fn prepare<R: ProcessRunner>(
ctx: &Context,
profile: &str,
runner: &R,
) -> Result<Option<String>, std::io::Error> {
// Going through `just` keeps the profile-to-directory mapping and the
// cbindgen invocation defined in one place.
let ffi = runner.run("just", &["build-ffi", profile], &ctx.root)?;
if !ffi.status.is_success() {
return Ok(Some(format!(
"Building `jp_ffi` failed:\n\n```\n{}\n```",
report(&ffi, "just build-ffi")
)));
}

let project = match runner.run(
"xcodegen",
&["generate", "--spec", PROJECT_SPEC, "--project", PROJECT_DIR],
&ctx.root,
) {
Ok(project) => project,

// A binary that is not installed fails to spawn rather than running and
// exiting non-zero, so it never reaches the branch below. The runner's
// `unchecked` suppresses a bad exit status, not a failure to start.
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(Some(
"`xcodegen` is not installed. The Xcode project is generated from \
`apps/macos/project.yml` rather than committed, so it is needed to build the app \
at all. Install it with `brew install xcodegen`."
.to_owned(),
));
}

Err(error) => return Err(error),
};
if !project.status.is_success() {
return Ok(Some(format!(
"Generating `{PROJECT_PATH}` failed:\n\n```\n{}\n```",
report(&project, "xcodegen")
)));
}

Ok(None)
}

/// The diagnostics from a process, preferring stdout and falling back to
/// stderr.
///
/// `xcodebuild` reports compiler diagnostics on stdout and its own failures on
/// stderr; most other tools use stderr for both.
/// `label` names the program for the case where it printed nothing at all.
fn report(output: &ProcessOutput, label: &str) -> String {
let stdout = strip(&output.stdout);
if !stdout.is_empty() {
return stdout;
}

let stderr = strip(&output.stderr);
if !stderr.is_empty() {
return stderr;
}

format!(
"{label} exited with status {} and no diagnostics.",
output.status
)
}

/// Strip ANSI escapes and trim, capping the result.
fn strip(output: &str) -> String {
let stripped = strip_ansi_escapes::strip_str(output);
truncate(stripped.trim(), MAX_DIAGNOSTIC_BYTES)
}

/// The message from a failed outcome.
///
/// Failures come back as `Ok(Outcome::Error { .. })` rather than `Err`, so
/// tests have to reach into the outcome to assert on the message.
///
/// # Panics
///
/// Panics if the outcome is not an error.
#[cfg(test)]
fn error_message(outcome: jp_tool::Outcome) -> String {
match outcome {
jp_tool::Outcome::Error { message, .. } => message,
other => panic!("expected an error outcome, got {other:?}"),
}
}

#[cfg(test)]
#[path = "swift_tests.rs"]
mod tests;
71 changes: 71 additions & 0 deletions .config/jp/tools/src/swift/check.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
use jp_tool::Context;

use super::{PROJECT_PATH, SCHEME, prepare, report, strip};
use crate::util::{
ToolResult, error,
runner::{DuctProcessRunner, ProcessRunner},
};

pub(crate) async fn swift_check(ctx: &Context, configuration: Option<String>) -> ToolResult {
swift_check_impl(ctx, configuration.as_deref(), &DuctProcessRunner)
}

fn swift_check_impl<R: ProcessRunner>(
ctx: &Context,
configuration: Option<&str>,
runner: &R,
) -> ToolResult {
let configuration = configuration.unwrap_or("Debug");
// Cargo's profile directory for a configuration, matching `CARGO_PROFILE` in
// the Xcode project.
let profile = if configuration == "Release" {
"release"
} else {
"debug"
};

if let Some(failure) = prepare(ctx, profile, runner)? {
return error(failure);
}

let output = runner.run(
"xcodebuild",
&[
"build",
"-project",
PROJECT_PATH,
"-scheme",
SCHEME,
"-configuration",
configuration,
"-destination",
"platform=macOS",
// Nothing is run or installed, so skip signing and its keychain
// prompts.
"CODE_SIGNING_ALLOWED=NO",
"-quiet",
],
&ctx.root,
)?;

let diagnostics = strip(&output.stdout);

if output.status.is_success() {
return Ok(if diagnostics.is_empty() {
"Build succeeded. No warnings or errors found."
.to_owned()
.into()
} else {
format!("```\n{diagnostics}\n```\n").into()
});
}

error(format!(
"Swift build failed:\n\n```\n{}\n```",
report(&output, "xcodebuild")
))
}

#[cfg(test)]
#[path = "check_tests.rs"]
mod tests;
Loading
Loading